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]
### 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
@@ -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: 'Удалил страницу (в корзину)',
@@ -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<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 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<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
// 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:
@@ -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<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.
createCommentSignalTracker:
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
// 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]
>;
@@ -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<string, loader.SharedToolSpec>,
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
});
const service = new AiChatToolsService(
{
@@ -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:
+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
(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`.
---
+15 -11
View File
@@ -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`.
---
+5 -3
View File
@@ -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 <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" +
"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" +
"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<string, Family> = {
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<string, Family> = {
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.
};
/**
+52
View File
@@ -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',
@@ -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";
+33 -3
View File
@@ -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");
@@ -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");
});