refactor(tools): updatePageContent → updatePageMarkdown; внешний MCP: +updatePageMarkdown, −import_page_markdown (#411)

Поверхности записи «целым телом» были несимметричны: у in-app агента полная
замена тела markdown называлась updatePageContent (имя не про формат, тогда как
парный updatePageJson — про JSON), а у внешнего MCP голого plain-body-replace
не было вовсе (только import_page_markdown — на деле парсер round-trip к
export_page_markdown, не plain-replace). Пара должна быть updatePageMarkdown /
updatePageJson.

Пост-Фаза-1б архитектура (реестр + циклы по обоим хостам):
- новая shared-спека updatePageMarkdown (mcpName update_page_markdown, inAppKey
  updatePageMarkdown, tier как у updatePageJson) с execute (client, {pageId,
  content, title}) => client.updatePage(...) — тот же путь updatePageContentRealtime
  → markdownToProseMirrorCanonical, ^[...]-сноски парсятся. Реестровый цикл
  регистрирует её на ОБОИХ хостах автоматически. Добавлен 'updatePage' в
  Pick DocmostClientLike.
- import_page_markdown убран с внешнего MCP через inAppOnly:true у спеки
  importPageMarkdown — MCP-цикл и генератор инвентаря её пропускают, in-app
  агент сохраняет importPageMarkdown; спека и client-метод НЕ удалены.
- удалён inline in-app updatePageContent tool (теперь из реестра под inAppKey
  updatePageMarkdown) + его INLINE_TOOL_TIERS-энтри.
- ROUTING_PROSE: bulk-rewrite ссылается на update_page_markdown|update_page_json;
  убрано упоминание import_page_markdown; инвентарь генерируется из catalogLine.
- лейбл-мапы chat-markdown.util (en/ru), человекочитаемые метки не тронуты.
- НЕ тронуты одноимённые внутренности: PageService.updatePageContent,
  updatePageContentRealtime, collaboration.handler — переименовано только имя тула.

Тесты: updatePageMarkdown на обеих поверхностях с идентичной схемой, forward в
client.updatePage; import_page_markdown ОТСУТСТВУЕТ на MCP, присутствует in-app;
^[...]→сноски покрыт через collaboration.test. CHANGELOG BREAKING + миграция;
README/README.ru пакета обновлены. Гейт: mcp node --test 646/646, server jest
259, tsc чисто. Первый линк breaking-окна #416 (#411→#412→#413→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:33:32 +03:00
parent ea99d4fe63
commit ccef8d0a7c
15 changed files with 244 additions and 64 deletions
+17
View File
@@ -10,6 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Breaking Changes
- **External MCP: `import_page_markdown` removed, `update_page_markdown` added.**
The external `/mcp` surface no longer exposes `import_page_markdown` (the
round-trip parser for a self-contained *exported* Docmost-Markdown file). In
its place it now exposes **`update_page_markdown`** — a plain-Markdown
full-body replace (`{pageId, content, title?}`) that pairs with
`update_page_json`, re-imports the whole body (block ids regenerate) and
parses Docmost-flavoured markdown including `^[...]` inline footnotes.
*Migration:* MCP clients that called `import_page_markdown` to overwrite a
page's body from Markdown should call `update_page_markdown` instead (pass the
markdown as `content`). Round-tripping an exported Docmost-Markdown file with
comment anchors/diagrams is no longer available on the external MCP surface;
export remains via `export_page_markdown`. The in-app AI agent is unaffected —
it keeps both `importPageMarkdown` and the renamed `updatePageMarkdown` (was
`updatePageContent`). The total MCP tool count is unchanged (−1 / +1). (#411)
### Added ### Added
- **Place several images side by side in a row.** A new "Inline (side by - **Place several images side by side in a row.** A new "Inline (side by
@@ -75,7 +75,7 @@ const LABELS: Record<
searchPages: 'Searched pages', searchPages: 'Searched pages',
getPage: 'Read page', getPage: 'Read page',
createPage: 'Created page', createPage: 'Created page',
updatePageContent: 'Updated page', updatePageMarkdown: 'Updated page',
renamePage: 'Renamed page', renamePage: 'Renamed page',
movePage: 'Moved page', movePage: 'Moved page',
deletePage: 'Deleted page (to trash)', deletePage: 'Deleted page (to trash)',
@@ -96,7 +96,7 @@ const LABELS: Record<
searchPages: 'Искал по страницам', searchPages: 'Искал по страницам',
getPage: 'Прочитал страницу', getPage: 'Прочитал страницу',
createPage: 'Создал страницу', createPage: 'Создал страницу',
updatePageContent: 'Обновил страницу', updatePageMarkdown: 'Обновил страницу',
renamePage: 'Переименовал страницу', renamePage: 'Переименовал страницу',
movePage: 'Переместил страницу', movePage: 'Переместил страницу',
deletePage: 'Удалил страницу (в корзину)', deletePage: 'Удалил страницу (в корзину)',
@@ -23,7 +23,7 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs
// sync. // sync.
const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({ const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({
DocmostClient, DocmostClient,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>, sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
}); });
/** /**
@@ -275,6 +275,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
const patchNodeCalls: unknown[][] = []; const patchNodeCalls: unknown[][] = [];
const insertNodeCalls: unknown[][] = []; const insertNodeCalls: unknown[][] = [];
const updatePageJsonCalls: unknown[][] = []; const updatePageJsonCalls: unknown[][] = [];
const updatePageCalls: unknown[][] = [];
const fakeClient: FakeDocmostClient = { const fakeClient: FakeDocmostClient = {
patchNode: (...args: unknown[]) => { patchNode: (...args: unknown[]) => {
@@ -289,6 +290,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
updatePageJsonCalls.push(args); updatePageJsonCalls.push(args);
return Promise.resolve({ ok: true }); return Promise.resolve({ ok: true });
}, },
// Backs the plain-Markdown full-body-replace tool updatePageMarkdown (#411).
updatePage: (...args: unknown[]) => {
updatePageCalls.push(args);
return Promise.resolve({ success: true });
},
}; };
const tokenServiceStub = { const tokenServiceStub = {
@@ -302,6 +308,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
patchNodeCalls.length = 0; patchNodeCalls.length = 0;
insertNodeCalls.length = 0; insertNodeCalls.length = 0;
updatePageJsonCalls.length = 0; updatePageJsonCalls.length = 0;
updatePageCalls.length = 0;
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue( jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () { mockLoaded(function () {
return fakeClient as DocmostClientLike; return fakeClient as DocmostClientLike;
@@ -437,6 +444,54 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
).rejects.toThrow('content was a string but not valid JSON'); ).rejects.toThrow('content was a string but not valid JSON');
expect(updatePageJsonCalls).toHaveLength(0); expect(updatePageJsonCalls).toHaveLength(0);
}); });
// #411: the plain-Markdown full-body-replace tool is now the shared
// `updatePageMarkdown` (was inline `updatePageContent`). It forwards to
// client.updatePage(pageId, content, title) -> updatePageContentRealtime ->
// markdownToProseMirrorCanonical, so `^[...]` footnotes materialize.
it('updatePageMarkdown forwards { pageId, content, title } to client.updatePage', async () => {
const tools = await buildTools();
await tools.updatePageMarkdown.execute(
{ pageId: 'p1', content: 'Body^[a note]', title: 'New title' } as never,
{} as never,
);
expect(updatePageCalls).toHaveLength(1);
expect(updatePageCalls[0]).toEqual(['p1', 'Body^[a note]', 'New title']);
});
it('updatePageMarkdown returns the RAW client result in-app (deliberate #411 shape change, documented on the spec)', async () => {
const tools = await buildTools();
// Registry canonical execute returns client.updatePage's result verbatim.
// The old inline tool projected to { pageId, updated }; the rename now
// surfaces the raw result (nothing reads the removed `.updated`; the raw
// shape carries footnote/verify warnings and matches the on-both-hosts
// registry convention). fakeClient.updatePage resolves { success: true }.
const result = await tools.updatePageMarkdown.execute(
{ pageId: 'p1', content: '# Hi' } as never,
{} as never,
);
expect(result).toEqual({ success: true });
});
it('updatePageMarkdown forwards title=undefined when omitted', async () => {
const tools = await buildTools();
await tools.updatePageMarkdown.execute(
{ pageId: 'p1', content: '# Hi' } as never,
{} as never,
);
expect(updatePageCalls[0]).toEqual(['p1', '# Hi', undefined]);
});
// #411 surface split: the plain-Markdown replace tool exists in-app under the
// new key; the OLD inline updatePageContent key is gone; importPageMarkdown is
// still present IN-APP (only the external MCP surface drops it — asserted in
// packages/mcp/test/unit/tool-inventory.test.mjs).
it('exposes updatePageMarkdown in-app, no legacy updatePageContent, keeps importPageMarkdown', async () => {
const tools = await buildTools();
expect(tools.updatePageMarkdown).toBeDefined();
expect((tools as Record<string, unknown>).updatePageContent).toBeUndefined();
expect(tools.importPageMarkdown).toBeDefined();
});
}); });
/** /**
@@ -294,8 +294,8 @@ export class AiChatToolsService {
// The in-app toolset. It starts with the tools kept INLINE here for a // The in-app toolset. It starts with the tools kept INLINE here for a
// documented per-layer reason: an intentional behaviour/schema divergence from // documented per-layer reason: an intentional behaviour/schema divergence from
// the standalone MCP surface (searchPages' hybrid RRF, updatePageContent's // the standalone MCP surface (searchPages' hybrid RRF,
// Markdown write, transformPage's guardrailed shorter schema), a // transformPage's guardrailed shorter schema), a
// snake_case/camelCase naming clash the shared registry forbids (getTable vs // snake_case/camelCase naming clash the shared registry forbids (getTable vs
// the MCP `table_get`), per-request state the registry loop cannot provide // the MCP `table_get`), per-request state the registry loop cannot provide
// (getCurrentPage reads the resolved openedPage; searchPages closes over the // (getCurrentPage reads the resolved openedPage; searchPages closes over the
@@ -438,28 +438,13 @@ export class AiChatToolsService {
}), }),
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) --- // --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
//
updatePageContent: tool({ // NOTE (issue #411): the plain-Markdown full-body-replace tool is no longer
description: // inline here — it moved to @docmost/mcp's SHARED_TOOL_SPECS as
"Replace a page's body with new Markdown content (and optionally its " + // `updatePageMarkdown` (was inline `updatePageContent`) so it registers on
'title). Reversible: the previous version is kept in page history.', // BOTH the external MCP and the in-app agent. The registry loop below adds
inputSchema: modelFriendlyInput({ // it under its inAppKey. importPageMarkdown stays a shared spec too (now
pageId: z.string().describe('The id of the page to update.'), // inAppOnly — dropped from the external MCP surface, kept in-app).
content: z.string().describe('The new page body as Markdown.'),
title: z
.string()
.optional()
.describe('Optional new title for the page.'),
}),
execute: async ({ pageId, content, title }) => {
// updatePage mutates the live collab doc -> provenance flows from the
// collab-token provider. Returns { success, modified, message, pageId }.
const result = (await client.updatePage(pageId, content, title)) as {
success?: boolean;
};
return { pageId, updated: result?.success ?? true };
},
}),
listSidebarPages: tool({ listSidebarPages: tool({
description: description:
@@ -283,7 +283,7 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => {
DocmostClient: function () { DocmostClient: function () {
return fakeClient as DocmostClientLike; return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor, } as unknown as loader.DocmostClientCtor,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>, sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
// Wire the REAL factory so the in-app path is exercised end to end. // Wire the REAL factory so the in-app path is exercised end to end.
createCommentSignalTracker: createCommentSignalTracker:
createCommentSignalTracker as unknown as loader.CommentSignalTrackerFactory, createCommentSignalTracker as unknown as loader.CommentSignalTrackerFactory,
@@ -70,7 +70,7 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
// Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal // Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal
// above otherwise narrows to a union where some members lack buildShape. // above otherwise narrows to a union where some members lack buildShape.
const specEntries = Object.entries(SHARED_TOOL_SPECS) as Array< const specEntries = Object.entries(SHARED_TOOL_SPECS) as unknown as Array<
[string, loader.SharedToolSpec] [string, loader.SharedToolSpec]
>; >;
@@ -123,7 +123,7 @@ describe('deferred catalog ↔ live forUser() toolset partition (#332, F3)', ()
DocmostClient: function () { DocmostClient: function () {
return {} as DocmostClientLike; return {} as DocmostClientLike;
} as unknown as loader.DocmostClientCtor, } as unknown as loader.DocmostClientCtor,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>, sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
}); });
const service = new AiChatToolsService( const service = new AiChatToolsService(
{ {
@@ -124,12 +124,9 @@ export const INLINE_TOOL_TIERS: Record<
// --- deferred inline --- // --- deferred inline ---
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and // NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they // exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
// carry their own deferred tier + catalogLine there. // carry their own deferred tier + catalogLine there. updatePageContent moved
updatePageContent: { // there too as updatePageMarkdown (#411) — a shared registry spec now, so it
tier: 'deferred', // is no longer an inline tier entry.
catalogLine:
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
},
listSidebarPages: { listSidebarPages: {
tier: 'deferred', tier: 'deferred',
catalogLine: catalogLine:
+15 -10
View File
@@ -155,6 +155,10 @@ All 41 tools, grouped by what you'd reach for them.
- **`update_page_json`** — Replace a page's entire content with a ProseMirror document - **`update_page_json`** — Replace a page's entire content with a ProseMirror document
(bulk rewrites, or when nodes lack ids). `content` is optional — omit it to update only (bulk rewrites, or when nodes lack ids). `content` is optional — omit it to update only
the title. Keeps the block ids you pass in, so heading anchors and history stay stable. the title. Keeps the block ids you pass in, so heading anchors and history stay stable.
- **`update_page_markdown`** — Replace a page's body (and optionally its title) with new
**plain Markdown**. The whole body is re-imported (block ids regenerate — for surgical or
id-preserving edits prefer `edit_page_text` / `patch_node` / `update_page_json`).
Docmost-flavoured markdown is parsed, including `^[...]` inline footnotes.
- **`docmost_transform`** — The agent-native editing interface: instead of retyping a - **`docmost_transform`** — The agent-native editing interface: instead of retyping a
document, the agent **writes a function that fixes it**. Edit a page by running an document, the agent **writes a function that fixes it**. Edit a page by running an
arbitrary **`(doc, ctx) => doc` JavaScript transform** against its *live* ProseMirror arbitrary **`(doc, ctx) => doc` JavaScript transform** against its *live* ProseMirror
@@ -184,13 +188,13 @@ All 41 tools, grouped by what you'd reach for them.
- **`export_page_markdown`** — Export a page to a single self-contained, **lossless - **`export_page_markdown`** — Export a page to a single self-contained, **lossless
Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
and diagrams, and a trailing comments-thread block. Built for a download → edit body → and diagrams, and a trailing comments-thread block. To replace a page's body from plain
`import_page_markdown` round-trip that preserves everything, including comment highlights. authoring Markdown, use `update_page_markdown`.
- **`import_page_markdown`** — Replace a page's content from a Docmost-flavoured Markdown
file produced by `export_page_markdown`, restoring comment-highlight anchors and diagrams > **Removed in this release:** `import_page_markdown` (the round-trip parser for an
from their inline HTML. (Comment *threads* in the file are not re-created on the server — > exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
only the page body and inline comment marks are written; manage threads via the comment > To replace a page's body from Markdown, use **`update_page_markdown`** (plain Markdown
tools/UI.) > body replace). See the CHANGELOG for the migration note.
### Images ### Images
@@ -256,7 +260,8 @@ so capable clients steer the model automatically.
`delete_node`, addressing the node by its `attrs.id` from `get_page_json`. `delete_node`, addressing the node by its `attrs.id` from `get_page_json`.
- **Images**: `insert_image` / `replace_image`. - **Images**: `insert_image` / `replace_image`.
- **A new page**: `create_page`. - **A new page**: `create_page`.
- **Bulk rewrite, or nodes without ids**: `update_page_json`. - **Bulk rewrite, or nodes without ids**: `update_page_json` (ProseMirror) or
`update_page_markdown` (plain Markdown body replace).
- **Multi-step / scripted rewrite** (renumbering, footnotes, coordinated edits): - **Multi-step / scripted rewrite** (renumbering, footnotes, coordinated edits):
`docmost_transform` — preview with `dryRun`, then apply. `docmost_transform` — preview with `dryRun`, then apply.
- **Copy a whole page's content from another page** (server-side): `copy_page_content`. - **Copy a whole page's content from another page** (server-side): `copy_page_content`.
@@ -269,8 +274,8 @@ so capable clients steer the model automatically.
`get_node`. `get_node`.
- **Tables** (add/remove a row, set a cell): `table_get` / `table_insert_row` / - **Tables** (add/remove a row, set a cell): `table_get` / `table_insert_row` /
`table_delete_row` / `table_update_cell`. `table_delete_row` / `table_update_cell`.
- **Round-trip a page as Markdown** (download, edit, re-upload losslessly with comments): - **Export a page as self-contained Markdown** (with comment anchors): `export_page_markdown`.
`export_page_markdown` / `import_page_markdown`. - **Replace a page's body from Markdown**: `update_page_markdown`.
--- ---
+15 -11
View File
@@ -161,6 +161,10 @@ Docmost-MCP не сочетают:
перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить
только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история
остаются стабильными. остаются стабильными.
- **`update_page_markdown`** — Заменить тело страницы (и опционально заголовок) новым
**обычным Markdown**. Всё тело переимпортируется (id блоков перегенерируются — для
хирургических правок или сохранения id используйте `edit_page_text` / `patch_node` /
`update_page_json`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`.
- **`docmost_transform`** — Агентоориентированный интерфейс редактирования: вместо - **`docmost_transform`** — Агентоориентированный интерфейс редактирования: вместо
перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует
страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом* страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом*
@@ -189,14 +193,13 @@ Docmost-MCP не сочетают:
- **`export_page_markdown`** — Экспортировать страницу в один самодостаточный, **lossless - **`export_page_markdown`** — Экспортировать страницу в один самодостаточный, **lossless
Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
диаграммами и завершающий блок тредов комментариев. Рассчитан на цикл «скачать → диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из
отредактировать тело → `import_page_markdown`», сохраняющий всё, включая выделения обычного авторского Markdown, используйте `update_page_markdown`.
комментариев.
- **`import_page_markdown`** — Заменить контент страницы из Markdown-файла в диалекте > **Удалено в этом релизе:** `import_page_markdown` (парсер round-trip для
Docmost, созданного `export_page_markdown`, восстанавливая якоря-выделения комментариев и > экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
диаграммы из их inline-HTML. (Треды комментариев из файла не пересоздаются на сервере — > Чтобы заменить тело страницы из Markdown, используйте **`update_page_markdown`** (замена
записываются только тело страницы и inline-марки комментариев; тредами управляйте через > тела обычным Markdown). См. заметку о миграции в CHANGELOG.
инструменты/UI комментариев.)
### Изображения ### Изображения
@@ -263,7 +266,8 @@ Docmost-MCP не сочетают:
`delete_node`, адресуя узел по его `attrs.id` из `get_page_json`. `delete_node`, адресуя узел по его `attrs.id` из `get_page_json`.
- **Изображения**: `insert_image` / `replace_image`. - **Изображения**: `insert_image` / `replace_image`.
- **Новая страница**: `create_page`. - **Новая страница**: `create_page`.
- **Массовая перезапись или узлы без id**: `update_page_json`. - **Массовая перезапись или узлы без id**: `update_page_json` (ProseMirror) или
`update_page_markdown` (замена тела обычным Markdown).
- **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки): - **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки):
`docmost_transform` — предпросмотр через `dryRun`, затем применение. `docmost_transform` — предпросмотр через `dryRun`, затем применение.
- **Скопировать контент целой страницы из другой** (на стороне сервера): - **Скопировать контент целой страницы из другой** (на стороне сервера):
@@ -278,8 +282,8 @@ Docmost-MCP не сочетают:
`get_node`. `get_node`.
- **Таблицы** (добавить/удалить строку, задать ячейку): `table_get` / `table_insert_row` / - **Таблицы** (добавить/удалить строку, задать ячейку): `table_get` / `table_insert_row` /
`table_delete_row` / `table_update_cell`. `table_delete_row` / `table_update_cell`.
- **Round-trip страницы через Markdown** (скачать, отредактировать, залить обратно без - **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `export_page_markdown`.
потерь, с комментариями): `export_page_markdown` / `import_page_markdown`. - **Заменить тело страницы из Markdown**: `update_page_markdown`.
--- ---
+5 -3
View File
@@ -41,10 +41,10 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
export const ROUTING_PROSE = export const ROUTING_PROSE =
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" + "Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" + "READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" + "EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace) or update_page_markdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" + "COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown."; "HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> export_page_markdown.";
/** /**
* A single generated inventory line: the tool's registered NAME + a one-line * A single generated inventory line: the tool's registered NAME + a one-line
@@ -96,6 +96,7 @@ const TOOL_FAMILY: Record<string, Family> = {
insert_node: "EDIT", insert_node: "EDIT",
delete_node: "EDIT", delete_node: "EDIT",
update_page_json: "EDIT", update_page_json: "EDIT",
update_page_markdown: "EDIT",
table_get: "EDIT", table_get: "EDIT",
table_update_cell: "EDIT", table_update_cell: "EDIT",
table_insert_row: "EDIT", table_insert_row: "EDIT",
@@ -128,7 +129,8 @@ const TOOL_FAMILY: Record<string, Family> = {
list_page_history: "HISTORY", list_page_history: "HISTORY",
restore_page_version: "HISTORY", restore_page_version: "HISTORY",
export_page_markdown: "HISTORY", export_page_markdown: "HISTORY",
import_page_markdown: "HISTORY", // import_page_markdown is now inAppOnly (#411) — it is not registered on the
// external MCP host, so it no longer appears in the generated inventory.
}; };
/** /**
+52
View File
@@ -81,6 +81,7 @@ export type DocmostClientLike = Pick<
| 'patchNode' | 'patchNode'
| 'insertNode' | 'insertNode'
| 'deleteNode' | 'deleteNode'
| 'updatePage'
| 'updatePageJson' | 'updatePageJson'
| 'tableInsertRow' | 'tableInsertRow'
| 'tableDeleteRow' | 'tableDeleteRow'
@@ -607,6 +608,12 @@ export const SHARED_TOOL_SPECS = {
importPageMarkdown: { importPageMarkdown: {
mcpName: 'import_page_markdown', mcpName: 'import_page_markdown',
inAppKey: 'importPageMarkdown', inAppKey: 'importPageMarkdown',
// IN-APP ONLY (issue #411): the external /mcp surface no longer exposes
// import_page_markdown — the registry loop in index.ts skips inAppOnly specs,
// so this stays available to the in-app agent (round-tripping an EXPORTED
// Docmost-Markdown file) but is removed from the public MCP tool set. Plain
// authoring-markdown body replace on the MCP surface is update_page_markdown.
inAppOnly: true,
description: description:
"Replace a page's content from a self-contained Docmost-flavoured " + "Replace a page's content from a self-contained Docmost-flavoured " +
'Markdown file produced by the page-Markdown export tool. Restores comment ' + 'Markdown file produced by the page-Markdown export tool. Restores comment ' +
@@ -1084,6 +1091,51 @@ export const SHARED_TOOL_SPECS = {
}, },
}, },
// Full-body replace from PLAIN Markdown (issue #411). Pairs with
// updatePageJson (which takes a ProseMirror document): this one takes a
// markdown string and re-imports the whole body. `client.updatePage` runs it
// through updatePageContentRealtime -> markdownToProseMirrorCanonical, so
// Docmost-flavoured markdown (incl. `^[...]` inline footnotes) is parsed and
// canonicalized. Distinct from importPageMarkdown, which re-imports a
// self-contained EXPORTED Docmost-Markdown file (with comment anchors +
// diagrams); this tool takes ordinary authoring markdown. Shared spec, so the
// registry loop registers it on BOTH hosts (external MCP + in-app agent) —
// #411 replaced the old inline in-app `updatePageContent` tool with this.
updatePageMarkdown: {
// snake_case for now; camelCase public MCP naming is the next issue (#412).
mcpName: 'update_page_markdown',
inAppKey: 'updatePageMarkdown',
description:
"Replace a page's body with new Markdown content (and optionally its " +
'title). The whole body is re-imported from the markdown (block ids ' +
'regenerate — for surgical or id-preserving edits use the find/replace, ' +
'node-patch or page-JSON tools instead). Docmost-flavoured markdown is ' +
'parsed, including `^[...]` inline footnotes. Reversible: the previous ' +
'version is kept in page history.',
tier: 'deferred',
catalogLine:
"updatePageMarkdown — replace a page's body (and optionally title) with new Markdown.",
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to update.'),
content: z.string().describe('The new page body as Markdown.'),
title: z
.string()
.optional()
.describe('Optional new title for the page.'),
}),
// Single canonical execute on BOTH hosts (the tool was in-app only before,
// so there is no external-MCP behavior to preserve). NOTE (rename #411): the
// old inline in-app tool projected the client result to { pageId, updated };
// the registry now returns the raw client result { success, modified,
// message, pageId, verify? } instead. Deliberate: no code reads the removed
// `.updated` field, the raw result is strictly more informative to the model
// (it surfaces footnote/verify warnings), and it matches the on-both-hosts
// registry convention. The result-shape change is the ONLY behavior delta of
// this rename; the write path (updatePage -> markdown canonicalize) is identical.
execute: (client, { pageId, content, title }) =>
client.updatePage(pageId as string, content as string, title as string | undefined),
},
exportPageMarkdown: { exportPageMarkdown: {
mcpName: 'export_page_markdown', mcpName: 'export_page_markdown',
inAppKey: 'exportPageMarkdown', inAppKey: 'exportPageMarkdown',
@@ -3,7 +3,7 @@
// footnote-canonical doc. These override the `replacePage` seam (symmetric to the // footnote-canonical doc. These override the `replacePage` seam (symmetric to the
// `mutatePage` seam used by the insert-footnote-wrapper test) to capture the // `mutatePage` seam used by the insert-footnote-wrapper test) to capture the
// persisted doc WITHOUT a live Hocuspocus collab socket. Symmetric to the // persisted doc WITHOUT a live Hocuspocus collab socket. Symmetric to the
// server-side focus specs for createPage / updatePageContent('replace'). // server-side focus specs for createPage / updatePage (markdown 'replace').
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { DocmostClient } from "../../build/client.js"; import { DocmostClient } from "../../build/client.js";
+33 -3
View File
@@ -27,7 +27,8 @@ const SRC = join(HERE, "..", "..", "src");
/** /**
* Every tool name the MCP server registers, scraped from the SOURCE: * Every tool name the MCP server registers, scraped from the SOURCE:
* - inline `server.registerTool("name", ...)` calls in index.ts; * - inline `server.registerTool("name", ...)` calls in index.ts;
* - shared specs in tool-specs.ts (`mcpName: 'name'`). * - shared specs in tool-specs.ts (`mcpName: 'name'`), EXCEPT `inAppOnly`
* specs, which the registry loop in index.ts SKIPS on the MCP host (#411).
* Same two registration mechanisms the old guard covered. * Same two registration mechanisms the old guard covered.
*/ */
function registeredToolNames() { function registeredToolNames() {
@@ -37,8 +38,13 @@ function registeredToolNames() {
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) { for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) {
names.add(m[1]); names.add(m[1]);
} }
for (const m of specsSrc.matchAll(/mcpName:\s*['"]([a-z0-9_]+)['"]/g)) { // Each spec is one `{ ... }` block; scrape its mcpName but skip a block that
names.add(m[1]); // carries `inAppOnly: true` (not registered on the external MCP host).
for (const block of specsSrc.split(/\n\s{2}\w+:\s*\{/)) {
const nameMatch = block.match(/mcpName:\s*['"]([a-z0-9_]+)['"]/);
if (!nameMatch) continue;
if (/inAppOnly:\s*true/.test(block)) continue;
names.add(nameMatch[1]);
} }
return names; return names;
} }
@@ -76,6 +82,30 @@ test("the inventory has no phantom tool (every line is a real registered tool)",
); );
}); });
// #411: the external MCP surface gains update_page_markdown and LOSES
// import_page_markdown (now inAppOnly). The in-app agent still keeps
// importPageMarkdown — asserted in the server-side contract spec.
test("update_page_markdown is on the MCP surface; import_page_markdown is NOT", () => {
const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
assert.ok(
inventory.has("update_page_markdown"),
"update_page_markdown should be registered on the external MCP surface",
);
assert.ok(
!inventory.has("import_page_markdown"),
"import_page_markdown must be dropped from the external MCP surface (#411)",
);
// And the routing prose no longer points MCP clients at it.
assert.ok(
!ROUTING_PROSE.includes("import_page_markdown"),
"ROUTING_PROSE still mentions the removed import_page_markdown",
);
assert.ok(
ROUTING_PROSE.includes("update_page_markdown"),
"ROUTING_PROSE should mention update_page_markdown",
);
});
test("every inventory line has a non-empty purpose", () => { test("every inventory line has a non-empty purpose", () => {
for (const line of buildToolInventoryLines()) { for (const line of buildToolInventoryLines()) {
assert.equal(typeof line.purpose, "string"); assert.equal(typeof line.purpose, "string");
@@ -145,3 +145,36 @@ test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () =>
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`); assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
} }
}); });
// #411: plain-Markdown full-body replace tool, paired with updatePageJson.
test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId, content, title }", () => {
const spec = SHARED_TOOL_SPECS.updatePageMarkdown;
assert.ok(spec, "updatePageMarkdown spec missing");
assert.equal(spec.mcpName, "update_page_markdown");
assert.equal(spec.inAppKey, "updatePageMarkdown");
// Registered on BOTH hosts (a shared spec, no inAppOnly/mcpOnly flag).
assert.notEqual(spec.inAppOnly, true);
assert.notEqual(spec.mcpOnly, true);
// Same tier as its JSON sibling.
assert.equal(spec.tier, SHARED_TOOL_SPECS.updatePageJson.tier);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["content", "pageId", "title"]);
// pageId + content required, title optional.
const schema = z.object(shape);
assert.doesNotThrow(() => schema.parse({ pageId: "p1", content: "# Hi" }));
assert.throws(() => schema.parse({ pageId: "p1" }));
// The description must flag the `^[...]` inline-footnote parse path so the
// markdown->footnote canonicalization guarantee stays documented (#411).
assert.match(spec.description, /\^\[/);
});
// #411: import_page_markdown is dropped from the EXTERNAL MCP surface but stays
// available to the in-app agent — encoded as inAppOnly on the shared spec.
test("importPageMarkdown spec is inAppOnly (removed from the external MCP surface, kept in-app)", () => {
const spec = SHARED_TOOL_SPECS.importPageMarkdown;
assert.ok(spec, "importPageMarkdown spec missing");
assert.equal(spec.inAppOnly, true);
// The spec + its client method are NOT deleted — only hidden from the MCP host.
assert.equal(spec.mcpName, "import_page_markdown");
assert.equal(spec.inAppKey, "importPageMarkdown");
});