From ccef8d0a7cda7dbbbd5e3d12a64dba9c11b88333 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 16:33:32 +0300 Subject: [PATCH] =?UTF-8?q?refactor(tools):=20updatePageContent=20?= =?UTF-8?q?=E2=86=92=20updatePageMarkdown;=20=D0=B2=D0=BD=D0=B5=D1=88?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20MCP:=20+updatePageMarkdown,=20=E2=88=92imp?= =?UTF-8?q?ort=5Fpage=5Fmarkdown=20(#411)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поверхности записи «целым телом» были несимметричны: у 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) --- CHANGELOG.md | 17 ++++++ .../src/core/ai-chat/chat-markdown.util.ts | 4 +- .../tools/ai-chat-tools.service.spec.ts | 57 ++++++++++++++++++- .../ai-chat/tools/ai-chat-tools.service.ts | 33 +++-------- .../tools/comment-signal-inapp.spec.ts | 2 +- .../tools/shared-tool-specs.contract.spec.ts | 2 +- .../src/core/ai-chat/tools/tool-tiers.spec.ts | 2 +- .../src/core/ai-chat/tools/tool-tiers.ts | 9 +-- packages/mcp/README.md | 25 ++++---- packages/mcp/README.ru.md | 26 +++++---- packages/mcp/src/server-instructions.ts | 8 ++- packages/mcp/src/tool-specs.ts | 52 +++++++++++++++++ .../mock/full-doc-write-canonicalize.test.mjs | 2 +- .../mcp/test/unit/tool-inventory.test.mjs | 36 +++++++++++- packages/mcp/test/unit/tool-specs.test.mjs | 33 +++++++++++ 15 files changed, 244 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e16d47cb..a6bda923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 - **Place several images side by side in a row.** A new "Inline (side by diff --git a/apps/server/src/core/ai-chat/chat-markdown.util.ts b/apps/server/src/core/ai-chat/chat-markdown.util.ts index 1888f781..00da9da9 100644 --- a/apps/server/src/core/ai-chat/chat-markdown.util.ts +++ b/apps/server/src/core/ai-chat/chat-markdown.util.ts @@ -75,7 +75,7 @@ const LABELS: Record< searchPages: 'Searched pages', getPage: 'Read page', createPage: 'Created page', - updatePageContent: 'Updated page', + updatePageMarkdown: 'Updated page', renamePage: 'Renamed page', movePage: 'Moved page', deletePage: 'Deleted page (to trash)', @@ -96,7 +96,7 @@ const LABELS: Record< searchPages: 'Искал по страницам', getPage: 'Прочитал страницу', createPage: 'Создал страницу', - updatePageContent: 'Обновил страницу', + updatePageMarkdown: 'Обновил страницу', renamePage: 'Переименовал страницу', movePage: 'Переместил страницу', deletePage: 'Удалил страницу (в корзину)', diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts index a2bd5be4..2c07cc55 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts @@ -23,7 +23,7 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs // sync. const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({ DocmostClient, - sharedToolSpecs: SHARED_TOOL_SPECS as Record, + sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record, }); /** @@ -275,6 +275,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => { const patchNodeCalls: unknown[][] = []; const insertNodeCalls: unknown[][] = []; const updatePageJsonCalls: unknown[][] = []; + const updatePageCalls: unknown[][] = []; const fakeClient: FakeDocmostClient = { patchNode: (...args: unknown[]) => { @@ -289,6 +290,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => { updatePageJsonCalls.push(args); 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 = { @@ -302,6 +308,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => { patchNodeCalls.length = 0; insertNodeCalls.length = 0; updatePageJsonCalls.length = 0; + updatePageCalls.length = 0; jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue( mockLoaded(function () { 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'); 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).updatePageContent).toBeUndefined(); + expect(tools.importPageMarkdown).toBeDefined(); + }); }); /** diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index c40bdbfd..2245ad0a 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -294,8 +294,8 @@ export class AiChatToolsService { // The in-app toolset. It starts with the tools kept INLINE here for a // documented per-layer reason: an intentional behaviour/schema divergence from - // the standalone MCP surface (searchPages' hybrid RRF, updatePageContent's - // Markdown write, transformPage's guardrailed shorter schema), a + // the standalone MCP surface (searchPages' hybrid RRF, + // transformPage's guardrailed shorter schema), a // snake_case/camelCase naming clash the shared registry forbids (getTable vs // the MCP `table_get`), per-request state the registry loop cannot provide // (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) --- - - updatePageContent: tool({ - description: - "Replace a page's body with new Markdown content (and optionally its " + - 'title). Reversible: the previous version is kept in page history.', - inputSchema: modelFriendlyInput({ - pageId: z.string().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.'), - }), - 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 }; - }, - }), + // + // NOTE (issue #411): the plain-Markdown full-body-replace tool is no longer + // inline here — it moved to @docmost/mcp's SHARED_TOOL_SPECS as + // `updatePageMarkdown` (was inline `updatePageContent`) so it registers on + // BOTH the external MCP and the in-app agent. The registry loop below adds + // it under its inAppKey. importPageMarkdown stays a shared spec too (now + // inAppOnly — dropped from the external MCP surface, kept in-app). listSidebarPages: tool({ description: diff --git a/apps/server/src/core/ai-chat/tools/comment-signal-inapp.spec.ts b/apps/server/src/core/ai-chat/tools/comment-signal-inapp.spec.ts index 1df0fb85..3dc1a7f6 100644 --- a/apps/server/src/core/ai-chat/tools/comment-signal-inapp.spec.ts +++ b/apps/server/src/core/ai-chat/tools/comment-signal-inapp.spec.ts @@ -283,7 +283,7 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => { DocmostClient: function () { return fakeClient as DocmostClientLike; } as unknown as loader.DocmostClientCtor, - sharedToolSpecs: SHARED_TOOL_SPECS as Record, + sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record, // Wire the REAL factory so the in-app path is exercised end to end. createCommentSignalTracker: createCommentSignalTracker as unknown as loader.CommentSignalTrackerFactory, diff --git a/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts b/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts index f62f0cc7..71221f50 100644 --- a/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts +++ b/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts @@ -70,7 +70,7 @@ describe('SHARED_TOOL_SPECS contract parity', () => { // Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal // 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] >; diff --git a/apps/server/src/core/ai-chat/tools/tool-tiers.spec.ts b/apps/server/src/core/ai-chat/tools/tool-tiers.spec.ts index 130e9b06..9a3447a4 100644 --- a/apps/server/src/core/ai-chat/tools/tool-tiers.spec.ts +++ b/apps/server/src/core/ai-chat/tools/tool-tiers.spec.ts @@ -123,7 +123,7 @@ describe('deferred catalog ↔ live forUser() toolset partition (#332, F3)', () DocmostClient: function () { return {} as DocmostClientLike; } as unknown as loader.DocmostClientCtor, - sharedToolSpecs: SHARED_TOOL_SPECS as Record, + sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record, }); const service = new AiChatToolsService( { diff --git a/apps/server/src/core/ai-chat/tools/tool-tiers.ts b/apps/server/src/core/ai-chat/tools/tool-tiers.ts index 94ab6af3..c4497368 100644 --- a/apps/server/src/core/ai-chat/tools/tool-tiers.ts +++ b/apps/server/src/core/ai-chat/tools/tool-tiers.ts @@ -124,12 +124,9 @@ export const INLINE_TOOL_TIERS: Record< // --- deferred inline --- // NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and // exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they - // carry their own deferred tier + catalogLine there. - updatePageContent: { - tier: 'deferred', - catalogLine: - "updatePageContent — replace a page's body (and optionally title) with new Markdown.", - }, + // carry their own deferred tier + catalogLine there. updatePageContent moved + // there too as updatePageMarkdown (#411) — a shared registry spec now, so it + // is no longer an inline tier entry. listSidebarPages: { tier: 'deferred', catalogLine: diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ecedb778..506b0ac9 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -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 (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. +- **`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 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 @@ -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 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 → - `import_page_markdown` round-trip that preserves everything, including comment highlights. -- **`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 - from their inline HTML. (Comment *threads* in the file are not re-created on the server — - only the page body and inline comment marks are written; manage threads via the comment - tools/UI.) + and diagrams, and a trailing comments-thread block. To replace a page's body from plain + authoring Markdown, use `update_page_markdown`. + +> **Removed in this release:** `import_page_markdown` (the round-trip parser for an +> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**. +> To replace a page's body from Markdown, use **`update_page_markdown`** (plain Markdown +> body replace). See the CHANGELOG for the migration note. ### 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`. - **Images**: `insert_image` / `replace_image`. - **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): `docmost_transform` — preview with `dryRun`, then apply. - **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`. - **Tables** (add/remove a row, set a cell): `table_get` / `table_insert_row` / `table_delete_row` / `table_update_cell`. -- **Round-trip a page as Markdown** (download, edit, re-upload losslessly with comments): - `export_page_markdown` / `import_page_markdown`. +- **Export a page as self-contained Markdown** (with comment anchors): `export_page_markdown`. +- **Replace a page's body from Markdown**: `update_page_markdown`. --- diff --git a/packages/mcp/README.ru.md b/packages/mcp/README.ru.md index 546b6713..ab04cf1d 100644 --- a/packages/mcp/README.ru.md +++ b/packages/mcp/README.ru.md @@ -161,6 +161,10 @@ Docmost-MCP не сочетают: перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история остаются стабильными. +- **`update_page_markdown`** — Заменить тело страницы (и опционально заголовок) новым + **обычным Markdown**. Всё тело переимпортируется (id блоков перегенерируются — для + хирургических правок или сохранения id используйте `edit_page_text` / `patch_node` / + `update_page_json`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`. - **`docmost_transform`** — Агентоориентированный интерфейс редактирования: вместо перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом* @@ -189,14 +193,13 @@ Docmost-MCP не сочетают: - **`export_page_markdown`** — Экспортировать страницу в один самодостаточный, **lossless Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и - диаграммами и завершающий блок тредов комментариев. Рассчитан на цикл «скачать → - отредактировать тело → `import_page_markdown`», сохраняющий всё, включая выделения - комментариев. -- **`import_page_markdown`** — Заменить контент страницы из Markdown-файла в диалекте - Docmost, созданного `export_page_markdown`, восстанавливая якоря-выделения комментариев и - диаграммы из их inline-HTML. (Треды комментариев из файла не пересоздаются на сервере — - записываются только тело страницы и inline-марки комментариев; тредами управляйте через - инструменты/UI комментариев.) + диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из + обычного авторского Markdown, используйте `update_page_markdown`. + +> **Удалено в этом релизе:** `import_page_markdown` (парсер round-trip для +> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**. +> Чтобы заменить тело страницы из Markdown, используйте **`update_page_markdown`** (замена +> тела обычным Markdown). См. заметку о миграции в CHANGELOG. ### Изображения @@ -263,7 +266,8 @@ Docmost-MCP не сочетают: `delete_node`, адресуя узел по его `attrs.id` из `get_page_json`. - **Изображения**: `insert_image` / `replace_image`. - **Новая страница**: `create_page`. -- **Массовая перезапись или узлы без id**: `update_page_json`. +- **Массовая перезапись или узлы без id**: `update_page_json` (ProseMirror) или + `update_page_markdown` (замена тела обычным Markdown). - **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки): `docmost_transform` — предпросмотр через `dryRun`, затем применение. - **Скопировать контент целой страницы из другой** (на стороне сервера): @@ -278,8 +282,8 @@ Docmost-MCP не сочетают: → `get_node`. - **Таблицы** (добавить/удалить строку, задать ячейку): `table_get` / `table_insert_row` / `table_delete_row` / `table_update_cell`. -- **Round-trip страницы через Markdown** (скачать, отредактировать, залить обратно без - потерь, с комментариями): `export_page_markdown` / `import_page_markdown`. +- **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `export_page_markdown`. +- **Заменить тело страницы из Markdown**: `update_page_markdown`. --- diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index eda032ed..f3289690 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -41,10 +41,10 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; export const ROUTING_PROSE = "Docmost editing guide — choose the tool by intent. The 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 \"#\" 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 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 \"#\" 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 \"#\" 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" + "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 @@ -96,6 +96,7 @@ const TOOL_FAMILY: Record = { insert_node: "EDIT", delete_node: "EDIT", update_page_json: "EDIT", + update_page_markdown: "EDIT", table_get: "EDIT", table_update_cell: "EDIT", table_insert_row: "EDIT", @@ -128,7 +129,8 @@ const TOOL_FAMILY: Record = { list_page_history: "HISTORY", restore_page_version: "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. }; /** diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index f0702f7a..b8d10775 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -81,6 +81,7 @@ export type DocmostClientLike = Pick< | 'patchNode' | 'insertNode' | 'deleteNode' + | 'updatePage' | 'updatePageJson' | 'tableInsertRow' | 'tableDeleteRow' @@ -607,6 +608,12 @@ export const SHARED_TOOL_SPECS = { importPageMarkdown: { mcpName: 'import_page_markdown', 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: "Replace a page's content from a self-contained Docmost-flavoured " + '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: { mcpName: 'export_page_markdown', inAppKey: 'exportPageMarkdown', diff --git a/packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs b/packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs index 8fcdf4a2..a9a057ef 100644 --- a/packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs +++ b/packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs @@ -3,7 +3,7 @@ // footnote-canonical doc. These override the `replacePage` seam (symmetric to the // `mutatePage` seam used by the insert-footnote-wrapper test) to capture 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 assert from "node:assert/strict"; import { DocmostClient } from "../../build/client.js"; diff --git a/packages/mcp/test/unit/tool-inventory.test.mjs b/packages/mcp/test/unit/tool-inventory.test.mjs index 7a51c416..3e518228 100644 --- a/packages/mcp/test/unit/tool-inventory.test.mjs +++ b/packages/mcp/test/unit/tool-inventory.test.mjs @@ -27,7 +27,8 @@ const SRC = join(HERE, "..", "..", "src"); /** * Every tool name the MCP server registers, scraped from the SOURCE: * - 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. */ function registeredToolNames() { @@ -37,8 +38,13 @@ function registeredToolNames() { for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) { names.add(m[1]); } - for (const m of specsSrc.matchAll(/mcpName:\s*['"]([a-z0-9_]+)['"]/g)) { - names.add(m[1]); + // Each spec is one `{ ... }` block; scrape its mcpName but skip a block that + // 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; } @@ -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", () => { for (const line of buildToolInventoryLines()) { assert.equal(typeof line.purpose, "string"); diff --git a/packages/mcp/test/unit/tool-specs.test.mjs b/packages/mcp/test/unit/tool-specs.test.mjs index 3c7d9df0..7c06d6ce 100644 --- a/packages/mcp/test/unit/tool-specs.test.mjs +++ b/packages/mcp/test/unit/tool-specs.test.mjs @@ -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`); } }); + +// #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"); +});