From 7cb3199d090d097da27542a4bffd3b8a07126f0a Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 19:01:01 +0300 Subject: [PATCH] =?UTF-8?q?refactor(mcp)!:=20BREAKING=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0=20MCP-=D1=82?= =?UTF-8?q?=D1=83=D0=BB=D0=BE=D0=B2=20snake=5Fcase=20=E2=86=92=20camelCase?= =?UTF-8?q?=20(=D1=83=D0=BD=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=20in-app)=20(#412)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Один логический тул жил под двумя именами: внешний MCP snake_case (edit_page_text), in-app camelCase (editPageText) — дублирование доков, путаница при переносе промптов/скиллов, помеха шарингу спек (#294). Решение владельца: единый camelCase везде, включая внешний MCP. После этого mcpName === inAppKey. - tool-specs.ts: mcpName ВЫВЕДЕН из ключа спеки (mcpName == inAppKey) для всех 43 shared-спек — раньше divergent snake, теперь равен ключу (проверено: mcpName читается только структурно — цикл регистрации, генератор , TOOL_FAMILY). +5 inline-регистраций (tableGet/updateComment/deleteComment/ docmostTransform; search без изменений). Рантайм: 47 тулов, все camelCase, ноль подчёркиваний. - Контракт-конвенция ИНВЕРТИРОВАНА: shared-tool-specs.contract.spec `mcpName === toSnake(inAppKey)` → `mcpName === inAppKey`; tool-specs.test и tool-inventory.test обновлены. - ROUTING_PROSE/TOOL_FAMILY/INLINE_MCP_INVENTORY (server-instructions.ts) → camelCase (105 замен). ai-chat.prompt/guard уже на in-app camelCase-ключах — без изменений (guard прошёл). comment-signal EXCLUDED_TOOLS схлопнут с дублей snake+camel до camelCase. - Некоторое неочевидное: assertUnambiguousMatch(op: "patch_node"|"delete_node") в prosemirror-markdown/node-ops — op интерполируется в model-facing ошибку; литерал-юнион + call-sites → "patchNode"|"deleteNode". - Все snake-имена в описаниях/error-строках/комментах/тестах/доках → camelCase (whole-token, longest-match-first). CHANGELOG: BREAKING-таблица 46 строк + миграция (allowlists mcp__gitmost-*__get_node→__getNode, промпты/скиллы, .mcp.json, метрики по tool-label); релизится вместе с #411. Внутренние имена методов (PageService.updatePageContent и т.п.) НЕ тронуты — переименованы только ИМЕНА ТУЛОВ. Гейт: mcp node --test 677/677; tsc -p apps/server чисто; jest ai-chat-tools. service + shared-tool-specs.contract + tool-tiers + ai-chat.prompt + comment-signal-inapp → 323. Второй линк breaking-окна (#411→ЭТОТ→#413→#415). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 86 +++++++- .../tools/ai-chat-tools.service.spec.ts | 4 +- .../ai-chat/tools/ai-chat-tools.service.ts | 21 +- .../ai-chat/tools/docmost-client.loader.ts | 4 +- .../tools/shared-tool-specs.contract.spec.ts | 14 +- .../src/core/ai-chat/tools/tool-tiers.spec.ts | 2 +- .../src/core/ai-chat/tools/tool-tiers.ts | 8 +- .../src/integrations/mcp/mcp-auth.helpers.ts | 2 +- .../src/integrations/mcp/mcp.service.ts | 2 +- packages/mcp/README.md | 160 +++++++------- packages/mcp/README.ru.md | 162 +++++++------- packages/mcp/TEST-PLAN.md | 20 +- packages/mcp/src/client.ts | 170 +++++++-------- packages/mcp/src/comment-signal.ts | 11 +- packages/mcp/src/index.ts | 38 ++-- packages/mcp/src/lib/collaboration.ts | 6 +- packages/mcp/src/lib/comment-anchor.ts | 2 +- packages/mcp/src/lib/diff.ts | 4 +- packages/mcp/src/lib/drawio-guide.ts | 28 +-- packages/mcp/src/lib/drawio-layout.ts | 4 +- packages/mcp/src/lib/drawio-shapes.ts | 2 +- packages/mcp/src/lib/drawio-xml.ts | 2 +- packages/mcp/src/lib/footnote-canonicalize.ts | 8 +- packages/mcp/src/lib/json-edit.ts | 16 +- packages/mcp/src/lib/page-search.ts | 24 +-- packages/mcp/src/lib/text-normalize.ts | 2 +- packages/mcp/src/lib/transforms.ts | 2 +- packages/mcp/src/server-instructions.ts | 112 +++++----- packages/mcp/src/tool-specs.ts | 202 +++++++++--------- packages/mcp/test-e2e.mjs | 152 ++++++------- .../mcp/test/mock/ambiguous-node-id.test.mjs | 10 +- packages/mcp/test/mock/drawio-tools.test.mjs | 42 ++-- .../mcp/test/mock/footnote-write.test.mjs | 6 +- .../mock/full-doc-write-canonicalize.test.mjs | 6 +- .../mock/invalid-node-validation.test.mjs | 22 +- .../mcp/test/mock/pagination-cursor.test.mjs | 6 +- .../resolve-page-id-collab-doc-name.test.mjs | 4 +- .../test/mock/stash-page-mcp-result.test.mjs | 6 +- .../mcp/test/mock/tool-timing-server.test.mjs | 4 +- .../mcp/test/unit/comment-signal.test.mjs | 6 +- packages/mcp/test/unit/diff-verify.test.mjs | 2 +- packages/mcp/test/unit/drawio-guide.test.mjs | 2 +- packages/mcp/test/unit/drawio-shapes.test.mjs | 4 +- .../unit/drawio-stage2-registration.test.mjs | 10 +- .../mcp/test/unit/error-diagnostics.test.mjs | 18 +- .../unit/footnote-warnings-import.test.mjs | 2 +- .../mcp/test/unit/json-edit-refuse.test.mjs | 6 +- packages/mcp/test/unit/node-ops.test.mjs | 14 +- packages/mcp/test/unit/table-ops.test.mjs | 10 +- .../mcp/test/unit/tool-inventory.test.mjs | 29 +-- packages/mcp/test/unit/tool-specs.test.mjs | 19 +- packages/mcp/test/unit/tool-timing.test.mjs | 4 +- .../src/lib/docmost-schema.ts | 2 +- .../prosemirror-markdown/src/lib/node-ops.ts | 18 +- .../src/lib/parse-node-arg.ts | 4 +- 55 files changed, 799 insertions(+), 727 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6bda923..dfbdb63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,20 +12,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +- **External MCP tool names are now camelCase (all renamed).** Every tool on the + external `/mcp` surface was renamed from `snake_case` to `camelCase`, so the + external MCP name now matches the in-app tool name exactly (one logical tool, + one name everywhere). For example `get_node` → `getNode`, `edit_page_text` → + `editPageText`, `patch_node` → `patchNode`. The tools' behaviour, inputs and + outputs are unchanged — only the names change. The single-word `search` + keeps its name. + + *Migration (external MCP clients only — the in-app AI agent already used these + names and is unaffected):* update anything that refers to a tool by its + string name — permission allowlists (`mcp__gitmost-*__get_node` → + `mcp__gitmost-*__getNode`), saved prompts/skills, `.mcp.json` tool filters, + and metrics dashboards that group by the `tool` label — and roll it out in + lockstep with this deploy, because the old snake_case names stop resolving. + Released together with the `import_page_markdown`/`update_page_markdown` + change below so external configs break exactly once. + + Full mapping (old → new): + + | Old (snake_case) | New (camelCase) | + | --- | --- | + | `check_new_comments` | `checkNewComments` | + | `copy_page_content` | `copyPageContent` | + | `create_comment` | `createComment` | + | `create_page` | `createPage` | + | `delete_comment` | `deleteComment` | + | `delete_node` | `deleteNode` | + | `delete_page` | `deletePage` | + | `diff_page_versions` | `diffPageVersions` | + | `docmost_transform` | `docmostTransform` | + | `drawio_create` | `drawioCreate` | + | `drawio_get` | `drawioGet` | + | `drawio_guide` | `drawioGuide` | + | `drawio_shapes` | `drawioShapes` | + | `drawio_update` | `drawioUpdate` | + | `edit_page_text` | `editPageText` | + | `export_page_markdown` | `exportPageMarkdown` | + | `get_node` | `getNode` | + | `get_outline` | `getOutline` | + | `get_page` | `getPage` | + | `get_page_json` | `getPageJson` | + | `get_workspace` | `getWorkspace` | + | `insert_footnote` | `insertFootnote` | + | `insert_image` | `insertImage` | + | `insert_node` | `insertNode` | + | `list_comments` | `listComments` | + | `list_page_history` | `listPageHistory` | + | `list_pages` | `listPages` | + | `list_shares` | `listShares` | + | `list_spaces` | `listSpaces` | + | `move_page` | `movePage` | + | `patch_node` | `patchNode` | + | `rename_page` | `renamePage` | + | `replace_image` | `replaceImage` | + | `resolve_comment` | `resolveComment` | + | `restore_page_version` | `restorePageVersion` | + | `search` | `search` (unchanged) | + | `search_in_page` | `searchInPage` | + | `share_page` | `sharePage` | + | `stash_page` | `stashPage` | + | `table_delete_row` | `tableDeleteRow` | + | `table_get` | `tableGet` | + | `table_insert_row` | `tableInsertRow` | + | `table_update_cell` | `tableUpdateCell` | + | `unshare_page` | `unsharePage` | + | `update_comment` | `updateComment` | + | `update_page_json` | `updatePageJson` | + | `update_page_markdown` | `updatePageMarkdown` | + + (#412) + - **External MCP: `import_page_markdown` removed, `update_page_markdown` added.** - The external `/mcp` surface no longer exposes `import_page_markdown` (the + The external `/mcp` surface no longer exposes `importPageMarkdown` (the round-trip parser for a self-contained *exported* Docmost-Markdown file). In - its place it now exposes **`update_page_markdown`** — a plain-Markdown + its place it now exposes **`updatePageMarkdown`** — a plain-Markdown full-body replace (`{pageId, content, title?}`) that pairs with - `update_page_json`, re-imports the whole body (block ids regenerate) and + `updatePageJson`, 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 + *Migration:* MCP clients that called `importPageMarkdown` to overwrite a + page's body from Markdown should call `updatePageMarkdown` 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 — + export remains via `exportPageMarkdown`. 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) + `updatePageContent`). The total MCP tool count is unchanged (−1 / +1). The + external names shown here are the post-#412 camelCase names. (#411) ### Added 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 b6a8e2f4..00990666 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 @@ -25,7 +25,7 @@ const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({ DocmostClient, sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record, // Pure no-network draw.io helpers (#424). Type-correct stubs: these tests - // never execute the drawio_shapes / drawio_guide tool bodies. + // never execute the drawioShapes / drawioGuide tool bodies. searchShapes: (() => []) as unknown as loader.SearchShapesFn, getGuideSection: (() => ({ section: 'index', @@ -912,7 +912,7 @@ describe('AiChatToolsService getCurrentPage selection (#388)', () => { }); /** - * #440 review: the in-app drawio_create / drawio_update handlers must forward + * #440 review: the in-app drawioCreate / drawioUpdate handlers must forward * the optional `layout:"elk"` param to the client (5th positional arg), exactly * like the MCP host. It was silently dropped, so ELK auto-layout worked only via * the standalone MCP server, not in-app. These tests pin per-host parity. 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 ccfc5429..e7e0414b 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 @@ -266,7 +266,7 @@ export class AiChatToolsService { // construction is shared with the page-change detection path (#274) via // buildDocmostClient so both go over the exact same authenticated route. // searchShapes / getGuideSection (#424) are the PURE, no-network helpers - // backing drawio_shapes / drawio_guide. They are `inlineBothHosts` specs (no + // backing drawioShapes / drawioGuide. They are `inlineBothHosts` specs (no // canonical execute — their catalog loader uses import.meta and can't be // value-imported into the zod-agnostic tool-specs.ts under the server's // commonjs type-check), so the shared registry loop below SKIPS them and this @@ -308,9 +308,10 @@ 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, - // 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 + // transformPage's guardrailed shorter schema), a name clash the shared + // registry forbids (in-app `getTable` verb-first vs the MCP noun-first + // `tableGet` — the registry requires mcpName === inAppKey), per-request + // state the registry loop cannot provide // (getCurrentPage reads the resolved openedPage; searchPages closes over the // per-request user/embedding deps), or a tool with no MCP twin // (listSidebarPages/getComment/getPageHistory). Every SHARED tool is then added @@ -477,9 +478,9 @@ export class AiChatToolsService { await client.listSidebarPages(spaceId, pageId), }), - // NOT shared (kept inline): the MCP tool name `table_get` is noun-first - // while this key is `getTable` (verb-first), breaking the - // snake_case(inAppKey) convention the shared registry enforces. Its + // NOT shared (kept inline): the MCP tool name `tableGet` is noun-first + // while this key is `getTable` (verb-first), so it cannot satisfy the + // shared registry's `mcpName === inAppKey` convention (#412). Its // reference parameter is still named `table` (was `tableRef`) so it matches // the migrated table row/cell tools below. getTable: tool({ @@ -522,7 +523,7 @@ export class AiChatToolsService { // INTENTIONAL per-transport divergence (not shared): deliberately omits the // `deleteComments` schema field (comment-deletion guardrail) and carries a - // much shorter description; the standalone MCP `docmost_transform` exposes + // much shorter description; the standalone MCP `docmostTransform` exposes // the full helper catalogue. Different schema, so kept per-layer. transformPage: tool({ description: @@ -553,7 +554,7 @@ export class AiChatToolsService { // WHICH mapping to run and returns its value directly (no envelope). For each // spec: // - skip `mcpOnly` specs (they belong to the standalone MCP host only); - // - skip `inlineBothHosts` specs (drawio_shapes / drawio_guide): they carry + // - skip `inlineBothHosts` specs (drawioShapes / drawioGuide): they carry // no execute and are wired INLINE just below, calling the pure helpers; // - use `inAppExecute` when the spec declares a DELIBERATE per-layer // difference (a projected result shape, a different guardrail message); @@ -574,7 +575,7 @@ export class AiChatToolsService { ); } - // drawio_shapes / drawio_guide (#424): `inlineBothHosts` registry specs wired + // drawioShapes / drawioGuide (#424): `inlineBothHosts` registry specs wired // here with the SAME schema+description the shared spec pins, but calling the // pure searchShapes / getGuideSection helpers off the loaded @docmost/mcp // module — they are not client methods and their catalog loader uses diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index d9419a7e..2012e2b8 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -146,7 +146,7 @@ export type CommentSignalTrackerFactory = (options: { // Pure, no-network draw.io helpers (#424). These are plain functions on the // module (NOT DocmostClient methods) — the in-app AI-SDK service calls them -// directly to wire drawio_shapes / drawio_guide, mirroring the MCP server. +// directly to wire drawioShapes / drawioGuide, mirroring the MCP server. export type SearchShapesFn = ( query: string, opts?: { category?: string; limit?: number }, @@ -169,7 +169,7 @@ interface DocmostMcpModule { // the mocked loader in unit tests) — the stale-check below is a NO-OP when it // is missing, so an older build never wrongly fails startup. REGISTRY_STAMP?: string; - // Pure, no-network draw.io helpers (#424) backing drawio_shapes / drawio_guide. + // Pure, no-network draw.io helpers (#424) backing drawioShapes / drawioGuide. // Those two specs are `inlineBothHosts` (they stay in SHARED_TOOL_SPECS for the // shared contract but carry no execute — their catalog loader uses import.meta // and can't be value-imported into the zod-agnostic tool-specs.ts), so the 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 00065977..4046f2aa 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 @@ -17,8 +17,10 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs * This test fails the build if a spec is added to the registry but never wired * in-app, if an `inAppKey` is renamed without updating the service, if the * description drifts between the registry and the exposed tool, if the - * snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the - * exposed tool's input-schema keys diverge from the spec's `buildShape`. + * `mcpName === inAppKey` convention is broken (issue #412 unified the external + * MCP tool name with the in-app key — both are the same camelCase identifier), + * or if the exposed tool's input-schema keys diverge from the spec's + * `buildShape`. * * It does NOT need @docmost/mcp built: the registry is imported from TS source, * and the ESM loader is mocked so `forUser()` never dynamically imports the @@ -74,10 +76,6 @@ describe('SHARED_TOOL_SPECS contract parity', () => { afterAll(() => jest.restoreAllMocks()); - // camelCase -> snake_case, matching the registry's mcpName convention. - const toSnake = (s: string) => - s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); - // 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 unknown as Array< @@ -96,8 +94,8 @@ describe('SHARED_TOOL_SPECS contract parity', () => { expect(spec.inAppKey).toBe(registryKey); }); - it('mcpName is the snake_case form of inAppKey', () => { - expect(spec.mcpName).toBe(toSnake(spec.inAppKey)); + it('mcpName equals inAppKey (unified camelCase name, #412)', () => { + expect(spec.mcpName).toBe(spec.inAppKey); }); it('is exposed in-app under its inAppKey', () => { 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 1503ac08..0c99f195 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 @@ -36,7 +36,7 @@ describe('tool tier metadata (#332)', () => { }); it('#410 image tools are DEFERRED, footnote tool is CORE', () => { - // insert_footnote is core (symmetric with editPageText); the image tools stay + // insertFootnote is core (symmetric with editPageText); the image tools stay // deferred (rare, fat — loaded on demand). Assert both the spec tier and the // CORE_TOOL_SET membership so a future tier edit that desyncs them fails here. expect(SHARED_TOOL_SPECS.insertFootnote.tier).toBe('core'); 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 c4497368..1e74c86a 100644 --- a/apps/server/src/core/ai-chat/tools/tool-tiers.ts +++ b/apps/server/src/core/ai-chat/tools/tool-tiers.ts @@ -60,10 +60,10 @@ export const CORE_TOOL_KEYS = [ 'listComments', 'resolveComment', 'editPageText', - // #330 search_in_page — frequent for editorial sweeps; core despite predating + // #330 searchInPage — frequent for editorial sweeps; core despite predating // the issue's tier list. 'searchInPage', - // #410 insert_footnote — core so pinpoint citations to already-written text + // #410 insertFootnote — core so pinpoint citations to already-written text // don't degrade into literal `^[...]`; kept symmetric with editPageText. 'insertFootnote', ] as const; @@ -138,7 +138,7 @@ export const INLINE_TOOL_TIERS: Record< }, // NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to // @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier + - // catalogLine there. getTable stays inline (its MCP name table_get breaks the + // catalogLine there. getTable stays inline (its MCP name tableGet breaks the // snake_case(inAppKey) convention, so it has no shared spec). // NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); // it carries its own deferred tier + catalogLine there. @@ -150,7 +150,7 @@ export const INLINE_TOOL_TIERS: Record< // NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries // its own deferred tier + catalogLine there. transformPage stays inline (its // schema deliberately diverges — it omits the deleteComments field the MCP - // docmost_transform exposes, a comment-deletion guardrail). + // docmostTransform exposes, a comment-deletion guardrail). transformPage: { tier: 'deferred', catalogLine: "transformPage — run a sandboxed JS transform over a page's document.", diff --git a/apps/server/src/integrations/mcp/mcp-auth.helpers.ts b/apps/server/src/integrations/mcp/mcp-auth.helpers.ts index e1709666..31a1d8f1 100644 --- a/apps/server/src/integrations/mcp/mcp-auth.helpers.ts +++ b/apps/server/src/integrations/mcp/mcp-auth.helpers.ts @@ -143,7 +143,7 @@ export type DocmostMcpConfig = ( buf: Buffer, mime: string, ) => { uri: string; sha256: string; size: number }; - // Optional live/evict probes the package uses to keep stash_page's mirror + // Optional live/evict probes the package uses to keep stashPage's mirror // counts honest under the store's FIFO eviction (mirror of the package's // sink type); older bindings omit them. has?: (uri: string) => boolean; diff --git a/apps/server/src/integrations/mcp/mcp.service.ts b/apps/server/src/integrations/mcp/mcp.service.ts index 26349d56..dccfc3ab 100644 --- a/apps/server/src/integrations/mcp/mcp.service.ts +++ b/apps/server/src/integrations/mcp/mcp.service.ts @@ -332,7 +332,7 @@ export class McpService implements OnModuleDestroy { // Should never happen: handle() always stashes before delegating. throw new UnauthorizedException('MCP authentication missing.'); } - // Inject the blob-sandbox sink after the auth decision so stash_page + // Inject the blob-sandbox sink after the auth decision so stashPage // can store blobs in the shared in-RAM store regardless of which // credential variant resolved. The sink (put/has/evict + uri↔id // mapping) is owned by SandboxStore.asSink(). diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 506b0ac9..bdbaf6d9 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -12,7 +12,7 @@ license. > better at *writing a small function that fixes the text* than at re-reading and > re-emitting a whole document. So this server is built around the way a model actually > wants to edit: address a block by id, run a find/replace, or hand it a -> `(doc, ctx) => doc` transform and let it *program* the change. `docmost_transform` is +> `(doc, ctx) => doc` transform and let it *program* the change. `docmostTransform` is > that interface. Other Docmost MCPs are human-shaped — they expose "open the page" and > "replace the page"; this one exposes the editing primitives a model is good at. @@ -69,9 +69,9 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison. - **Token-efficient editing.** Most Docmost MCPs (and the official one) only offer "replace the whole page" writes — the agent must download the entire document, mutate it, and upload it back, paying for the full document **twice** on every tiny fix. - This server lets the agent change exactly one block (`patch_node` / `insert_node` / - `delete_node`), do a structure-preserving find/replace (`edit_page_text`), or copy a - whole page server-side (`copy_page_content`) — **without the document ever passing + This server lets the agent change exactly one block (`patchNode` / `insertNode` / + `deleteNode`), do a structure-preserving find/replace (`editPageText`), or copy a + whole page server-side (`copyPageContent`) — **without the document ever passing through the model**. - **Writes that don't fight the editor.** Naive REST writes race with whatever a human @@ -85,12 +85,12 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison. - **Agent-native editing model.** Human-facing servers expose "open the page" and "replace the page", because that mirrors how a person works. A model edits better by *programming* the change — addressing blocks by id, running a find/replace, or supplying a - `(doc, ctx) => doc` transform (`docmost_transform`, with a dry-run diff before it + `(doc, ctx) => doc` transform (`docmostTransform`, with a dry-run diff before it commits). This server is shaped around that, which is why it has editing primitives the others simply don't. -- **An editing safety net the others lack.** `list_page_history` → `diff_page_versions` - → `restore_page_version` give an agent (and you) a full view-and-undo loop. The diff +- **An editing safety net the others lack.** `listPageHistory` → `diffPageVersions` + → `restorePageVersion` give an agent (and you) a full view-and-undo loop. The diff uses the *same* `recreateTransform → ChangeSet → simplifyChanges` pipeline Docmost's own history viewer uses, so what you see matches the product. @@ -110,56 +110,56 @@ All 41 tools, grouped by what you'd reach for them. ### Exploration & retrieval -- **`get_workspace`** — Information about the current Docmost workspace. -- **`list_spaces`** — All spaces in the workspace. -- **`list_pages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50, +- **`getWorkspace`** — Information about the current Docmost workspace. +- **`listSpaces`** — All spaces in the workspace. +- **`listPages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50, max 100). Use `search` for lookups in large spaces. - **`search`** — Full-text search across pages and content (bounded by `limit`, max 100). -- **`get_page`** — A page's content as clean **Markdown** (convenient, but a *lossy* +- **`getPage`** — A page's content as clean **Markdown** (convenient, but a *lossy* view — block ids and exact table/callout structure are approximated). -- **`get_page_json`** — A page's **lossless ProseMirror/TipTap JSON**, including every +- **`getPageJson`** — A page's **lossless ProseMirror/TipTap JSON**, including every block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing tools consume. -- **`get_outline`** — A compact outline of a page's top-level blocks (`{index, type, id, +- **`getOutline`** — A compact outline of a page's top-level blocks (`{index, type, id, level, firstText}`; tables add row/column counts and their header-cell texts, lists add item counts) **without** the document body. The cheap way to locate a section or table and grab its block id before - `get_node` / `patch_node` / `insert_node`. -- **`get_node`** — Fetch a single block's full ProseMirror subtree (lossless) without - pulling the whole page. Address it by a block id (from `get_outline` / `get_page_json`), + `getNode` / `patchNode` / `insertNode`. +- **`getNode`** — Fetch a single block's full ProseMirror subtree (lossless) without + pulling the whole page. Address it by a block id (from `getOutline` / `getPageJson`), or by `#` for a top-level block — use the `#` form for tables/rows/cells, which carry no id. ### Page lifecycle -- **`create_page`** — Create a page from Markdown and place it in the hierarchy (optional +- **`createPage`** — Create a page from Markdown and place it in the hierarchy (optional `parentPageId`) in one call. Uses Docmost's import API for clean Markdown→ProseMirror. -- **`rename_page`** — Change a page's title only, without touching or resending content. -- **`move_page`** — Re-parent a page (nest it, or move to root); supports fractional-index +- **`renamePage`** — Change a page's title only, without touching or resending content. +- **`movePage`** — Re-parent a page (nest it, or move to root); supports fractional-index positioning. Returns only on a *positively confirmed* success. -- **`delete_page`** — Delete a single page. -- **`copy_page_content`** — Replace one page's body with a copy of another's, **entirely +- **`deletePage`** — Delete a single page. +- **`copyPageContent`** — Replace one page's body with a copy of another's, **entirely server-side** — the document never passes through the model. The target keeps its own title and slug (so its URL is preserved). ### Editing -- **`edit_page_text`** — Surgical find/replace inside a page's text. Preserves **all** +- **`editPageText`** — Surgical find/replace inside a page's text. Preserves **all** structure: block ids, marks, links, callouts, tables. The preferred tool for fixing wording, typos, numbers and names. -- **`patch_node`** — Replace a single block addressed by its `attrs.id` (from - `get_page_json`), without resending the document. -- **`insert_node`** — Insert a block before/after another (by `attrs.id` or anchor text), +- **`patchNode`** — Replace a single block addressed by its `attrs.id` (from + `getPageJson`), without resending the document. +- **`insertNode`** — Insert a block before/after another (by `attrs.id` or anchor text), or append at the end. -- **`delete_node`** — Remove a single block by its `attrs.id`. -- **`update_page_json`** — Replace a page's entire content with a ProseMirror document +- **`deleteNode`** — Remove a single block by its `attrs.id`. +- **`updatePageJson`** — 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 +- **`updatePageMarkdown`** — 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`). + id-preserving edits prefer `editPageText` / `patchNode` / `updatePageJson`). Docmost-flavoured markdown is parsed, including `^[...]` inline footnotes. -- **`docmost_transform`** — The agent-native editing interface: instead of retyping a +- **`docmostTransform`** — 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 document. Runs **sandboxed** @@ -172,42 +172,42 @@ All 41 tools, grouped by what you'd reach for them. ### Tables -- **`table_get`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}` +- **`tableGet`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}` (a paragraph id per cell, or `null`). Address the table by `#` (from - `get_outline`) or any block id inside it. Use `cellIds` with `patch_node` for + `getOutline`) or any block id inside it. Use `cellIds` with `patchNode` for rich-formatted cell edits. -- **`table_insert_row`** — Insert a row of plain-text cells, padded to the table's column +- **`tableInsertRow`** — Insert a row of plain-text cells, padded to the table's column count (passing more cells than columns is an error). `index` is the 0-based insert position (0 inserts before the header); omit it to append at the end. -- **`table_delete_row`** — Delete the row at a 0-based `index`. Refuses to delete a table's +- **`tableDeleteRow`** — Delete the row at a 0-based `index`. Refuses to delete a table's only row; deleting row 0 promotes the next row to header. -- **`table_update_cell`** — Set the plain-text content of cell `[row, col]` (0-based). For - rich formatting, `patch_node` the cell's paragraph id from `table_get`. +- **`tableUpdateCell`** — Set the plain-text content of cell `[row, col]` (0-based). For + rich formatting, `patchNode` the cell's paragraph id from `tableGet`. ### Markdown round-trip -- **`export_page_markdown`** — Export a page to a single self-contained, **lossless +- **`exportPageMarkdown`** — 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. To replace a page's body from plain - authoring Markdown, use `update_page_markdown`. + authoring Markdown, use `updatePageMarkdown`. -> **Removed in this release:** `import_page_markdown` (the round-trip parser for an +> **Removed in this release:** `importPageMarkdown` (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 +> To replace a page's body from Markdown, use **`updatePageMarkdown`** (plain Markdown > body replace). See the CHANGELOG for the migration note. ### Images -- **`insert_image`** — Download an image from a web (http/https) URL and insert it in one +- **`insertImage`** — Download an image from a web (http/https) URL and insert it in one step: append it, drop it in place of a text placeholder (`replaceText`), or put it after a given block (`afterText`). Preserves all other block ids. -- **`replace_image`** — Swap an existing image for one fetched from a web (http/https) URL. +- **`replaceImage`** — Swap an existing image for one fetched from a web (http/https) URL. Uploads the new file as a **fresh attachment** (clean URL that renders and busts browser caches), then re-points every node referencing the old attachment (recursively, including callouts/tables) via the live document, preserving comments, alignment and alt text. (In-place overwrite is deliberately avoided — some Docmost versions corrupt the attachment on overwrite.) -- **`stash_page`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral +- **`stashPage`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral in-RAM blob and return ONLY a short anonymous URL — the body never enters the model context, so it is the way to hand a large page (and its images) to an external consumer without truncation. Every internal file/image attachment is mirrored into the same @@ -218,35 +218,35 @@ All 41 tools, grouped by what you'd reach for them. ### Comments -- **`create_comment`** — Add a page comment, optionally **anchored inline** to an exact +- **`createComment`** — Add a page comment, optionally **anchored inline** to an exact span of text (the first occurrence is wrapped in a comment mark). -- **`list_comments`** — List a page's comments (content returned as Markdown). -- **`update_comment`** — Edit an existing comment. -- **`delete_comment`** — Delete a comment. -- **`resolve_comment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level - comments can be resolved; the thread and its replies are kept, unlike `delete_comment`. -- **`check_new_comments`** — Find comments created after a given ISO-8601 timestamp across +- **`listComments`** — List a page's comments (content returned as Markdown). +- **`updateComment`** — Edit an existing comment. +- **`deleteComment`** — Delete a comment. +- **`resolveComment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level + comments can be resolved; the thread and its replies are kept, unlike `deleteComment`. +- **`checkNewComments`** — Find comments created after a given ISO-8601 timestamp across a space, optionally scoped to a page subtree — ideal for an agent that watches a doc for feedback. ### Versioning & history -- **`list_page_history`** — A page's saved versions (Docmost auto-snapshots on save), +- **`listPageHistory`** — A page's saved versions (Docmost auto-snapshots on save), newest first, cursor-paginated. Each item's id is the `historyId`. -- **`diff_page_versions`** — Diff two versions (or a version against the live page). +- **`diffPageVersions`** — Diff two versions (or a version against the live page). Returns inserted/deleted text, integrity counts (images, links, tables, callouts, footnote markers), and a human-readable Markdown summary — computed with the same pipeline Docmost's own history viewer uses. -- **`restore_page_version`** — Write a saved version back as the current content. Docmost +- **`restorePageVersion`** — Write a saved version back as the current content. Docmost has no restore endpoint, so this creates a **new** snapshot — the restore is itself revertible. ### Sharing -- **`share_page`** — Make a page publicly accessible (idempotent) and return its public +- **`sharePage`** — Make a page publicly accessible (idempotent) and return its public URL (`/share//p/`); optional search-engine indexing. -- **`unshare_page`** — Revoke a page's public share. -- **`list_shares`** — All public shares in the workspace, with titles and public URLs. +- **`unsharePage`** — Revoke a page's public share. +- **`listShares`** — All public shares in the workspace, with titles and public URLs. --- @@ -255,27 +255,27 @@ All 41 tools, grouped by what you'd reach for them. This same guidance is also delivered at runtime via the MCP server `instructions` field, so capable clients steer the model automatically. -- **Text fixes** (wording, typos, numbers): `edit_page_text`. -- **One block** (paragraph/heading/callout/table cell): `patch_node` / `insert_node` / - `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` (ProseMirror) or - `update_page_markdown` (plain Markdown body replace). +- **Text fixes** (wording, typos, numbers): `editPageText`. +- **One block** (paragraph/heading/callout/table cell): `patchNode` / `insertNode` / + `deleteNode`, addressing the node by its `attrs.id` from `getPageJson`. +- **Images**: `insertImage` / `replaceImage`. +- **A new page**: `createPage`. +- **Bulk rewrite, or nodes without ids**: `updatePageJson` (ProseMirror) or + `updatePageMarkdown` (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`. -- **Rename a page** (title only): `rename_page`. -- **Reads**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror with ids). -- **Review changes**: `list_page_history` → `diff_page_versions` → `restore_page_version`. -- **Comments**: `create_comment` (with optional inline anchoring) / `list_comments` / - `update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`. -- **Navigate a page cheaply** (find a section/table, grab a block id): `get_outline` → - `get_node`. -- **Tables** (add/remove a row, set a cell): `table_get` / `table_insert_row` / - `table_delete_row` / `table_update_cell`. -- **Export a page as self-contained Markdown** (with comment anchors): `export_page_markdown`. -- **Replace a page's body from Markdown**: `update_page_markdown`. + `docmostTransform` — preview with `dryRun`, then apply. +- **Copy a whole page's content from another page** (server-side): `copyPageContent`. +- **Rename a page** (title only): `renamePage`. +- **Reads**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror with ids). +- **Review changes**: `listPageHistory` → `diffPageVersions` → `restorePageVersion`. +- **Comments**: `createComment` (with optional inline anchoring) / `listComments` / + `updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`. +- **Navigate a page cheaply** (find a section/table, grab a block id): `getOutline` → + `getNode`. +- **Tables** (add/remove a row, set a cell): `tableGet` / `tableInsertRow` / + `tableDeleteRow` / `tableUpdateCell`. +- **Export a page as self-contained Markdown** (with comment anchors): `exportPageMarkdown`. +- **Replace a page's body from Markdown**: `updatePageMarkdown`. --- @@ -293,8 +293,8 @@ so capable clients steer the model automatically. refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the collaboration-token path), with in-flight login de-duplication so a burst of calls triggers a single re-login. -- **Lossless and lossy reads.** `get_page_json` returns the exact ProseMirror tree with - block ids; `get_page` returns clean Markdown for convenience. +- **Lossless and lossy reads.** `getPageJson` returns the exact ProseMirror tree with + block ids; `getPage` returns clean Markdown for convenience. - **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds, highlights, sub/superscript and more, with defensive caps against pathological input. @@ -305,7 +305,7 @@ so capable clients steer the model automatically. - **Token-optimized responses.** API responses are filtered down to the fields agents actually need, and large collections (spaces, pages, comments, history) are paginated. - **Hardened runtime.** Global handlers keep a stray socket error from tearing down the - stdio server; `move_page` requires a positively confirmed success; the diff engine + stdio server; `movePage` requires a positively confirmed success; the diff engine falls back to a coarse block diff rather than hard-failing on a pathological document. --- @@ -363,7 +363,7 @@ npm run test:e2e This project began as a fork of [MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp) (by Moritz Krause) and extends it substantially — adding per-block node editing, -surgical text edits, the sandboxed `docmost_transform`, version history / diff / restore, +surgical text edits, the sandboxed `docmostTransform`, version history / diff / restore, comments, image insert/replace, public sharing, server-side page copy, dual JSON/Markdown reads, transparent re-authentication and significant hardening. The comment tools were ported from upstream PR #3 by Max Nikitin. Thanks to both. diff --git a/packages/mcp/README.ru.md b/packages/mcp/README.ru.md index ab04cf1d..18574af0 100644 --- a/packages/mcp/README.ru.md +++ b/packages/mcp/README.ru.md @@ -12,7 +12,7 @@ > небольшую функцию, которая чинит текст*, чем перечитывать и заново выдавать весь > документ. Поэтому сервер построен вокруг того, как модели на самом деле удобно > редактировать: адресовать блок по id, сделать find/replace или передать трансформ -> `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmost_transform` — +> `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmostTransform` — > это и есть такой интерфейс. Другие Docmost-MCP «заточены под человека» — они дают > «открыть страницу» и «заменить страницу»; этот даёт примитивы редактирования, в которых > модель сильна. @@ -71,9 +71,9 @@ Docmost-MCP не сочетают: - **Экономия токенов при редактировании.** Большинство Docmost-MCP (и официальный) предлагают только запись «заменить всю страницу» — агент вынужден скачать весь документ, изменить и загрузить обратно, оплачивая весь документ **дважды** на каждой мелкой - правке. Этот сервер позволяет агенту изменить ровно один блок (`patch_node` / - `insert_node` / `delete_node`), сделать find/replace с сохранением структуры - (`edit_page_text`) или скопировать страницу на стороне сервера (`copy_page_content`) — + правке. Этот сервер позволяет агенту изменить ровно один блок (`patchNode` / + `insertNode` / `deleteNode`), сделать find/replace с сохранением структуры + (`editPageText`) или скопировать страницу на стороне сервера (`copyPageContent`) — **причём документ ни разу не проходит через модель**. - **Записи, которые не воюют с редактором.** Наивная запись через REST конфликтует с тем, @@ -87,12 +87,12 @@ Docmost-MCP не сочетают: - **Агентоориентированная модель редактирования.** Серверы «под человека» дают «открыть страницу» и «заменить страницу», потому что это отражает то, как работает человек. Модель редактирует лучше, *программируя* правку — адресуя блоки по id, делая find/replace или - передавая трансформ `(doc, ctx) => doc` (`docmost_transform`, с dry-run диффом перед + передавая трансформ `(doc, ctx) => doc` (`docmostTransform`, с dry-run диффом перед коммитом). Этот сервер построен вокруг этого — поэтому у него есть примитивы редактирования, которых у остальных просто нет. -- **Страховка при редактировании, которой нет у других.** `list_page_history` → - `diff_page_versions` → `restore_page_version` дают агенту (и вам) полный цикл «посмотреть +- **Страховка при редактировании, которой нет у других.** `listPageHistory` → + `diffPageVersions` → `restorePageVersion` дают агенту (и вам) полный цикл «посмотреть и откатить». Дифф использует *тот же* конвейер `recreateTransform → ChangeSet → simplifyChanges`, что и встроенный просмотр истории Docmost, так что результат совпадает с продуктом. @@ -113,59 +113,59 @@ Docmost-MCP не сочетают: ### Чтение и поиск -- **`get_workspace`** — Информация о текущем воркспейсе Docmost. -- **`list_spaces`** — Все пространства воркспейса. -- **`list_pages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию +- **`getWorkspace`** — Информация о текущем воркспейсе Docmost. +- **`listSpaces`** — Все пространства воркспейса. +- **`listPages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию 50, максимум 100). Для поиска в больших пространствах используйте `search`. - **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум 100). -- **`get_page`** — Контент страницы как чистый **Markdown** (удобно, но это +- **`getPage`** — Контент страницы как чистый **Markdown** (удобно, но это *lossy*-представление — id блоков и точная структура таблиц/коллаутов аппроксимируются). -- **`get_page_json`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id` +- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id` каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты поблочного редактирования. -- **`get_outline`** — Компактная структура страницы из блоков верхнего уровня (`{index, +- **`getOutline`** — Компактная структура страницы из блоков верхнего уровня (`{index, type, id, level, firstText}`; для таблиц добавляются число строк/столбцов и тексты ячеек заголовка, для списков — число пунктов) **без** тела документа. Дешёвый способ найти раздел или таблицу и получить - id блока перед `get_node` / `patch_node` / `insert_node`. -- **`get_node`** — Получить полное ProseMirror-поддерево одного блока (lossless), не - вытягивая всю страницу. Адресуйте его по id блока (из `get_outline` / `get_page_json`) + id блока перед `getNode` / `patchNode` / `insertNode`. +- **`getNode`** — Получить полное ProseMirror-поддерево одного блока (lossless), не + вытягивая всю страницу. Адресуйте его по id блока (из `getOutline` / `getPageJson`) или формой `#` для блока верхнего уровня — используйте `#` для таблиц/строк/ячеек, у которых нет id. ### Жизненный цикл страниц -- **`create_page`** — Создать страницу из Markdown и поместить в иерархию (опционально +- **`createPage`** — Создать страницу из Markdown и поместить в иерархию (опционально `parentPageId`) одним вызовом. Использует import API Docmost для чистой конвертации Markdown→ProseMirror. -- **`rename_page`** — Изменить только заголовок страницы, не трогая и не пересылая контент. -- **`move_page`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает +- **`renamePage`** — Изменить только заголовок страницы, не трогая и не пересылая контент. +- **`movePage`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает позиционирование по fractional-index. Возвращает успех только при *положительно подтверждённом* результате. -- **`delete_page`** — Удалить одну страницу. -- **`copy_page_content`** — Заменить тело одной страницы копией тела другой, **полностью на +- **`deletePage`** — Удалить одну страницу. +- **`copyPageContent`** — Заменить тело одной страницы копией тела другой, **полностью на стороне сервера** — документ не проходит через модель. У целевой страницы сохраняются собственные заголовок и slug (URL не меняется). ### Редактирование -- **`edit_page_text`** — Хирургический find/replace внутри текста страницы. Сохраняет +- **`editPageText`** — Хирургический find/replace внутри текста страницы. Сохраняет **всю** структуру: id блоков, marks, ссылки, коллауты, таблицы. Предпочтительный инструмент для правки формулировок, опечаток, чисел и имён. -- **`patch_node`** — Заменить один блок, адресованный по `attrs.id` (из `get_page_json`), +- **`patchNode`** — Заменить один блок, адресованный по `attrs.id` (из `getPageJson`), без пересылки документа. -- **`insert_node`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту) +- **`insertNode`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту) либо добавить в конец. -- **`delete_node`** — Удалить один блок по его `attrs.id`. -- **`update_page_json`** — Заменить весь контент страницы документом ProseMirror (массовые +- **`deleteNode`** — Удалить один блок по его `attrs.id`. +- **`updatePageJson`** — Заменить весь контент страницы документом ProseMirror (массовые перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история остаются стабильными. -- **`update_page_markdown`** — Заменить тело страницы (и опционально заголовок) новым +- **`updatePageMarkdown`** — Заменить тело страницы (и опционально заголовок) новым **обычным Markdown**. Всё тело переимпортируется (id блоков перегенерируются — для - хирургических правок или сохранения id используйте `edit_page_text` / `patch_node` / - `update_page_json`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`. -- **`docmost_transform`** — Агентоориентированный интерфейс редактирования: вместо + хирургических правок или сохранения id используйте `editPageText` / `patchNode` / + `updatePageJson`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`. +- **`docmostTransform`** — Агентоориентированный интерфейс редактирования: вместо перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом* документе ProseMirror. Работает в **песочнице** (без `require`/`process`/`fs`/сети, @@ -177,42 +177,42 @@ Docmost-MCP не сочетают: ### Таблицы -- **`table_get`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]), +- **`tableGet`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]), cellIds}` (id абзаца на ячейку или `null`). Адресуйте таблицу через `#` (из - `get_outline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patch_node` + `getOutline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patchNode` для правок ячеек с форматированием. -- **`table_insert_row`** — Вставить строку из текстовых ячеек, дополненную до числа +- **`tableInsertRow`** — Вставить строку из текстовых ячеек, дополненную до числа столбцов таблицы (передать ячеек больше числа столбцов — ошибка). `index` — 0-based позиция вставки (0 вставляет перед заголовком); опустите, чтобы добавить в конец. -- **`table_delete_row`** — Удалить строку по 0-based `index`. Отказывается удалять +- **`tableDeleteRow`** — Удалить строку по 0-based `index`. Отказывается удалять единственную строку таблицы; удаление строки 0 делает заголовком следующую строку. -- **`table_update_cell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для - форматирования используйте `patch_node` по id абзаца ячейки из `table_get`. +- **`tableUpdateCell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для + форматирования используйте `patchNode` по id абзаца ячейки из `tableGet`. ### Markdown: экспорт и импорт -- **`export_page_markdown`** — Экспортировать страницу в один самодостаточный, **lossless +- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный, **lossless Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из - обычного авторского Markdown, используйте `update_page_markdown`. + обычного авторского Markdown, используйте `updatePageMarkdown`. -> **Удалено в этом релизе:** `import_page_markdown` (парсер round-trip для +> **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для > экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**. -> Чтобы заменить тело страницы из Markdown, используйте **`update_page_markdown`** (замена +> Чтобы заменить тело страницы из Markdown, используйте **`updatePageMarkdown`** (замена > тела обычным Markdown). См. заметку о миграции в CHANGELOG. ### Изображения -- **`insert_image`** — Загрузить локальное изображение и вставить за один шаг: добавить в +- **`insertImage`** — Загрузить локальное изображение и вставить за один шаг: добавить в конец, поставить вместо текстового плейсхолдера (`replaceText`) или после заданного блока (`afterText`). Сохраняет id всех остальных блоков. -- **`replace_image`** — Заменить существующее изображение. Загружает новый файл как **новое +- **`replaceImage`** — Заменить существующее изображение. Загружает новый файл как **новое вложение** (чистый URL, который рендерится и сбрасывает кэш браузера), затем перенаправляет все узлы, ссылавшиеся на старое вложение (рекурсивно, включая коллауты/таблицы), через живой документ, сохраняя комментарии, выравнивание и alt-текст. (Перезапись «по месту» намеренно не используется — некоторые версии Docmost портят вложение при перезаписи.) -- **`stash_page`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в +- **`stashPage`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в эфемерный blob в оперативной памяти и вернуть ТОЛЬКО короткий анонимный URL — тело никогда не попадает в контекст модели, поэтому это способ передать большую страницу (вместе с её изображениями) внешнему потребителю без усечения. Каждое внутреннее @@ -224,35 +224,35 @@ Docmost-MCP не сочетают: ### Комментарии -- **`create_comment`** — Добавить комментарий к странице, опционально **привязав inline** к +- **`createComment`** — Добавить комментарий к странице, опционально **привязав inline** к точному фрагменту текста (первое вхождение оборачивается comment-маркой). -- **`list_comments`** — Список комментариев страницы (контент возвращается как Markdown). -- **`update_comment`** — Изменить существующий комментарий. -- **`delete_comment`** — Удалить комментарий. -- **`resolve_comment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve - доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `delete_comment`. -- **`check_new_comments`** — Найти комментарии, созданные после заданной метки времени +- **`listComments`** — Список комментариев страницы (контент возвращается как Markdown). +- **`updateComment`** — Изменить существующий комментарий. +- **`deleteComment`** — Удалить комментарий. +- **`resolveComment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve + доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `deleteComment`. +- **`checkNewComments`** — Найти комментарии, созданные после заданной метки времени ISO-8601, по пространству, опционально в рамках поддерева страниц — идеально для агента, который следит за обратной связью в документе. ### Версии и история -- **`list_page_history`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом +- **`listPageHistory`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом сохранении), новые сверху, курсорная пагинация. id каждого элемента — это `historyId`. -- **`diff_page_versions`** — Дифф двух версий (или версии против живой страницы). +- **`diffPageVersions`** — Дифф двух версий (или версии против живой страницы). Возвращает вставленный/удалённый текст, счётчики целостности (изображения, ссылки, таблицы, коллауты, маркеры сносок) и человекочитаемую Markdown-сводку — посчитано тем же конвейером, что использует встроенный просмотр истории Docmost. -- **`restore_page_version`** — Записать сохранённую версию обратно как текущий контент. У +- **`restorePageVersion`** — Записать сохранённую версию обратно как текущий контент. У Docmost нет эндпоинта восстановления, поэтому создаётся **новый** снапшот — само восстановление тоже обратимо. ### Публикация -- **`share_page`** — Сделать страницу публично доступной (идемпотентно) и вернуть её +- **`sharePage`** — Сделать страницу публично доступной (идемпотентно) и вернуть её публичный URL (`/share//p/`); опционально индексирование поисковиками. -- **`unshare_page`** — Отозвать публичный доступ к странице. -- **`list_shares`** — Все публичные ссылки воркспейса с заголовками и публичными URL. +- **`unsharePage`** — Отозвать публичный доступ к странице. +- **`listShares`** — Все публичные ссылки воркспейса с заголовками и публичными URL. --- @@ -261,29 +261,29 @@ Docmost-MCP не сочетают: Та же подсказка отдаётся в рантайме через поле `instructions` MCP-сервера, так что подходящие клиенты направляют модель автоматически. -- **Правки текста** (формулировки, опечатки, числа): `edit_page_text`. -- **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patch_node` / `insert_node` / - `delete_node`, адресуя узел по его `attrs.id` из `get_page_json`. -- **Изображения**: `insert_image` / `replace_image`. -- **Новая страница**: `create_page`. -- **Массовая перезапись или узлы без id**: `update_page_json` (ProseMirror) или - `update_page_markdown` (замена тела обычным Markdown). +- **Правки текста** (формулировки, опечатки, числа): `editPageText`. +- **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patchNode` / `insertNode` / + `deleteNode`, адресуя узел по его `attrs.id` из `getPageJson`. +- **Изображения**: `insertImage` / `replaceImage`. +- **Новая страница**: `createPage`. +- **Массовая перезапись или узлы без id**: `updatePageJson` (ProseMirror) или + `updatePageMarkdown` (замена тела обычным Markdown). - **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки): - `docmost_transform` — предпросмотр через `dryRun`, затем применение. + `docmostTransform` — предпросмотр через `dryRun`, затем применение. - **Скопировать контент целой страницы из другой** (на стороне сервера): - `copy_page_content`. -- **Переименовать страницу** (только заголовок): `rename_page`. -- **Чтение**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror с id). -- **Просмотр изменений**: `list_page_history` → `diff_page_versions` → - `restore_page_version`. -- **Комментарии**: `create_comment` (с опциональной inline-привязкой) / `list_comments` / - `update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`. -- **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `get_outline` - → `get_node`. -- **Таблицы** (добавить/удалить строку, задать ячейку): `table_get` / `table_insert_row` / - `table_delete_row` / `table_update_cell`. -- **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `export_page_markdown`. -- **Заменить тело страницы из Markdown**: `update_page_markdown`. + `copyPageContent`. +- **Переименовать страницу** (только заголовок): `renamePage`. +- **Чтение**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror с id). +- **Просмотр изменений**: `listPageHistory` → `diffPageVersions` → + `restorePageVersion`. +- **Комментарии**: `createComment` (с опциональной inline-привязкой) / `listComments` / + `updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`. +- **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `getOutline` + → `getNode`. +- **Таблицы** (добавить/удалить строку, задать ячейку): `tableGet` / `tableInsertRow` / + `tableDeleteRow` / `tableUpdateCell`. +- **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `exportPageMarkdown`. +- **Заменить тело страницы из Markdown**: `updatePageMarkdown`. --- @@ -302,8 +302,8 @@ Docmost-MCP не сочетают: автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один повторный логин. -- **Lossless- и lossy-чтение.** `get_page_json` возвращает точное дерево ProseMirror с id - блоков; `get_page` возвращает чистый Markdown для удобства. +- **Lossless- и lossy-чтение.** `getPageJson` возвращает точное дерево ProseMirror с id + блоков; `getPage` возвращает чистый Markdown для удобства. - **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты (включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы, блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами @@ -316,7 +316,7 @@ Docmost-MCP не сочетают: нужных агентам, а большие коллекции (пространства, страницы, комментарии, история) пагинируются. - **Закалённый рантайм.** Глобальные обработчики не дают случайной ошибке сокета уронить - stdio-сервер; `move_page` требует положительно подтверждённого успеха; движок диффа + stdio-сервер; `movePage` требует положительно подтверждённого успеха; движок диффа откатывается к грубому поблочному диффу, а не падает на патологическом документе. --- @@ -376,7 +376,7 @@ npm run test:e2e Проект начинался как форк [MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp) (автор Moritz Krause) и существенно его расширяет — добавлены поблочное редактирование узлов, хирургические -правки текста, песочница `docmost_transform`, история версий / дифф / восстановление, +правки текста, песочница `docmostTransform`, история версий / дифф / восстановление, комментарии, вставка/замена изображений, публичные ссылки, серверное копирование страниц, двойное чтение JSON/Markdown, прозрачная переавторизация и значительное упрочнение. Инструменты комментариев портированы из upstream PR #3 от Max Nikitin. Спасибо обоим. diff --git a/packages/mcp/TEST-PLAN.md b/packages/mcp/TEST-PLAN.md index 24fdd733..1e56b9c0 100644 --- a/packages/mcp/TEST-PLAN.md +++ b/packages/mcp/TEST-PLAN.md @@ -22,14 +22,14 @@ are debounced server-side, so the script waits ~16 s before reading back via RES | # | Tool / path | What is checked | Expected | |---|-------------|-----------------|----------| -| 1 | `create_page` | title with spaces, slugId returned | page created, title intact | +| 1 | `createPage` | title with spaces, slugId returned | page created, title intact | | 2 | `update_page` (markdown) | headings, **bold**/*italic*/~~strike~~/`code`/link, nested bullet + ordered lists, blockquote, code block, `:::callout:::`, table | all structures survive re-import | -| 3 | `get_page_json` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) | -| 4 | `edit_page_text` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct | -| 5 | `update_page_json` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact | +| 3 | `getPageJson` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) | +| 4 | `editPageText` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct | +| 5 | `updatePageJson` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact | | 6 | `upload_image` | uploads attachment, returns node | src is a **clean** `/api/files//` URL, served `200 image/*` | -| 7 | `insert_image` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved | -| 8 | **`replace_image`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed | +| 7 | `insertImage` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved | +| 8 | **`replaceImage`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed | ## Image-specific assertions (the recurring bug area) @@ -39,7 +39,7 @@ For every uploaded/inserted/replaced image, assert at the HTTP level that the * `GET ` → `200`, `Content-Type: image/*`, body starts with the image magic (`89 50 4E 47` for PNG, etc.). * `src` does **not** contain a `?v=` query (see "Known pitfalls"). -* After `replace_image`: the returned `newAttachmentId` **differs** from the old +* After `replaceImage`: the returned `newAttachmentId` **differs** from the old one (replacement uses a fresh attachment → fresh URL), and `GET ` → `200`. * The old image node on the page is repointed to the new attachmentId. @@ -64,7 +64,7 @@ broken/empty figure. Uploading with an existing `attachmentId` (`POST /files/upload` + `attachmentId`) overwrites the bytes in place. On this Docmost the attachment then returns **500 for every URL** (clean, `?v=`, any filename) → broken image. Therefore - `replace_image` must upload a **new** attachment and repoint the nodes; the new + `replaceImage` must upload a **new** attachment and repoint the nodes; the new id yields a new URL that both renders and busts the browser cache. The old attachment is left as an unreferenced orphan: Docmost exposes **no HTTP API to delete a single content attachment** (verified against the attachment @@ -80,9 +80,9 @@ broken/empty figure. from `?v=`. Image `src` is kept clean (`/api/files//`); cache-busting on replace is achieved by the new attachment id. -3. **REST snapshot lag.** `get_page_json` reads the debounced DB snapshot, so a +3. **REST snapshot lag.** `getPageJson` reads the debounced DB snapshot, so a write made moments earlier may not be visible yet. Wait (~16 s) before reading - back, and never feed a possibly-stale snapshot straight into `update_page_json`. + back, and never feed a possibly-stale snapshot straight into `updatePageJson`. 4. **Callout type narrowing (minor, open).** A `:::warning` callout is imported as `type: "info"` — the markdown→callout conversion does not carry non-`info` diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 89891063..8852378f 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -139,7 +139,7 @@ export type DocmostMcpConfig = { apiUrl: string } & ( // both branches; see the type doc above. getCollabToken?: () => Promise; // Optional blob sandbox sink. Present only where the stash tool is wired; - // when absent, stash_page throws a clear "not configured" error. The + // when absent, stashPage throws a clear "not configured" error. The // optional `has`/`evict` probes let stashPage keep its mirror counts honest // under the store's FIFO eviction (see stashPage); older sinks omit them. sandbox?: { @@ -236,7 +236,7 @@ export function assertFullUuid( throw new Error( `${tool}: '${param}' must be the FULL comment UUID (36 chars, e.g. ` + `019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '${value}'. Copy the id ` + - `verbatim from list_comments / create_comment output.`, + `verbatim from listComments / createComment output.`, ); } } @@ -816,7 +816,7 @@ export class DocmostClient { if (tree) { if (!spaceId) { throw new Error( - "list_pages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.", + "listPages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.", ); } const { pages } = await this.enumerateSpacePages(spaceId); @@ -946,7 +946,7 @@ export class DocmostClient { // listSidebarPages(spaceId, rootPageId) returns only the root's CHILDREN. // The `visited` set below prevents a double-add if the root also appears // among the children. getPageRaw returns a page whose id/title/spaceId are - // exactly what buildPageTree and check_new_comments consume. + // exactly what buildPageTree and checkNewComments consume. if (rootPageId) { try { const root = await this.getPageRaw(rootPageId); @@ -1033,7 +1033,7 @@ export class DocmostClient { const resultData = await this.getPageRaw(pageId); // Agent read: hide resolved-comment anchors so the agent sees only active - // discussions. Active anchors are kept. (The lossless export_page_markdown + // discussions. Active anchors are kept. (The lossless exportPageMarkdown // round-trip deliberately does NOT pass this flag — resolved anchors there // must be preserved.) let content = resultData.content @@ -1146,12 +1146,12 @@ export class DocmostClient { }> { if (!this.sandboxPut) { throw new Error( - "stash_page is unavailable: the blob sandbox is not configured on this server", + "stashPage is unavailable: the blob sandbox is not configured on this server", ); } await this.ensureAuthenticated(); - // Stash the SAME shape get_page_json returns (id/title/.../content), with a + // Stash the SAME shape getPageJson returns (id/title/.../content), with a // deep clone so the rewrite never mutates anything shared. const pageJson = await this.getPageJson(pageId); const cloned: any = structuredClone(pageJson); @@ -1200,7 +1200,7 @@ export class DocmostClient { // matching the package's ungated console.warn convention. failed++; console.warn( - `stash_page: failed to mirror "${src}": ${ + `stashPage: failed to mirror "${src}": ${ err instanceof Error ? err.message : String(err) }`, ); @@ -1220,7 +1220,7 @@ export class DocmostClient { mirrored--; failed++; console.warn( - `stash_page: mirrored blob ${mirror.uri} was evicted before the doc ` + + `stashPage: mirrored blob ${mirror.uri} was evicted before the doc ` + `could safely reference it; reverted its src and counted it as failed`, ); }; @@ -1284,7 +1284,7 @@ export class DocmostClient { /** * Compact outline of a page's top-level blocks (no full document body). * Cheap way to locate sections/tables and grab block ids before drilling in - * with get_node / patch_node / insert_node. + * with getNode / patchNode / insertNode. */ async getOutline(pageId: string) { await this.ensureAuthenticated(); @@ -1312,7 +1312,7 @@ export class DocmostClient { ); if (!hit) { throw new Error( - `get_node: no node found for "${nodeId}" on page ${pageId} (use a block id from get_outline, or "#" for a top-level block such as a table)`, + `getNode: no node found for "${nodeId}" on page ${pageId} (use a block id from getOutline, or "#" for a top-level block such as a table)`, ); } return { @@ -1329,10 +1329,10 @@ export class DocmostClient { * each text container (reusing the same `getPageRaw` fetch as the other read * tools) — no server search endpoint, no whole-document round-trip through the * model. Returns `{ total, truncated, matches }`; each match carries a ref for - * get_node/patch_node (the `#` form resolves with get_node but NOT - * patch_node — see SearchMatch.nodeId), plus the top-level block index and a + * getNode/patchNode (the `#` form resolves with getNode but NOT + * patchNode — see SearchMatch.nodeId), plus the top-level block index and a * short context window used to build a unique text `selection` for - * create_comment (create_comment has no nodeId param). The pure engine + * createComment (createComment has no nodeId param). The pure engine * (`searchInDoc`) owns the traversal, glue, the RE2 ReDoS-safe regex engine * and the empty-query / invalid-or-unsupported-regex errors. */ @@ -1348,10 +1348,10 @@ export class DocmostClient { } /** - * Read a table as a matrix. `tableRef` is `#` (from get_outline) or a + * Read a table as a matrix. `tableRef` is `#` (from getOutline) or a * block id of any node inside the table. Returns the cell texts plus a * parallel cellIds matrix (each cell's first paragraph id, or null) so a - * caller can patch_node a cell for rich-formatted edits. Throws when no table + * caller can patchNode a cell for rich-formatted edits. Throws when no table * resolves for the reference. */ async getTable(pageId: string, tableRef: string) { @@ -1360,7 +1360,7 @@ export class DocmostClient { const t = readTable(data.content ?? { type: "doc", content: [] }, tableRef); if (!t) { throw new Error( - `table_get: no table found for "${tableRef}" on page ${pageId} (use "#" from get_outline, or a block id inside the table)`, + `tableGet: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, ); } return { @@ -1415,7 +1415,7 @@ export class DocmostClient { if (!inserted) { throw new Error( - `table_insert_row: no table found for "${tableRef}" on page ${pageId} (use "#" from get_outline, or a block id inside the table)`, + `tableInsertRow: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, ); } return { @@ -1457,7 +1457,7 @@ export class DocmostClient { if (!deleted) { throw new Error( - `table_delete_row: no table found for "${tableRef}" on page ${pageId} (use "#" from get_outline, or a block id inside the table)`, + `tableDeleteRow: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, ); } return { @@ -1509,7 +1509,7 @@ export class DocmostClient { if (!updated) { throw new Error( - `table_update_cell: no table found for "${tableRef}" on page ${pageId} (use "#" from get_outline, or a block id inside the table)`, + `tableUpdateCell: no table found for "${tableRef}" on page ${pageId} (use "#" from getOutline, or a block id inside the table)`, ); } return { @@ -1846,7 +1846,7 @@ export class DocmostClient { * `Unknown node type: undefined`, but only AFTER a collab session was opened * and a page lock taken. Calling this BEFORE `getCollabTokenWithReauth` / * `mutatePageContent` fails fast: no collab connection, no lock, deterministic - * message. `op` names the tool for the message prefix (e.g. "patch_node"). + * message. `op` names the tool for the message prefix (e.g. "patchNode"). * * `findInvalidNode` derives its "known type" set from the very same * `docmostExtensions` the encode path uses, so a node this gate accepts is one @@ -1878,7 +1878,7 @@ export class DocmostClient { if (doc == null) { if (!title) { throw new Error( - "update_page_json: nothing to update (provide content and/or title)", + "updatePageJson: nothing to update (provide content and/or title)", ); } await this.client.post("/pages/update", { pageId, title }); @@ -1914,7 +1914,7 @@ export class DocmostClient { // `type` is a string but NOT a known Docmost schema node (a typo/unknown // block) — the same `Unknown node type` the encoder throws — with a rich, // path-anchored message, still BEFORE any collab connection. - this.assertValidNodeShape("update_page_json", doc); + this.assertValidNodeShape("updatePageJson", doc); // Sanitize URLs before writing. This closes the JSON-path bypass: unlike // the markdown link path (which TipTap sanitizes), raw JSON could otherwise @@ -1971,10 +1971,10 @@ export class DocmostClient { async insertFootnote(pageId: string, anchorText: string, text: string) { await this.ensureAuthenticated(); if (!anchorText || !anchorText.trim()) { - throw new Error("insert_footnote: anchorText is required"); + throw new Error("insertFootnote: anchorText is required"); } if (text == null || `${text}`.trim() === "") { - throw new Error("insert_footnote: text is required"); + throw new Error("insertFootnote: text is required"); } const collabToken = await this.getCollabTokenWithReauth(); // Open the collab doc by the canonical UUID, never the slugId (#260). @@ -1991,7 +1991,7 @@ export class DocmostClient { // persist when the transform throws, so a missing anchor leaves the // page untouched (no partial write). throw new Error( - `insert_footnote: anchor text not found: ${JSON.stringify( + `insertFootnote: anchor text not found: ${JSON.stringify( anchorText.slice(0, 80), )}`, ); @@ -2018,7 +2018,7 @@ export class DocmostClient { /** * Page-locked write seam over collaboration.mutatePageContent. Production just - * delegates; it exists as an overridable method so the insert_footnote wrapper + * delegates; it exists as an overridable method so the insertFootnote wrapper * (transform abort-on-not-found + response shaping) can be unit-tested without * standing up a live Hocuspocus collab socket. */ @@ -2034,7 +2034,7 @@ export class DocmostClient { /** * Full-document write seam over collaboration.replacePageContent. Production * just delegates; it exists as an overridable method so the full-doc write - * tools (update_page_json, copy_page_content) can have their footnote- + * tools (updatePageJson, copyPageContent) can have their footnote- * canonicalization binding unit-tested without a live Hocuspocus collab socket. */ protected replacePage( @@ -2165,7 +2165,7 @@ export class DocmostClient { // mistake surfaces as a clear error rather than a silent round-trip. if (sourcePageId === targetPageId) { throw new Error( - "copy_page_content: sourcePageId and targetPageId are the same page (no-op copy)", + "copyPageContent: sourcePageId and targetPageId are the same page (no-op copy)", ); } @@ -2178,7 +2178,7 @@ export class DocmostClient { !Array.isArray(content.content) ) { throw new Error( - `copy_page_content: source page ${sourcePageId} has no usable ProseMirror content to copy`, + `copyPageContent: source page ${sourcePageId} has no usable ProseMirror content to copy`, ); } @@ -2262,7 +2262,7 @@ export class DocmostClient { // No edit applied: surface an aggregated, actionable error so the caller // does not mistake a no-op for a partial success. throw new Error( - "edit_page_text: no edits were applied (nothing written). " + + "editPageText: no edits were applied (nothing written). " + failed!.map((f) => `"${f.find}": ${f.reason}`).join("; "), ); } @@ -2293,14 +2293,14 @@ export class DocmostClient { }; // If any applied edit matched only after stripping markdown (the - // normalized fallback), warn that edit_page_text preserved existing marks + // normalized fallback), warn that editPageText preserved existing marks // and did NOT change formatting — so a caller who intended a formatting - // change is pointed at patch_node. + // change is pointed at patchNode. if (results?.some((r) => r.normalized === true)) { result.warning = "Some edits matched only after stripping markdown from your find string; " + - "edit_page_text preserved existing marks (it did not change bold/strike/etc.). " + - "If you intended a formatting change, use patch_node."; + "editPageText preserved existing marks (it did not change bold/strike/etc.). " + + "If you intended a formatting change, use patchNode."; } return result; @@ -2320,7 +2320,7 @@ export class DocmostClient { if (!node || typeof node !== "object" || typeof node.type !== "string") { throw new Error( - "patch_node: `node` must be an object with a string `type`", + "patchNode: `node` must be an object with a string `type`", ); } // Preserve the block id WITHOUT mutating the caller's object: build a local @@ -2342,7 +2342,7 @@ export class DocmostClient { // lock — the root-only `typeof node.type === "string"` check above never // sees nested children, and the encoder's `Unknown node type: undefined` // would otherwise only surface after the connection. - this.assertValidNodeShape("patch_node", target); + this.assertValidNodeShape("patchNode", target); const collabToken = await this.getCollabTokenWithReauth(); // Open the collab doc by the canonical UUID, never the slugId (#260). @@ -2376,7 +2376,7 @@ export class DocmostClient { // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped // the write for any count !== 1). Single shared guard (#159, #185 review). - assertUnambiguousMatch("patch_node", "replace", replaced, nodeId, pageId); + assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId); return { success: true, replaced, nodeId, verify: mutation.verify }; } @@ -2408,7 +2408,7 @@ export class DocmostClient { if (!node || typeof node !== "object" || typeof node.type !== "string") { throw new Error( - "insert_node: `node` must be an object with a string `type`", + "insertNode: `node` must be an object with a string `type`", ); } if ( @@ -2418,7 +2418,7 @@ export class DocmostClient { opts.position !== "append") ) { throw new Error( - 'insert_node: `position` must be one of "before", "after", "append"', + 'insertNode: `position` must be one of "before", "after", "append"', ); } if (opts.position === "before" || opts.position === "after") { @@ -2429,7 +2429,7 @@ export class DocmostClient { typeof opts.anchorText === "string" && opts.anchorText.length > 0; if (hasId === hasText) { throw new Error( - `insert_node: position "${opts.position}" requires exactly one of anchorNodeId or anchorText`, + `insertNode: position "${opts.position}" requires exactly one of anchorNodeId or anchorText`, ); } } @@ -2437,7 +2437,7 @@ export class DocmostClient { // #409: fail fast on a malformed node SHAPE (a nested child with an // absent/unknown `type`) BEFORE opening a collab session or taking the page // lock — the root-only check above never sees nested children. - this.assertValidNodeShape("insert_node", node); + this.assertValidNodeShape("insertNode", node); const collabToken = await this.getCollabTokenWithReauth(); // Open the collab doc by the canonical UUID, never the slugId (#260). @@ -2471,10 +2471,10 @@ export class DocmostClient { // markdown/emoji are tolerated only as a strip-and-retry fallback, so a // miss usually means the text differs from what's on the page. const hint = opts.anchorText - ? " anchorText must be the block's literal rendered plain text (no markdown wrappers or emoji); anchorNodeId from get_page_json is more reliable." + ? " anchorText must be the block's literal rendered plain text (no markdown wrappers or emoji); anchorNodeId from getPageJson is more reliable." : ""; throw new Error( - `insert_node: anchor not found (${anchorDesc}) on page ${pageId}.${hint}`, + `insertNode: anchor not found (${anchorDesc}) on page ${pageId}.${hint}`, ); } @@ -2522,7 +2522,7 @@ export class DocmostClient { // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped // the write for any count !== 1). Single shared guard (#159, #185 review). - assertUnambiguousMatch("delete_node", "delete", deleted, nodeId, pageId); + assertUnambiguousMatch("deleteNode", "delete", deleted, nodeId, pageId); return { success: true, deleted, nodeId, verify: mutation.verify }; } @@ -2771,8 +2771,8 @@ export class DocmostClient { } /** - * Build the actionable error for a create_comment anchor MISS, porting - * edit_page_text's self-correction affordances: an explicit "spans multiple + * Build the actionable error for a createComment anchor MISS, porting + * editPageText's self-correction affordances: an explicit "spans multiple * blocks" message when the selection straddles a block boundary, otherwise a * "closest block text" hint quoting the block that holds the selection's * longest token. `live` switches the wording between the pre-check (reading the @@ -2787,14 +2787,14 @@ export class DocmostClient { const rolled = live ? " The comment was rolled back." : ""; if (this.selectionSpansMultipleBlocks(blockTexts, selection)) { return new Error( - "create_comment: the selection spans multiple blocks; anchor on a " + + "createComment: the selection spans multiple blocks; anchor on a " + "contiguous fragment within a SINGLE paragraph/block (<=250 chars)." + rolled, ); } const where = live ? "in the live document" : "in the page"; return new Error( - `create_comment: could not find the selection text ${where} to anchor ` + + `createComment: could not find the selection text ${where} to anchor ` + "the comment. Provide the EXACT contiguous text from a single " + "paragraph/block (<=250 chars)." + closestBlockHint(blockTexts, selection) + @@ -2825,7 +2825,7 @@ export class DocmostClient { // network call. Validate only when truthy — a falsy parentCommentId means // "top-level comment" (mirrors the isReply computation below), not a reply. if (parentCommentId) { - assertFullUuid("create_comment", "parentCommentId", parentCommentId); + assertFullUuid("createComment", "parentCommentId", parentCommentId); } await this.ensureAuthenticated(); @@ -2838,12 +2838,12 @@ export class DocmostClient { if (hasSuggestion) { if (isReply) { throw new Error( - "create_comment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.", + "createComment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.", ); } if (!selection || !selection.trim()) { throw new Error( - "create_comment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.", + "createComment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.", ); } } @@ -2855,7 +2855,7 @@ export class DocmostClient { const effectiveType: "page" | "inline" = isReply ? "page" : "inline"; if (!isReply && (!selection || !selection.trim())) { throw new Error( - "create_comment: an inline 'selection' (exact text to anchor on) is required for a top-level comment", + "createComment: an inline 'selection' (exact text to anchor on) is required for a top-level comment", ); } @@ -2871,7 +2871,7 @@ export class DocmostClient { let anchoredSelection: string | null = null; // Set when the anchor matched only after stripping markdown from the // selection (the strip fallback); surfaced as a soft warning like - // edit_page_text does, so a stale-markdown selection is flagged. + // editPageText does, so a stale-markdown selection is flagged. let anchorNormalized = false; // For a top-level comment, fail BEFORE creating anything when the selection @@ -2892,7 +2892,7 @@ export class DocmostClient { } if (matches >= 2) { throw new Error( - `create_comment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` + + `createComment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` + "A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " + "(still <=250 chars) so it appears exactly once.", ); @@ -2919,12 +2919,12 @@ export class DocmostClient { // enforce) anchoring. if ( e instanceof Error && - (e.message.startsWith("create_comment: could not find the selection") || + (e.message.startsWith("createComment: could not find the selection") || e.message.startsWith( - "create_comment: the selection spans multiple blocks", + "createComment: the selection spans multiple blocks", ) || e.message.startsWith( - "create_comment: the suggestion's selection is ambiguous", + "createComment: the suggestion's selection is ambiguous", )) ) { throw e; @@ -2987,7 +2987,7 @@ export class DocmostClient { // to roll back here (nothing was created with an id), so just fail loudly. if (!newCommentId) { throw new Error( - "create_comment: the server returned no comment id, so the comment could not be anchored", + "createComment: the server returned no comment id, so the comment could not be anchored", ); } let anchored = false; @@ -3064,24 +3064,24 @@ export class DocmostClient { await this.safeDeleteComment(newCommentId); if (ambiguousInLiveDoc) { throw new Error( - "create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.", + "createComment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.", ); } throw ( liveNotFoundError ?? new Error( - "create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back", + "createComment: failed to anchor the comment (selection not found in the live document); the comment was rolled back", ) ); } - // Soft warning (like edit_page_text): the selection only matched after + // Soft warning (like editPageText): the selection only matched after // stripping markdown, so the caller likely quoted a styled fragment. if (anchorNormalized) { result.warning = "The selection matched only after stripping markdown syntax; the comment " + "was anchored on the document's plain text. Copy the selection verbatim " + - "from get_page / search_in_page output to avoid this."; + "from getPage / searchInPage output to avoid this."; } result.anchored = true; @@ -3110,7 +3110,7 @@ export class DocmostClient { async updateComment(commentId: string, content: string) { // Fail fast (#436): reject a truncated id before any network call. - assertFullUuid("update_comment", "commentId", commentId); + assertFullUuid("updateComment", "commentId", commentId); await this.ensureAuthenticated(); // NON-canonicalizing on purpose (comment body — see createComment). const jsonContent = await markdownToProseMirror(content); @@ -3127,7 +3127,7 @@ export class DocmostClient { async deleteComment(commentId: string) { // Fail fast (#436): reject a truncated id before any network call. - assertFullUuid("delete_comment", "commentId", commentId); + assertFullUuid("deleteComment", "commentId", commentId); await this.ensureAuthenticated(); return this.client .post("/comments/delete", { commentId }) @@ -3141,7 +3141,7 @@ export class DocmostClient { */ async resolveComment(commentId: string, resolved: boolean) { // Fail fast (#436): reject a truncated id before any network call. - assertFullUuid("resolve_comment", "commentId", commentId); + assertFullUuid("resolveComment", "commentId", commentId); await this.ensureAuthenticated(); const response = await this.client.post("/comments/resolve", { commentId, @@ -3206,7 +3206,7 @@ export class DocmostClient { for (const page of pagesInScope) { try { // Full feed (incl. resolved): a "new comments since" scan reports all - // recent activity; the active-only filter is scoped to list_comments. + // recent activity; the active-only filter is scoped to listComments. const comments = (await this.listComments(page.id, true)).items; const newComments = comments.filter( (c: any) => new Date(c.createdAt) > sinceDate, @@ -3539,7 +3539,7 @@ export class DocmostClient { }; // Insert into the LIVE synced document, not the debounced REST snapshot, so - // concurrent edits/comments/images are preserved and parallel insert_image + // concurrent edits/comments/images are preserved and parallel insertImage // calls (serialized by the per-page lock) each see the previous insertion. let placement: "replaced" | "after" | "appended" | undefined; const mutation = await mutatePageContent( @@ -3588,7 +3588,7 @@ export class DocmostClient { if (matchedBlock && CONTAINER_TYPES.has(matchedBlock.type)) { throw new Error( `replaceText matched a ${matchedBlock.type} container block; replacing it would destroy the whole structure. ` + - `Use afterText to insert near it, or update_page_json for surgical edits.`, + `Use afterText to insert near it, or updatePageJson for surgical edits.`, ); } doc.content.splice(idx, 1, node); @@ -3705,7 +3705,7 @@ export class DocmostClient { if (!matchFound) { throw new Error( - `replace_image: no image with attachmentId "${oldAttachmentId}" found on page ${pageId}`, + `replaceImage: no image with attachmentId "${oldAttachmentId}" found on page ${pageId}`, ); } @@ -3808,7 +3808,7 @@ export class DocmostClient { /** * Upload a ready-made byte buffer as a page attachment via the same * multipart /files/upload endpoint uploadImage uses. Split out as its own - * (overridable) seam so drawio_create/update can upload the generated + * (overridable) seam so drawioCreate/update can upload the generated * `.drawio.svg` without going through the URL-fetch path, and so tests can * stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403 * re-auth handling (a FormData body is single-use, so it must be rebuilt per @@ -3882,7 +3882,7 @@ export class DocmostClient { /** * Fetch a stored `.drawio.svg` attachment as text. Overridable seam over * fetchInternalFile (the authed loopback fetch, which also rejects any - * traversal/SSRF src) so drawio_get/update can read the current diagram and + * traversal/SSRF src) so drawioGet/update can read the current diagram and * tests can stub the bytes. */ protected async fetchAttachmentText(src: string): Promise { @@ -3906,7 +3906,7 @@ export class DocmostClient { ); if (!hit) { throw new Error( - `drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#" from get_outline)`, + `drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#" from getOutline)`, ); } if (hit.type !== "drawio") { @@ -3921,7 +3921,7 @@ export class DocmostClient { * Read a drawio diagram as mxGraph XML (default) or as the raw `.drawio.svg`. * Runs the decode chain (base64/entity content= → drawio file → nested XML or * pako-inflated compressed ). The returned `hash` is the - * optimistic-lock key for drawio_update. + * optimistic-lock key for drawioUpdate. */ async drawioGet( pageId: string, @@ -4001,7 +4001,7 @@ export class DocmostClient { where.position !== "append") ) { throw new Error( - 'drawio_create: `where.position` must be one of "before", "after", "append"', + 'drawioCreate: `where.position` must be one of "before", "after", "append"', ); } if (where.position === "before" || where.position === "after") { @@ -4011,7 +4011,7 @@ export class DocmostClient { typeof where.anchorText === "string" && where.anchorText.length > 0; if (hasId === hasText) { throw new Error( - `drawio_create: position "${where.position}" requires exactly one of anchorNodeId or anchorText`, + `drawioCreate: position "${where.position}" requires exactly one of anchorNodeId or anchorText`, ); } } @@ -4091,7 +4091,7 @@ export class DocmostClient { ? `anchorNodeId "${where.anchorNodeId}"` : `anchorText "${where.anchorText}"`; throw new Error( - `drawio_create: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`, + `drawioCreate: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`, ); } @@ -4101,14 +4101,14 @@ export class DocmostClient { // cannot reference it. drawio nodes carry no persisted id, so there is no // stable handle for a nested diagram. throw new Error( - `drawio_create: the diagram was inserted on page ${pageId} but not as a ` + + `drawioCreate: the diagram was inserted on page ${pageId} but not as a ` + `top-level block, so it has no addressable "#" handle. Anchor ` + `on a top-level block (or append) so the diagram can be re-read.`, ); } // The returned handle is POSITIONAL ("#"): valid for the immediate - // create -> get/update flow, but re-resolve via get_outline if the document + // create -> get/update flow, but re-resolve via getOutline if the document // structure changes (blocks added/removed before it shift the index). const nodeId = `#${insertedIndex}`; @@ -4123,7 +4123,7 @@ export class DocmostClient { /** * Full-replacement update of a drawio diagram. `baseHash` is MANDATORY: it is - * compared against the hash of the diagram's CURRENT XML (from drawio_get); + * compared against the hash of the diagram's CURRENT XML (from drawioGet); * any mismatch means a human or another agent edited the diagram after the * read, so the write is refused with a conflict error. On success the new * `.drawio.svg` is uploaded as a FRESH attachment (in-place byte overwrite is @@ -4146,7 +4146,7 @@ export class DocmostClient { await this.ensureAuthenticated(); if (typeof baseHash !== "string" || baseHash.length === 0) { throw new Error( - "drawio_update: baseHash is mandatory — read the diagram with drawio_get first and pass back its meta.hash", + "drawioUpdate: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash", ); } @@ -4161,15 +4161,15 @@ export class DocmostClient { const nodeId = oldAttrs.id ?? ref; if (!oldSrc) { throw new Error( - `drawio_update: node "${node}" on page ${pageId} has no src to compare against`, + `drawioUpdate: node "${node}" on page ${pageId} has no src to compare against`, ); } const currentSvg = await this.fetchAttachmentText(oldSrc); const currentHash = mxHash(decodeDrawioSvg(currentSvg)); if (currentHash !== baseHash) { throw new Error( - `drawio_update: conflict — the diagram changed since it was read ` + - `(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawio_get and retry.`, + `drawioUpdate: conflict — the diagram changed since it was read ` + + `(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`, ); } @@ -4305,7 +4305,7 @@ export class DocmostClient { typeof version.content !== "object" ) { throw new Error( - `restore_page_version: history ${historyId} has no usable content`, + `restorePageVersion: history ${historyId} has no usable content`, ); } // Defense-in-depth: sanitize URLs in the restored content (parity with the diff --git a/packages/mcp/src/comment-signal.ts b/packages/mcp/src/comment-signal.ts index b7b9cf85..0aa053cf 100644 --- a/packages/mcp/src/comment-signal.ts +++ b/packages/mcp/src/comment-signal.ts @@ -57,17 +57,14 @@ export const DEFAULT_COMMENT_SIGNAL_DEBOUNCE_MS = 20_000; /** * Tools whose OWN result must NOT carry the signal — it would be tautological - * (the agent is already looking at comments) and noisy. Listed in BOTH the - * standalone MCP snake_case names AND the in-app camelCase keys so a single set - * covers both surfaces (the signal text itself uses the camelCase `listComments` - * per roadmap #412). `getComment` (single fetch) is intentionally NOT excluded. + * (the agent is already looking at comments) and noisy. Since issue #412 both + * the standalone MCP surface and the in-app agent use the same camelCase tool + * names, so a single set of camelCase names covers both surfaces. `getComment` + * (single fetch) is intentionally NOT excluded. */ export const COMMENT_SIGNAL_EXCLUDED_TOOLS: ReadonlySet = new Set([ - "list_comments", "listComments", - "check_new_comments", "checkNewComments", - "create_comment", "createComment", ]); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 4c22ea6f..c2a69446 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -58,7 +58,7 @@ export type { CommentSignalTrackerOptions, } from "./comment-signal.js"; // Re-export the pure, no-network draw.io helpers (#424) so the in-app AI-SDK -// service can wire drawio_shapes / drawio_guide off the loaded module. These are +// service can wire drawioShapes / drawioGuide off the loaded module. These are // NOT client methods (no page/backend hit) — the in-app handler calls them // directly, mirroring how the standalone MCP server wires them here. export { searchShapes } from "./lib/drawio-shapes.js"; @@ -89,7 +89,7 @@ const VERSION = packageJson.version; // (SHARED_TOOL_SPECS + INLINE_MCP_INVENTORY), so it can no longer drift out of // sync with the registered tools. Re-exported here (its old home) so existing // importers are unaffected; the composition lives in server-instructions.ts. -// The drawio_shapes / drawio_guide tools (#424) stay in SHARED_TOOL_SPECS (so the +// The drawioShapes / drawioGuide tools (#424) stay in SHARED_TOOL_SPECS (so the // generated picks them up from their catalogLine automatically) // but are flagged `inlineBothHosts` and registered inline below (their pure // helpers can't cross into tool-specs.ts); only the hand-written routing prose in @@ -286,7 +286,7 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer { // the wrapping is typed loosely and cast — runtime behaviour is unchanged. const registerSharedFromSpec = (spec: SharedToolSpec) => { if (spec.inAppOnly) return; - // `inlineBothHosts` specs (drawio_shapes / drawio_guide) carry no execute — + // `inlineBothHosts` specs (drawioShapes / drawioGuide) carry no execute — // their pure helper cannot cross into the zod-agnostic tool-specs.ts, so they // are registered INLINE below (searchShapes / getGuideSection). Skip them here // so the loop never dereferences a missing `execute`. @@ -316,7 +316,7 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer { } // --- INLINE drawio helper tools (IN the shared registry, but inlineBothHosts) --- - // drawio_shapes / drawio_guide (#424) live in SHARED_TOOL_SPECS (so the shared + // drawioShapes / drawioGuide (#424) live in SHARED_TOOL_SPECS (so the shared // contract pins their name/description/schema across both hosts) but carry the // `inlineBothHosts` flag and NO execute: their pure backing helpers // (searchShapes / getGuideSection) cannot be value-imported into the @@ -356,25 +356,25 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer { // --- INLINE tools kept per-transport (NOT in the shared registry) --- // Each stays inline for a documented reason: a snake_case/camelCase naming - // clash the registry convention forbids (table_get), an intentional - // per-transport behaviour/schema divergence (search, docmost_transform), or a - // tool that exists ONLY on this standalone MCP surface (update_comment, - // delete_comment — the in-app agent deliberately exposes no hard comment + // clash the registry convention forbids (tableGet), an intentional + // per-transport behaviour/schema divergence (search, docmostTransform), or a + // tool that exists ONLY on this standalone MCP surface (updateComment, + // deleteComment — the in-app agent deliberately exposes no hard comment // edit/delete tool). - // Tool: table_get -// NOT in the shared registry: the MCP tool name `table_get` is noun-first while + // Tool: tableGet +// NOT in the shared registry: the MCP tool name `tableGet` is noun-first while // the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey) // convention the shared registry enforces (shared-tool-specs.contract.spec.ts). // Renaming the public MCP tool would break external clients, so it stays inline. server.registerTool( - "table_get", + "tableGet", { description: "Read a table as a matrix. Returns {rows, cols, cells (text[][]), " + "cellIds (paragraph id per cell, or null)}. `table` = `#` from " + - "get_outline, or any block id inside the table. Use cellIds with " + - "patch_node for rich-formatted cell edits. `cols` is the FIRST row's " + + "getOutline, or any block id inside the table. Use cellIds with " + + "patchNode for rich-formatted cell edits. `cols` is the FIRST row's " + "width; ragged tables may vary per row, so use the per-row length of " + "`cells` for each row.", inputSchema: { @@ -388,9 +388,9 @@ server.registerTool( }, ); -// Tool: update_comment +// Tool: updateComment server.registerTool( - "update_comment", + "updateComment", { description: "Update an existing comment's content. Only the comment creator can " + @@ -409,9 +409,9 @@ server.registerTool( }, ); -// Tool: delete_comment +// Tool: deleteComment server.registerTool( - "delete_comment", + "deleteComment", { description: "Delete a comment. Only the comment creator or space admin can delete it.", @@ -463,13 +463,13 @@ server.registerTool( }, ); -// Tool: docmost_transform +// Tool: docmostTransform // INTENTIONAL per-transport divergence (not shared): the in-app `transformPage` // deliberately omits the `deleteComments` schema field (comment-deletion // guardrail) and carries a much shorter description; this transport exposes the // full helper catalogue. Different schema, so kept per-layer. server.registerTool( - "docmost_transform", + "docmostTransform", { description: "Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " + diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index 559d7519..c7a77c5d 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -87,8 +87,8 @@ global.WebSocket = WebSocket; * bodies merged. So the import output is ALREADY in canonical footnote * topology. * - `canonicalizeFootnotes` runs AFTER as the mcp write-path invariant shared - * with every other full-document persist path (`update_page_json`, - * `docmost_transform`, `insert_footnote`, …). Because the package output is + * with every other full-document persist path (`updatePageJson`, + * `docmostTransform`, `insertFootnote`, …). Because the package output is * already canonical, this layer is a no-op here (idempotent) — it exists so * the page-write contract is enforced uniformly regardless of how the PM doc * was produced, not because the import needs fixing. @@ -282,7 +282,7 @@ export async function mutatePageContent( * it was produced from markdown (ids regenerate) or edited in place * (existing block ids preserved). * - * This is an intentional full replace (used by update_page / update_page_json), + * This is an intentional full replace (used by update_page / updatePageJson), * but now runs under the per-page lock and waits for server persistence via * mutatePageContent. */ diff --git a/packages/mcp/src/lib/comment-anchor.ts b/packages/mcp/src/lib/comment-anchor.ts index 4c16456d..b42cd780 100644 --- a/packages/mcp/src/lib/comment-anchor.ts +++ b/packages/mcp/src/lib/comment-anchor.ts @@ -20,7 +20,7 @@ * * MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries * inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not - * match the document's plain text. Exactly like edit_page_text's json-edit + * match the document's plain text. Exactly like editPageText's json-edit * fallback, we first try the verbatim selection and, ONLY if it anchors nowhere * in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`, * `getAnchoredText` and `applyAnchorInDoc` share this decision via diff --git a/packages/mcp/src/lib/diff.ts b/packages/mcp/src/lib/diff.ts index ba216df4..38457dc0 100644 --- a/packages/mcp/src/lib/diff.ts +++ b/packages/mcp/src/lib/diff.ts @@ -380,7 +380,7 @@ export interface VerifyReport { /** * ONLY structural integrity types whose count changed, as [before, after] * (images/links/tables/callouts). Surfaces structural mutations that touch - * neither text nor marks (e.g. insert_image, deleting a table) which diffDocs + * neither text nor marks (e.g. insertImage, deleting a table) which diffDocs * — being TEXT-only — would otherwise report as "no content change". */ structure?: Record; @@ -400,7 +400,7 @@ export interface VerifyReport { * * The structural integrity delta (from diffDocs's `integrity` tuples) is what * makes `changed` true for an image/table/callout/link count change that diffs - * to zero text — closing a verify blind spot for insert_image, delete_node on a + * to zero text — closing a verify blind spot for insertImage, deleteNode on a * table, etc. */ export function summarizeChange(before: any, after: any): VerifyReport { diff --git a/packages/mcp/src/lib/drawio-guide.ts b/packages/mcp/src/lib/drawio-guide.ts index cc0e5d2d..52340a83 100644 --- a/packages/mcp/src/lib/drawio-guide.ts +++ b/packages/mcp/src/lib/drawio-guide.ts @@ -1,4 +1,4 @@ -// Progressive-disclosure authoring reference for the `drawio_guide` tool +// Progressive-disclosure authoring reference for the `drawioGuide` tool // (issue #424, stage 2). The FULL draw.io authoring guide would bloat every // context window, so it is split into small sections the model reads on demand: // skeleton | layout | containers | icons-aws | icons-azure @@ -22,7 +22,7 @@ export const GUIDE_SECTIONS: GuideSection[] = [ "icons-azure", ]; -const SKELETON = `# drawio_guide: skeleton +const SKELETON = `# drawioGuide: skeleton Canonical mxGraph skeleton. id="0" and id="1" are MANDATORY sentinels; every real cell has parent="1" (or a container id). Set adaptiveColors="auto" on the @@ -50,7 +50,7 @@ model so Docmost's dark theme adapts strokeColor/fillColor/fontColor="default". \`\`\` -Three accepted inputs to drawio_create/drawio_update: a bare , a +Three accepted inputs to drawioCreate/drawioUpdate: a bare , a full (decoded to its first page), or a raw list of (the server wraps it and adds the id=0/id=1 sentinels). @@ -58,12 +58,12 @@ Hard rules: a cell is vertex="1" XOR edge="1" (a container/group is neither); every edge has a child ; ids are unique; no XML comments; put html=1 in styles and XML-escape value (& -> &, < -> <); a newline in a label is , never a literal \\n. Don't guess -shape=mxgraph.* names — call drawio_shapes first (a wrong name renders empty).`; +shape=mxgraph.* names — call drawioShapes first (a wrong name renders empty).`; -const LAYOUT = `# drawio_guide: layout +const LAYOUT = `# drawioGuide: layout Turn "make it look good" into checkable numbers. Or pass layout:"elk" to -drawio_create/drawio_update and the server computes coordinates for you (ELK +drawioCreate/drawioUpdate and the server computes coordinates for you (ELK layered layout, honouring nested containers) — you declare structure, it places pixels. @@ -96,7 +96,7 @@ The linter returns quality WARNINGS (bbox overlap, edge through a shape, edge-on-edge, gap <150px, label wider than its shape, negative/off-page coords). They do not block the write — fix them and retry, max 2 iterations.`; -const CONTAINERS = `# drawio_guide: containers +const CONTAINERS = `# drawioGuide: containers Groups/zones are TRANSPARENT containers. A coloured group fill is an instant "AI-generated" tell — never fill a group. @@ -133,10 +133,10 @@ Example (transparent zone with two children and an internal edge): \`\`\``; -const ICONS_AWS = `# drawio_guide: icons-aws +const ICONS_AWS = `# drawioGuide: icons-aws Two mutually-exclusive AWS icon patterns — mixing them is the #1 cause of empty -boxes. Always call drawio_shapes for the exact resIcon name; do not guess. +boxes. Always call drawioShapes for the exact resIcon name; do not guess. | Level | style | strokeColor | |---|---|---| @@ -168,7 +168,7 @@ Group stencils (transparent containers): AWS Cloud group_aws_cloud_alt, VPC group_vpc2, Subnet group_security_group, Account group_account; subnets use shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_public_subnet;.`; -const ICONS_AZURE = `# drawio_guide: icons-azure +const ICONS_AZURE = `# drawioGuide: icons-azure shape=mxgraph.azure2.* does NOT render in every host. Use the portable image-style instead: @@ -190,7 +190,7 @@ an absolute URL fallback for the image: https://raw.githubusercontent.com/jgraph/drawio/dev/src/main/webapp/img/lib/azure2//.svg \`\`\` -Call drawio_shapes with the service name (e.g. "cosmos", "api management", +Call drawioShapes with the service name (e.g. "cosmos", "api management", "front door") to get the exact image-style string and default 68x68 size.`; const CONTENT: Record = { @@ -216,13 +216,13 @@ export function getGuideSection(section?: string): { return { section: key, content: CONTENT[key], sections: GUIDE_SECTIONS }; } const index = - "# drawio_guide\n\nProgressive-disclosure draw.io authoring reference. " + - "Call drawio_guide(section) with one of:\n" + + "# drawioGuide\n\nProgressive-disclosure draw.io authoring reference. " + + "Call drawioGuide(section) with one of:\n" + "- skeleton — canonical mxGraph XML, sentinels, the three accepted inputs, hard rules\n" + "- layout — spacing heuristics, edge routing, the layout:\"elk\" option, quality warnings\n" + "- containers — transparent groups, relative child coords, cross-container edges, swimlanes\n" + "- icons-aws — the service/resource icon patterns, category colors, rebrandings, blocklist\n" + "- icons-azure — the portable image-style paths\n\n" + - "Also call drawio_shapes(query) for verified stencil style-strings."; + "Also call drawioShapes(query) for verified stencil style-strings."; return { section: "index", content: index, sections: GUIDE_SECTIONS }; } diff --git a/packages/mcp/src/lib/drawio-layout.ts b/packages/mcp/src/lib/drawio-layout.ts index e3ff71e4..7adb166f 100644 --- a/packages/mcp/src/lib/drawio-layout.ts +++ b/packages/mcp/src/lib/drawio-layout.ts @@ -19,7 +19,7 @@ const DEFAULT_W = 140; const DEFAULT_H = 60; // DoS bounds for the in-process ELK layout. The mxGraph XML is LLM-supplied -// (layout:"elk" in drawio_create/drawio_update) and elkjs runs synchronously on +// (layout:"elk" in drawioCreate/drawioUpdate) and elkjs runs synchronously on // the MCP server's event loop, so an unbounded graph would block it for // seconds-to-minutes. A ~1MB XML (well under the stage-1 16MB cap) can carry // thousands of nodes. We cap the graph size and race the layout against a @@ -81,7 +81,7 @@ interface ElkGraph extends ElkNode { /** * Apply an ELK layered layout to a drawio input and return a full mxGraphModel * string with rewritten geometry. Accepts the same three input forms as - * drawio_create (a bare model, an , or a list). Async because + * drawioCreate (a bare model, an , or a list). Async because * elkjs' layout() is promise-based. On any layout failure the ORIGINAL * (normalized) model is returned unchanged — layout is best-effort polish, never * a reason to fail the write. diff --git a/packages/mcp/src/lib/drawio-shapes.ts b/packages/mcp/src/lib/drawio-shapes.ts index 7ca6e9a4..2d71afdf 100644 --- a/packages/mcp/src/lib/drawio-shapes.ts +++ b/packages/mcp/src/lib/drawio-shapes.ts @@ -1,4 +1,4 @@ -// Verified draw.io shape catalog for the `drawio_shapes` tool (issue #424, +// Verified draw.io shape catalog for the `drawioShapes` tool (issue #424, // stage 2). This is the fix for AI-generated diagrams' #1 defect: guessed // `shape=mxgraph.*` names that render as EMPTY BOXES because the stencil does // not exist. Instead of guessing, the model queries this catalog and gets back diff --git a/packages/mcp/src/lib/drawio-xml.ts b/packages/mcp/src/lib/drawio-xml.ts index feb9df50..84b2c5a3 100644 --- a/packages/mcp/src/lib/drawio-xml.ts +++ b/packages/mcp/src/lib/drawio-xml.ts @@ -1070,7 +1070,7 @@ export function prepareModel(inputXml: string): PreparedModel { }; } -/** Cell count of a decoded model (user cells only) — used by drawio_get meta. */ +/** Cell count of a decoded model (user cells only) — used by drawioGet meta. */ export function countUserCells(modelXml: string): number { return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length; } diff --git a/packages/mcp/src/lib/footnote-canonicalize.ts b/packages/mcp/src/lib/footnote-canonicalize.ts index a483134b..7ed0eec5 100644 --- a/packages/mcp/src/lib/footnote-canonicalize.ts +++ b/packages/mcp/src/lib/footnote-canonicalize.ts @@ -16,8 +16,8 @@ * `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the * importer's `assembleFootnotes`, #414), so this file stays a pure mirror. * - * Why it exists: every NON-editor write path (markdown import, update_page_json, - * docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the + * Why it exists: every NON-editor write path (markdown import, updatePageJson, + * docmostTransform, insertFootnote) builds ProseMirror JSON directly, so the * editor's footnote plugins never run and the canonical topology (sequential * numbering by first reference, one trailing list, no orphans, no raw `[^id]`) * was never enforced. Running this at the end of every write path closes that @@ -28,8 +28,8 @@ * `canonicalizeFootnotes(doc)` before writing — the current callers are * `markdownToProseMirrorCanonical` (page markdown import/update; the plain * `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a - * reference-less definition), `update_page_json`, `docmost_transform`, - * `insert_footnote`, and `copy_page_content`. Append/prepend FRAGMENT writes MUST + * reference-less definition), `updatePageJson`, `docmostTransform`, + * `insertFootnote`, and `copyPageContent`. Append/prepend FRAGMENT writes MUST * NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment * and comment-vs-page nuances make a single naive wrapper unsafe). */ diff --git a/packages/mcp/src/lib/json-edit.ts b/packages/mcp/src/lib/json-edit.ts index fde9b877..91350868 100644 --- a/packages/mcp/src/lib/json-edit.ts +++ b/packages/mcp/src/lib/json-edit.ts @@ -283,7 +283,7 @@ export function applyTextEdits( for (const edit of edits) { if (!edit.find) throw new Error("edit.find must be a non-empty string"); - // HARD-REFUSE formatting changes. edit_page_text edits PLAIN TEXT only and + // HARD-REFUSE formatting changes. editPageText edits PLAIN TEXT only and // writes the replacement verbatim, so it cannot add/remove marks. We refuse // only a pure formatting TOGGLE: find and replace differ ONLY by balanced // markdown markers (e.g. find:"~~$69~~" / replace:"$69", or find:"M5Stack" / @@ -304,22 +304,22 @@ export function applyTextEdits( failed.push({ find: edit.find, reason: - "edit_page_text edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with get_page_json and use patch_node (or update_page_json) to set the node's marks array.", + "editPageText edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with getPageJson and use patchNode (or updatePageJson) to set the node's marks array.", }); continue; } // HARD-REFUSE inline footnote tokens (#410). `^[...]` in a `replace` is // markdown that only becomes a real footnote when a whole markdown body is - // written (create_page / update_page_content / import_page_markdown). Written - // through edit_page_text it stays a LITERAL string in the text — the exact + // written (createPage / update_page_content / importPageMarkdown). Written + // through editPageText it stays a LITERAL string in the text — the exact // failure mode #410 fixes — so refuse it here (defense-in-depth) and point the - // caller at insert_footnote, mirroring the formatting-marker refusal above. + // caller at insertFootnote, mirroring the formatting-marker refusal above. if (/\^\[[\s\S]*?\]/.test(edit.replace)) { failed.push({ find: edit.find, reason: - "edit_page_text writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insert_footnote (anchorText = where, text = the note).", + "editPageText writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insertFootnote (anchorText = where, text = the note).", }); continue; } @@ -381,12 +381,12 @@ export function applyTextEdits( let reason: string; if (existsAcrossAtom) { reason = - "match crosses a non-text inline node (image/break/mention); use update_page_json for structural changes."; + "match crosses a non-text inline node (image/break/mention); use updatePageJson for structural changes."; } else { // Append a bounded "closest text" hint: find the FIRST block that // contains the longest whitespace-delimited token (>= 3 chars) of the // (stripped, then raw) locator, and quote that block's plain text. Shared - // with create_comment via closestBlockHint so both give the same hint. + // with createComment via closestBlockHint so both give the same hint. reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find); } failed.push({ find: edit.find, reason }); diff --git a/packages/mcp/src/lib/page-search.ts b/packages/mcp/src/lib/page-search.ts index f4c49ac3..db8faf90 100644 --- a/packages/mcp/src/lib/page-search.ts +++ b/packages/mcp/src/lib/page-search.ts @@ -3,7 +3,7 @@ * * `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring * (default) or a regular expression across the page's TEXT CONTAINERS and - * reports WHERE each match is — the container's ref (for get_node/patch_node; + * reports WHERE each match is — the container's ref (for getNode/patchNode; * see the SearchMatch.nodeId note for the `#` caveat), the top-level * block index, and a short context window around the hit. It never touches the * network, the DB, or the schema mirror; like `comment-anchor.ts` it is @@ -69,24 +69,24 @@ export interface SearchOptions { /** One located occurrence. */ export interface SearchMatch { /** - * The container's ref, for addressing the block with get_node/patch_node: its + * The container's ref, for addressing the block with getNode/patchNode: its * `attrs.id` when it has one, otherwise `#` of the nearest * top-level block. Table-cell/list-item paragraphs that carry no id fall back * to the `#` form. * - * CAVEAT: the `#` form is accepted by get_node (getNodeByRef resolves - * it by top-level index) but NOT by patch_node (replaceNodeById resolves only + * CAVEAT: the `#` form is accepted by getNode (getNodeByRef resolves + * it by top-level index) but NOT by patchNode (replaceNodeById resolves only * by `attrs.id`), so id-less table/cell content can be READ by this ref but * not PATCHED by it. * - * To anchor a comment, do NOT pass this ref to create_comment — it has no + * To anchor a comment, do NOT pass this ref to createComment — it has no * nodeId parameter. A top-level comment needs an exact-text `selection` that * occurs once on the page (it fails if the text isn't found), so build a - * UNIQUE `selection` from before+match+after and pass THAT as create_comment's + * UNIQUE `selection` from before+match+after and pass THAT as createComment's * `selection`. */ nodeId: string; - /** The top-level block index (as in get_outline). */ + /** The top-level block index (as in getOutline). */ blockIndex: number; /** The container node's type (paragraph/heading/...). */ type: string | undefined; @@ -188,12 +188,12 @@ export function searchInDoc( // --- edge-case guards (fail loudly so the agent can correct the call) --- if (typeof query !== "string" || query.trim().length === 0) { throw new Error( - "search_in_page: query is empty — pass the text (or regex) to look for.", + "searchInPage: query is empty — pass the text (or regex) to look for.", ); } if (query.length > MAX_PATTERN_LENGTH) { throw new Error( - `search_in_page: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`, + `searchInPage: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`, ); } @@ -212,7 +212,7 @@ export function searchInDoc( re = new RE2(query, caseSensitive ? "g" : "gi"); } catch (e) { throw new Error( - `search_in_page: invalid or unsupported regular expression: ${ + `searchInPage: invalid or unsupported regular expression: ${ e instanceof Error ? e.message : String(e) } — RE2 does not support lookaround ((?=…)/(?<=…)) or backreferences (\\1); rewrite the pattern without them.`, ); @@ -237,9 +237,9 @@ export function searchInDoc( // in a very long container. const text = blockPlainText(node); - // The container's own id addresses it verbatim in get_node/patch_node; a + // The container's own id addresses it verbatim in getNode/patchNode; a // container with no id (e.g. a table-cell paragraph) falls back to the - // top-level block's # (readable via get_node, but not patchable — + // top-level block's # (readable via getNode, but not patchable — // see the SearchMatch.nodeId note). const id = isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0 diff --git a/packages/mcp/src/lib/text-normalize.ts b/packages/mcp/src/lib/text-normalize.ts index 997cbff0..e80bcfdb 100644 --- a/packages/mcp/src/lib/text-normalize.ts +++ b/packages/mcp/src/lib/text-normalize.ts @@ -117,7 +117,7 @@ export function stripInlineMarkdown(s: string): string { /** * Build a bounded "closest text" hint for an anchor/find MISS, shared by - * edit_page_text (json-edit) and create_comment (client) so both surface the + * editPageText (json-edit) and createComment (client) so both surface the * same self-correction affordance. * * Take the longest whitespace-delimited token (>= 3 chars) of the locator diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index 37b4b118..c65e30e1 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -740,7 +740,7 @@ export function insertInlineFootnote( // subtree, so a reference is never glued inside an existing definition (which // the canonicalizer would then drop as an orphan, losing that definition's // prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a - // schema-invalid doc; insert_footnote skips validateDocStructure). + // schema-invalid doc; insertFootnote skips validateDocStructure). // When the only anchor match is in such a place, the insert is refused and the // write aborts cleanly (inserted:false) instead of destroying content. const boundaryIdx = Array.isArray(doc?.content) diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index b8fde0f0..03dc170b 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -40,11 +40,11 @@ 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); before authoring a diagram, drawio_shapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawio_guide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawio_create/drawio_update to auto-place nodes. 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). Export a page to self-contained Docmost Markdown (with comment anchors) -> export_page_markdown."; + "READ: find a page -> search (workspace-wide full-text); list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block's subtree -> getNode (by attrs.id, or \"#\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, lossy; inline tags are comment anchors — markup, not text) or getPageJson (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + + "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patchNode (by attrs.id from getOutline). Add a block -> insertNode (before/after a block by attrs.id or by anchor text, or append). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (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) -> docmostTransform: 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 -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (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) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + + "COMMENTS: createComment 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 -> createComment 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 -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" + + "HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown."; /** * A single generated inventory line: the tool's registered NAME + a one-line @@ -81,57 +81,57 @@ type Family = (typeof FAMILY_ORDER)[number]; const TOOL_FAMILY: Record = { // READ search: "READ", - list_pages: "READ", - list_spaces: "READ", - get_outline: "READ", - get_node: "READ", - search_in_page: "READ", - get_page: "READ", - get_page_json: "READ", - get_workspace: "READ", - stash_page: "READ", + listPages: "READ", + listSpaces: "READ", + getOutline: "READ", + getNode: "READ", + searchInPage: "READ", + getPage: "READ", + getPageJson: "READ", + getWorkspace: "READ", + stashPage: "READ", // EDIT - edit_page_text: "EDIT", - patch_node: "EDIT", - 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", - table_delete_row: "EDIT", - insert_image: "EDIT", - replace_image: "EDIT", - insert_footnote: "EDIT", - drawio_get: "EDIT", - drawio_create: "EDIT", - drawio_update: "EDIT", - drawio_shapes: "EDIT", - drawio_guide: "EDIT", - docmost_transform: "EDIT", + editPageText: "EDIT", + patchNode: "EDIT", + insertNode: "EDIT", + deleteNode: "EDIT", + updatePageJson: "EDIT", + updatePageMarkdown: "EDIT", + tableGet: "EDIT", + tableUpdateCell: "EDIT", + tableInsertRow: "EDIT", + tableDeleteRow: "EDIT", + insertImage: "EDIT", + replaceImage: "EDIT", + insertFootnote: "EDIT", + drawioGet: "EDIT", + drawioCreate: "EDIT", + drawioUpdate: "EDIT", + drawioShapes: "EDIT", + drawioGuide: "EDIT", + docmostTransform: "EDIT", // PAGES - create_page: "PAGES", - rename_page: "PAGES", - move_page: "PAGES", - delete_page: "PAGES", - copy_page_content: "PAGES", - share_page: "PAGES", - unshare_page: "PAGES", - list_shares: "PAGES", + createPage: "PAGES", + renamePage: "PAGES", + movePage: "PAGES", + deletePage: "PAGES", + copyPageContent: "PAGES", + sharePage: "PAGES", + unsharePage: "PAGES", + listShares: "PAGES", // COMMENTS - create_comment: "COMMENTS", - list_comments: "COMMENTS", - update_comment: "COMMENTS", - resolve_comment: "COMMENTS", - delete_comment: "COMMENTS", - check_new_comments: "COMMENTS", + createComment: "COMMENTS", + listComments: "COMMENTS", + updateComment: "COMMENTS", + resolveComment: "COMMENTS", + deleteComment: "COMMENTS", + checkNewComments: "COMMENTS", // HISTORY - diff_page_versions: "HISTORY", - list_page_history: "HISTORY", - restore_page_version: "HISTORY", - export_page_markdown: "HISTORY", - // import_page_markdown is now inAppOnly (#411) — it is not registered on the + diffPageVersions: "HISTORY", + listPageHistory: "HISTORY", + restorePageVersion: "HISTORY", + exportPageMarkdown: "HISTORY", + // importPageMarkdown is now inAppOnly (#411) — it is not registered on the // external MCP host, so it no longer appears in the generated inventory. }; @@ -145,7 +145,7 @@ const TOOL_FAMILY: Record = { */ export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [ { - name: "table_get", + name: "tableGet", purpose: "read a table as a matrix of cell texts + per-cell paragraph ids.", }, @@ -155,16 +155,16 @@ export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [ "full-text search for pages and content across the whole workspace.", }, { - name: "docmost_transform", + name: "docmostTransform", purpose: "edit a page by running a sandboxed JS `(doc, ctx) => doc` transform, with a dryRun diff preview.", }, { - name: "update_comment", + name: "updateComment", purpose: "update an existing comment's content (creator only).", }, { - name: "delete_comment", + name: "deleteComment", purpose: "delete a comment (creator or space admin only).", }, ]; diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index 7cbcbe76..874b4619 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -8,7 +8,7 @@ // z.array() and z.object() — API identical across v3 and v4 — so a single // builder works with either namespace. // -// Only tools whose snake_case/camelCase name, input schema AND model-facing +// Only tools whose camelCase name, input schema AND model-facing // description are genuinely identical across both layers live here. Tools that // diverge on purpose (security guardrails, tuned UX, "Reversible" framing on // some write tools, different limits, hybrid-RRF search, etc.) stay defined @@ -29,8 +29,8 @@ // one of them. Each builder uses only the common, stable subset of the API. type ZodLike = any; -// The `node` normalizer shared by BOTH hosts (patch_node / insert_node / -// update_page_json): the model sometimes serializes a ProseMirror node arg as a +// The `node` normalizer shared by BOTH hosts (patchNode / insertNode / +// updatePageJson): the model sometimes serializes a ProseMirror node arg as a // JSON string, so we parse a string to an object (throwing a documented message // on invalid JSON) and pass an object through. It lives in the converter package // (#414) so it is the ONE copy both the MCP server and the in-app server import; @@ -117,7 +117,8 @@ export type SharedToolExecute = ( ) => Promise; export interface SharedToolSpec { - /** snake_case tool name passed to McpServer.registerTool. */ + /** camelCase tool name passed to McpServer.registerTool. Since issue #412 the + * external MCP name equals the in-app key (mcpName === inAppKey). */ mcpName: string; /** camelCase key in the ai-SDK tools object (the in-app layer). */ inAppKey: string; @@ -178,7 +179,7 @@ export interface SharedToolSpec { * description / schema across both hosts) but carries NO `execute`/override and * is registered INLINE by BOTH hosts instead of through the registry loop. Used * for tools whose implementation cannot cross into this zod-agnostic file — the - * drawio_shapes / drawio_guide pure helpers, whose backing module resolves a + * drawioShapes / drawioGuide pure helpers, whose backing module resolves a * bundled data file via `import.meta` and so cannot be value-imported here * without breaking the in-app server's commonjs type-check of this source. Both * registry loops SKIP a spec with this flag; the per-host inline registrations @@ -206,10 +207,10 @@ const mcpJson = (data: unknown) => ({ }); /** - * Compact HARD-RULES block injected into the drawio_create / drawio_update + * Compact HARD-RULES block injected into the drawioCreate / drawioUpdate * descriptions (issue #424 — the jgraph/drawio-mcp pattern of putting the * must-follow rules right where the model reads them at call time). Deliberately - * terse; the long-form authoring guidance lives in drawio_guide. + * terse; the long-form authoring guidance lives in drawioGuide. */ export const DRAWIO_HARD_RULES = ' RULES: id="0" and id="1"(parent="0") sentinels are MANDATORY; each cell is ' + @@ -221,8 +222,8 @@ export const DRAWIO_HARD_RULES = 'and RELATIVE coords, and an edge between different containers is parent="1"; set ' + 'adaptiveColors="auto" on (free dark-theme adaptation for ' + 'strokeColor/fillColor/fontColor="default"); do NOT guess shape=mxgraph.* names ' + - "(a wrong name renders as an empty box) — call drawio_shapes first; call " + - "drawio_guide(section) for authoring help. Pass layout:\"elk\" to let the server " + + "(a wrong name renders as an empty box) — call drawioShapes first; call " + + "drawioGuide(section) for authoring help. Pass layout:\"elk\" to let the server " + "compute coordinates from your rough placement. The result carries geometry " + "WARNINGS (overlaps, an edge through a shape, edge-on-edge, gaps <150px, a label " + "wider than its shape, negative coords) — they do NOT block the write; fix them " + @@ -232,7 +233,7 @@ export const SHARED_TOOL_SPECS = { // --- no-argument read tools --- getWorkspace: { - mcpName: 'get_workspace', + mcpName: 'getWorkspace', inAppKey: 'getWorkspace', description: 'Fetch metadata about the current workspace (name, settings).', tier: 'core', @@ -241,7 +242,7 @@ export const SHARED_TOOL_SPECS = { }, listSpaces: { - mcpName: 'list_spaces', + mcpName: 'listSpaces', inAppKey: 'listSpaces', description: 'List the spaces the current user can access. Returns the array of ' + @@ -252,7 +253,7 @@ export const SHARED_TOOL_SPECS = { }, listShares: { - mcpName: 'list_shares', + mcpName: 'listShares', inAppKey: 'listShares', description: 'List all public shares in the workspace with page titles and public URLs.', @@ -264,7 +265,7 @@ export const SHARED_TOOL_SPECS = { // --- single-pageId read tools --- getPageJson: { - mcpName: 'get_page_json', + mcpName: 'getPageJson', inAppKey: 'getPageJson', description: 'Get page details with the raw ProseMirror JSON content (lossless: ' + @@ -281,7 +282,7 @@ export const SHARED_TOOL_SPECS = { }, getOutline: { - mcpName: 'get_outline', + mcpName: 'getOutline', inAppKey: 'getOutline', description: "Return a COMPACT outline of a page's top-level blocks ({index, type, " + @@ -301,7 +302,7 @@ export const SHARED_TOOL_SPECS = { // --- two-id read tool --- getNode: { - mcpName: 'get_node', + mcpName: 'getNode', inAppKey: 'getNode', description: "Fetch a single node's full ProseMirror subtree (lossless) without " + @@ -323,21 +324,21 @@ export const SHARED_TOOL_SPECS = { // --- in-page occurrence search (client-side, over ProseMirror plain text) --- searchInPage: { - mcpName: 'search_in_page', + mcpName: 'searchInPage', inAppKey: 'searchInPage', description: 'Find every occurrence of a string (or regex) INSIDE one page and get ' + - 'WHERE each is — instead of pulling blocks one-by-one with get_node. ' + + 'WHERE each is — instead of pulling blocks one-by-one with getNode. ' + 'Searches the plain text of each text block/cell (marks glued, so a match ' + 'survives bold/italic/link splits; comment anchors do not interfere). ' + 'Returns { total, truncated, matches:[{ nodeId, blockIndex, type, before, ' + 'match, after }] }: `nodeId` is the block id (or "#" for ' + - 'table/cell content) — pass it to get_node/patch_node (the "#" ' + - 'form resolves with get_node but NOT patch_node, which only accepts a real ' + - 'block id). To anchor a comment, do NOT pass nodeId to create_comment (it ' + + 'table/cell content) — pass it to getNode/patchNode (the "#" ' + + 'form resolves with getNode but NOT patchNode, which only accepts a real ' + + 'block id). To anchor a comment, do NOT pass nodeId to createComment (it ' + 'has no nodeId param); build a UNIQUE text selection from before+match+' + - 'after and pass it as create_comment\'s `selection`. `blockIndex` is the ' + - 'get_outline index; `before`/`after` give ~40 chars of context to build ' + + 'after and pass it as createComment\'s `selection`. `blockIndex` is the ' + + 'getOutline index; `before`/`after` give ~40 chars of context to build ' + 'that unique selection. `total` counts all ' + 'hits and `truncated` is true when more than `limit` were found (nothing ' + 'is silently dropped). Default is a literal, case-INSENSITIVE substring; ' + @@ -386,7 +387,7 @@ export const SHARED_TOOL_SPECS = { // --- node delete --- deleteNode: { - mcpName: 'delete_node', + mcpName: 'deleteNode', inAppKey: 'deleteNode', description: 'Remove a single block by its attrs.id (from the page outline or ' + @@ -408,10 +409,10 @@ export const SHARED_TOOL_SPECS = { // AND the in-app copy's "keeps the same node id" + "Reversible via page // history" framing — nothing either side conveyed is dropped. Sibling tools are // named in transport-neutral prose ("the page-JSON view", "a full-document - // replace") to match the rest of the registry, since the two layers expose - // those siblings under different (snake_case vs camelCase) identifiers. + // replace") to match the rest of the registry, so a description reads the same + // on both layers. patchNode: { - mcpName: 'patch_node', + mcpName: 'patchNode', inAppKey: 'patchNode', description: 'Replace a single content block identified by its attrs.id with a new ' + @@ -457,7 +458,7 @@ export const SHARED_TOOL_SPECS = { }, insertNode: { - mcpName: 'insert_node', + mcpName: 'insertNode', inAppKey: 'insertNode', description: 'Insert a block before/after another block (by attrs.id or anchor text) ' + @@ -525,7 +526,7 @@ export const SHARED_TOOL_SPECS = { // "per-transport divergence" note on the old inline copies was stale), so // there was no real behavioral divergence to preserve — only wording drift. sharePage: { - mcpName: 'share_page', + mcpName: 'sharePage', inAppKey: 'sharePage', // CANONICAL: merges the MCP copy's URL-format + idempotency detail with the // in-app copy's reversibility note; keeps the security framing both had. @@ -554,7 +555,7 @@ export const SHARED_TOOL_SPECS = { }, unsharePage: { - mcpName: 'unshare_page', + mcpName: 'unsharePage', inAppKey: 'unsharePage', description: 'Remove the public share of a page (revokes the public URL).', tier: 'deferred', @@ -568,7 +569,7 @@ export const SHARED_TOOL_SPECS = { // --- version history --- diffPageVersions: { - mcpName: 'diff_page_versions', + mcpName: 'diffPageVersions', inAppKey: 'diffPageVersions', description: 'Diff two versions of a page and return a Docmost-equivalent change set ' + @@ -600,7 +601,7 @@ export const SHARED_TOOL_SPECS = { }, listPageHistory: { - mcpName: 'list_page_history', + mcpName: 'listPageHistory', inAppKey: 'listPageHistory', description: "List a page's saved versions (Docmost auto-snapshots on every save), " + @@ -621,7 +622,7 @@ export const SHARED_TOOL_SPECS = { }, restorePageVersion: { - mcpName: 'restore_page_version', + mcpName: 'restorePageVersion', inAppKey: 'restorePageVersion', description: 'Restore a page to a saved version: writes that version\'s content back ' + @@ -641,13 +642,13 @@ export const SHARED_TOOL_SPECS = { // --- markdown round-trip --- importPageMarkdown: { - mcpName: 'import_page_markdown', + mcpName: '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, + // importPageMarkdown — 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. + // authoring-markdown body replace on the MCP surface is updatePageMarkdown. inAppOnly: true, description: "Replace a page's content from a self-contained Docmost-flavoured " + @@ -670,7 +671,7 @@ export const SHARED_TOOL_SPECS = { // --- server-side content copy --- copyPageContent: { - mcpName: 'copy_page_content', + mcpName: 'copyPageContent', inAppKey: 'copyPageContent', description: "Replace targetPageId's content with a copy of sourcePageId's content, " + @@ -698,7 +699,7 @@ export const SHARED_TOOL_SPECS = { // stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry // fallback" is intentionally absent here. editPageText: { - mcpName: 'edit_page_text', + mcpName: 'editPageText', inAppKey: 'editPageText', description: "Surgical find/replace inside a page's text, preserving all block " + @@ -747,10 +748,10 @@ export const SHARED_TOOL_SPECS = { // --- hand a large page to an external consumer without bloating context --- stashPage: { - mcpName: 'stash_page', + mcpName: 'stashPage', inAppKey: 'stashPage', description: - 'Serialize a whole page (the full ProseMirror JSON, as get_page_json ' + + 'Serialize a whole page (the full ProseMirror JSON, as getPageJson ' + 'returns) into an ephemeral in-memory blob and return ONLY a short ' + 'anonymous URL to it — the body NEVER enters the model context, so this ' + 'is the way to hand a large page (or its images) to an external consumer ' + @@ -808,7 +809,7 @@ export const SHARED_TOOL_SPECS = { // in-app layer deliberately allowed a looser value (documented per field). getPage: { - mcpName: 'get_page', + mcpName: 'getPage', inAppKey: 'getPage', description: 'Fetch a single page as Markdown by its id. Returns the page title and ' + @@ -841,7 +842,7 @@ export const SHARED_TOOL_SPECS = { }, listPages: { - mcpName: 'list_pages', + mcpName: 'listPages', inAppKey: 'listPages', description: 'List the most recent pages (ordered by updatedAt, descending), ' + @@ -884,7 +885,7 @@ export const SHARED_TOOL_SPECS = { }, createPage: { - mcpName: 'create_page', + mcpName: 'createPage', inAppKey: 'createPage', description: 'Create a new page with a Markdown body in a space, optionally under a ' + @@ -896,7 +897,7 @@ export const SHARED_TOOL_SPECS = { // Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while // the in-app copy left it unbounded and DOCUMENTS an empty body as valid // ("may be empty") — creating an empty page to fill in later is a real use - // case. The looser (no-min) form is kept, so create_page now also accepts an + // case. The looser (no-min) form is kept, so createPage now also accepts an // empty body (harmless — it creates an empty page) and no previously-valid // in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1) // (an empty title or space is never valid). @@ -934,7 +935,7 @@ export const SHARED_TOOL_SPECS = { }, movePage: { - mcpName: 'move_page', + mcpName: 'movePage', inAppKey: 'movePage', description: 'Move a page under a new parent page, or to the space root when no ' + @@ -1027,7 +1028,7 @@ export const SHARED_TOOL_SPECS = { }, renamePage: { - mcpName: 'rename_page', + mcpName: 'renamePage', inAppKey: 'renamePage', description: 'Rename a page (change its title only; the body is untouched, never ' + @@ -1048,7 +1049,7 @@ export const SHARED_TOOL_SPECS = { }, deletePage: { - mcpName: 'delete_page', + mcpName: 'deletePage', inAppKey: 'deletePage', description: 'Move a page to the trash — SOFT delete only: the page can be restored ' + @@ -1081,7 +1082,7 @@ export const SHARED_TOOL_SPECS = { }, updatePageJson: { - mcpName: 'update_page_json', + mcpName: 'updatePageJson', inAppKey: 'updatePageJson', description: "Replace a page's content with a raw ProseMirror JSON document (lossless " + @@ -1137,8 +1138,7 @@ export const SHARED_TOOL_SPECS = { // 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', + mcpName: 'updatePageMarkdown', inAppKey: 'updatePageMarkdown', description: "Replace a page's body with new Markdown content (and optionally its " + @@ -1172,7 +1172,7 @@ export const SHARED_TOOL_SPECS = { }, exportPageMarkdown: { - mcpName: 'export_page_markdown', + mcpName: 'exportPageMarkdown', inAppKey: 'exportPageMarkdown', // CANONICAL: the MCP copy (a strict superset of the terse in-app wording). description: @@ -1204,19 +1204,19 @@ export const SHARED_TOOL_SPECS = { // --- comment tools (unified from the per-layer inline definitions, #294) --- // - // create_comment and resolve_comment previously carried a "per-transport + // createComment and resolveComment previously carried a "per-transport // divergence" note in BOTH layers; #294 unifies their schema + description // here. Only the four tools that genuinely exist in BOTH layers live in the - // registry: create/list/resolve comment and check_new_comments. + // registry: create/list/resolve comment and checkNewComments. // - // update_comment and delete_comment are intentionally NOT here: they exist + // updateComment and deleteComment are intentionally NOT here: they exist // ONLY on the standalone MCP server. The in-app agent deliberately exposes no // hard comment edit/delete tool (comment edits are irreversible / not // version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts), // so there is nothing to unify — they stay inline in index.ts. createComment: { - mcpName: 'create_comment', + mcpName: 'createComment', inAppKey: 'createComment', // CANONICAL: the in-app copy (the more-maintained one). It keeps the same // rules as the MCP copy — inline-only, top-level requires a `selection`, no @@ -1231,7 +1231,7 @@ export const SHARED_TOOL_SPECS = { '(which gets highlighted); page-level comments are NOT supported. A ' + 'new top-level comment REQUIRES a `selection`. Replies inherit the ' + "parent's anchor and take no selection. Always COPY the `selection` " + - 'VERBATIM from get_page / search_in_page output — do NOT quote it from ' + + 'VERBATIM from getPage / searchInPage output — do NOT quote it from ' + 'memory (stale-memory quoting is the top cause of anchor misses). If the ' + 'call fails with a "selection not found" error, the error quotes the ' + "closest block text (or says the selection spans multiple blocks); retry " + @@ -1284,25 +1284,25 @@ export const SHARED_TOOL_SPECS = { }), // Both hosts enforce the SAME guardrails (a top-level comment requires a // selection; suggestedText is forbidden on a reply / without a selection) but - // with per-layer error wording (snake_case 'create_comment:' on the MCP - // surface, camelCase 'createComment' in-app) and different result shapes (MCP - // jsonContent, in-app projects `{ commentId, pageId }`). Preserved byte-for- - // byte via the two overrides. + // with per-layer error wording (both now prefixed 'createComment', the MCP + // and in-app messages differing only in their trailing guidance) and different + // result shapes (MCP jsonContent, in-app projects `{ commentId, pageId }`). + // Preserved byte-for-byte via the two overrides. mcpExecute: async (client, { pageId, content, selection, parentCommentId, suggestedText }) => { if (!parentCommentId && (!selection || !(selection as string).trim())) { throw new Error( - "create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.", + "createComment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.", ); } if (suggestedText !== undefined) { if (parentCommentId) { throw new Error( - "create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.", + "createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.", ); } if (!selection || !(selection as string).trim()) { throw new Error( - "create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.", + "createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.", ); } } @@ -1348,7 +1348,7 @@ export const SHARED_TOOL_SPECS = { }, listComments: { - mcpName: 'list_comments', + mcpName: 'listComments', inAppKey: 'listComments', // CANONICAL: the two copies are near-identical; the MCP copy is the // superset (it keeps the "(pagination is handled internally)" note the @@ -1375,10 +1375,10 @@ export const SHARED_TOOL_SPECS = { }, resolveComment: { - mcpName: 'resolve_comment', + mcpName: 'resolveComment', inAppKey: 'resolveComment', - // CANONICAL: the MCP copy's richer wording, minus its snake_case reference - // to `delete_comment` (a sibling tool that does NOT exist in the in-app + // CANONICAL: the MCP copy's richer wording, minus its reference + // to `deleteComment` (a sibling tool that does NOT exist in the in-app // layer) — rephrased transport-neutrally per the registry convention. description: 'Resolve (close) or reopen a top-level comment thread (reversible — ' + @@ -1415,7 +1415,7 @@ export const SHARED_TOOL_SPECS = { }, checkNewComments: { - mcpName: 'check_new_comments', + mcpName: 'checkNewComments', inAppKey: 'checkNewComments', // CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's // execute-side guard that rejects an unparseable `since` timestamp stays in @@ -1486,15 +1486,15 @@ export const SHARED_TOOL_SPECS = { // behavior) plus the in-app copy's "Reversible via page history" note; sibling // tool references are phrased transport-neutrally. // - // NOT here (kept inline in index.ts): table_get / getTable. Its MCP tool name - // is noun-first (`table_get`) while the in-app key is verb-first (`getTable`), - // so it breaks the snake_case(inAppKey) naming convention the registry enforces - // (shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would - // break external clients, so it stays per-transport (its in-app param was still - // aligned to `table` for consistency with the migrated trio below). + // NOT here (kept inline in index.ts): tableGet / getTable. Its MCP tool name + // is noun-first (`tableGet`) while the in-app key is verb-first (`getTable`), + // so it breaks the mcpName === inAppKey naming convention the registry enforces + // (shared-tool-specs.contract.spec.ts). Renaming either public name would break + // external clients or the in-app tool key, so it stays per-transport (its in-app + // param was still aligned to `table` for consistency with the migrated trio below). tableInsertRow: { - mcpName: 'table_insert_row', + mcpName: 'tableInsertRow', inAppKey: 'tableInsertRow', description: 'Insert a row of plain-text cells into a table. `table` is `#` ' + @@ -1527,7 +1527,7 @@ export const SHARED_TOOL_SPECS = { }, tableDeleteRow: { - mcpName: 'table_delete_row', + mcpName: 'tableDeleteRow', inAppKey: 'tableDeleteRow', description: 'Delete the row at 0-based `index` from a table (`table` is `#` ' + @@ -1550,7 +1550,7 @@ export const SHARED_TOOL_SPECS = { }, tableUpdateCell: { - mcpName: 'table_update_cell', + mcpName: 'tableUpdateCell', inAppKey: 'tableUpdateCell', description: 'Set the plain-text content of cell [row, col] (0-based) in a table ' + @@ -1590,7 +1590,7 @@ export const SHARED_TOOL_SPECS = { // external MCP clients see identical tool names, fields and text. insertFootnote: { - mcpName: 'insert_footnote', + mcpName: 'insertFootnote', inAppKey: 'insertFootnote', description: 'Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) ' + @@ -1627,7 +1627,7 @@ export const SHARED_TOOL_SPECS = { }, insertImage: { - mcpName: 'insert_image', + mcpName: 'insertImage', inAppKey: 'insertImage', description: 'Download an image from a web (http/https) URL and insert it into ' + @@ -1671,7 +1671,7 @@ export const SHARED_TOOL_SPECS = { }, replaceImage: { - mcpName: 'replace_image', + mcpName: 'replaceImage', inAppKey: 'replaceImage', description: 'Replace an existing image on a page with a new image fetched from a web ' + @@ -1709,15 +1709,15 @@ export const SHARED_TOOL_SPECS = { // --- draw.io diagrams (issue #423 stage 1, #424 stage 2) --- drawioGet: { - mcpName: 'drawio_get', + mcpName: 'drawioGet', inAppKey: 'drawioGet', description: 'Read a draw.io diagram on a page as mxGraph XML (default) or as its raw ' + - '`.drawio.svg`. `node` is the drawio node\'s attrs.id (from get_outline / ' + - 'get_page_json) or "#" for a top-level block. Returns the decoded ' + + '`.drawio.svg`. `node` is the drawio node\'s attrs.id (from getOutline / ' + + 'getPageJson) or "#" for a top-level block. Returns the decoded ' + 'mxGraphModel XML plus meta { attachmentId, title, width, height, ' + 'cellCount, hash }. `hash` is the optimistic-lock key you MUST pass back ' + - 'as baseHash to drawio_update. Diagrams a human saved from the editor ' + + 'as baseHash to drawioUpdate. Diagrams a human saved from the editor ' + '(including draw.io\'s compressed format) decode losslessly.', tier: 'deferred', catalogLine: @@ -1742,7 +1742,7 @@ export const SHARED_TOOL_SPECS = { }, drawioCreate: { - mcpName: 'drawio_create', + mcpName: 'drawioCreate', inAppKey: 'drawioCreate', description: 'Create a draw.io diagram from mxGraph XML and insert it as a diagram ' + @@ -1753,14 +1753,14 @@ export const SHARED_TOOL_SPECS = { 'source/target and every parent resolve, style parses, no XML comments, ' + 'value escaping) — a violation returns a structured error naming the rule ' + 'and cellId so you can fix and retry. `where` positions the block like ' + - 'insert_node: position before/after (with exactly one of anchorNodeId or ' + + 'insertNode: position before/after (with exactly one of anchorNodeId or ' + 'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' + 'returned `nodeId` is an index-based "#" handle (drawio nodes carry ' + 'no attrs.id): it addresses the new top-level block and can be fed straight ' + - 'back into drawio_get / drawio_update for THIS document. It is positional, ' + - 'so if you add or remove blocks before it, re-resolve via get_outline. The ' + + 'back into drawioGet / drawioUpdate for THIS document. It is positional, ' + + 'so if you add or remove blocks before it, re-resolve via getOutline. The ' + 'diagram is editable in the draw.io editor and can be re-read with ' + - 'drawio_get.' + + 'drawioGet.' + DRAWIO_HARD_RULES, tier: 'deferred', catalogLine: @@ -1812,14 +1812,14 @@ export const SHARED_TOOL_SPECS = { }, drawioUpdate: { - mcpName: 'drawio_update', + mcpName: 'drawioUpdate', inAppKey: 'drawioUpdate', description: 'Replace a draw.io diagram\'s content with new mxGraph XML (same lint ' + - 'pipeline as drawio_create). `baseHash` is MANDATORY: pass the hash from ' + - 'the drawio_get you based the edit on. If the diagram changed since ' + + 'pipeline as drawioCreate). `baseHash` is MANDATORY: pass the hash from ' + + 'the drawioGet you based the edit on. If the diagram changed since ' + '(a human or another agent edited it) the hash mismatches and the update ' + - 'is refused with a conflict error — re-read with drawio_get and retry. On ' + + 'is refused with a conflict error — re-read with drawioGet and retry. On ' + 'success it overwrites the diagram attachment and updates the node ' + 'width/height. `node` is the drawio node attrs.id or "#".' + DRAWIO_HARD_RULES, @@ -1841,7 +1841,7 @@ export const SHARED_TOOL_SPECS = { baseHash: z .string() .min(1) - .describe('The meta.hash from the drawio_get this edit is based on.'), + .describe('The meta.hash from the drawioGet this edit is based on.'), layout: z .enum(['elk']) .optional() @@ -1863,7 +1863,7 @@ export const SHARED_TOOL_SPECS = { }, drawioShapes: { - mcpName: 'drawio_shapes', + mcpName: 'drawioShapes', inAppKey: 'drawioShapes', description: 'Look up VERIFIED draw.io stencil style-strings so you never guess a ' + @@ -1876,7 +1876,7 @@ export const SHARED_TOOL_SPECS = { 'stencils to working replacements (e.g. dynamodb_table -> dynamodb) with a ' + 'note. Each hit is { style, w, h, title, type, category?, note? } — copy ' + '`style` verbatim onto the cell and use w/h as the default size. Call this ' + - 'BEFORE drawio_create/drawio_update whenever you need a specific icon ' + + 'BEFORE drawioCreate/drawioUpdate whenever you need a specific icon ' + '(AWS/Azure/GCP/network/UML/flowchart).', tier: 'deferred', catalogLine: @@ -1895,7 +1895,7 @@ export const SHARED_TOOL_SPECS = { .optional() .describe('Max results (default 12, capped at 50).'), }), - // INLINE on both hosts (no `execute`): drawio_shapes calls the PURE helper + // INLINE on both hosts (no `execute`): drawioShapes calls the PURE helper // searchShapes, which is NOT a client method — it reads the bundled shape // catalog via `import.meta.url` (drawio-shapes.ts). tool-specs.ts is // type-checked FROM SOURCE by the in-app server under module:commonjs, where a @@ -1909,7 +1909,7 @@ export const SHARED_TOOL_SPECS = { }, drawioGuide: { - mcpName: 'drawio_guide', + mcpName: 'drawioGuide', inAppKey: 'drawioGuide', description: 'Progressive-disclosure draw.io authoring reference. Call with a `section` ' + @@ -1920,7 +1920,7 @@ export const SHARED_TOOL_SPECS = { 'child coords, cross-container edges, swimlanes), "icons-aws" (the ' + 'service/resource icon patterns, category colors, rebrandings, blocklist), ' + '"icons-azure" (portable image-style paths). Omit `section` to get the ' + - 'index of sections. Pair with drawio_shapes for exact stencil styles.', + 'index of sections. Pair with drawioShapes for exact stencil styles.', tier: 'deferred', catalogLine: 'drawioGuide — on-demand draw.io authoring reference (skeleton/layout/containers/icons).', @@ -1930,10 +1930,10 @@ export const SHARED_TOOL_SPECS = { .optional() .describe('Which section to read; omit for the section index.'), }), - // INLINE on both hosts (no `execute`) — same reason as drawio_shapes above: - // drawio_guide calls the PURE helper getGuideSection (drawio-guide.ts, no + // INLINE on both hosts (no `execute`) — same reason as drawioShapes above: + // drawioGuide calls the PURE helper getGuideSection (drawio-guide.ts, no // client, no network). getGuideSection itself has no `import.meta`, but it is - // kept inline for SYMMETRY with drawio_shapes (both drawio helper tools wired + // kept inline for SYMMETRY with drawioShapes (both drawio helper tools wired // the same way in one place) and to avoid pulling any drawio lib source into // the in-app server's commonjs type-check. `inlineBothHosts` makes both loops // skip it; index.ts and ai-chat-tools.service.ts register it directly. diff --git a/packages/mcp/test-e2e.mjs b/packages/mcp/test-e2e.mjs index 71cb8819..6de1d891 100644 --- a/packages/mcp/test-e2e.mjs +++ b/packages/mcp/test-e2e.mjs @@ -84,20 +84,20 @@ async function main() { let pageId = null; try { - // 1. create_page: title with spaces must survive (was: underscores bug) + // 1. createPage: title with spaces must survive (was: underscores bug) const created = await client.createPage("Тест апгрейда MCP сервера", MD, spaceId); pageId = created.data.id; - check("create_page: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title); - check("create_page: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId); + check("createPage: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title); + check("createPage: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId); - // 2. get_page_json: raw ProseMirror with callout + table + // 2. getPageJson: raw ProseMirror with callout + table const pj = await client.getPageJson(pageId); const types = pj.content.content.map((n) => n.type); - check("get_page_json: callout node present", types.includes("callout"), types.join(",")); - check("get_page_json: table node present", types.includes("table")); - check("get_page_json: slugId present", !!pj.slugId); + check("getPageJson: callout node present", types.includes("callout"), types.join(",")); + check("getPageJson: table node present", types.includes("table")); + check("getPageJson: slugId present", !!pj.slugId); - // 3. edit_page_text: surgical replace, ids preserved + // 3. editPageText: surgical replace, ids preserved const idsBefore = JSON.stringify( pj.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id), ); @@ -105,26 +105,26 @@ async function main() { { find: "БУКВОЕД", replace: "КНИГОЛЮБ" }, { find: "[1]", replace: "[42]" }, ]); - check("edit_page_text: both edits applied", editRes.applied.every((e) => e.replacements === 1)); + check("editPageText: both edits applied", editRes.applied.every((e) => e.replacements === 1)); await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence const pj2 = await client.getPageJson(pageId); const text2 = JSON.stringify(pj2.content); - check("edit_page_text: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]")); - check("edit_page_text: old text gone", !text2.includes("БУКВОЕД")); + check("editPageText: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]")); + check("editPageText: old text gone", !text2.includes("БУКВОЕД")); const idsAfter = JSON.stringify( pj2.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id), ); - check("edit_page_text: block ids preserved", idsBefore === idsAfter); - check("edit_page_text: callout survived", JSON.stringify(pj2.content).includes('"callout"')); - check("edit_page_text: table survived", pj2.content.content.some((n) => n.type === "table")); + check("editPageText: block ids preserved", idsBefore === idsAfter); + check("editPageText: callout survived", JSON.stringify(pj2.content).includes('"callout"')); + check("editPageText: table survived", pj2.content.content.some((n) => n.type === "table")); // 4. error reporting: ambiguous and missing finds let err1 = ""; try { await client.editPageText(pageId, [{ find: "Колонка", replace: "X" }]); } catch (e) { err1 = e.message; } - check("edit_page_text: ambiguous match rejected", err1.includes("matches"), err1); + check("editPageText: ambiguous match rejected", err1.includes("matches"), err1); let err2 = ""; try { await client.editPageText(pageId, [{ find: "НЕСУЩЕСТВУЮЩЕЕ", replace: "X" }]); } catch (e) { err2 = e.message; } - check("edit_page_text: missing text reported", err2.includes("not found"), err2); + check("editPageText: missing text reported", err2.includes("not found"), err2); // 5. update_page (markdown): table + callout must survive the re-import await client.updatePage(pageId, MD + "\nДобавленный абзац.\n"); @@ -137,21 +137,21 @@ async function main() { const cellText = JSON.stringify(tableNode); check("update_page md: table cells intact", cellText.includes("четыре") && cellText.includes("Колонка А")); - // 6. update_page_json: lossless write round-trip + // 6. updatePageJson: lossless write round-trip pj3.content.content.push({ type: "paragraph", attrs: { id: "testidjsonpush", indent: 0, textAlign: null }, - content: [{ type: "text", text: "Абзац, добавленный через update_page_json." }], + content: [{ type: "text", text: "Абзац, добавленный через updatePageJson." }], }); await client.updatePageJson(pageId, pj3.content); await new Promise((r) => setTimeout(r, 16000)); const pj4 = await client.getPageJson(pageId); const lastNode = pj4.content.content[pj4.content.content.length - 1]; - check("update_page_json: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через update_page_json")); - check("update_page_json: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id); + check("updatePageJson: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через updatePageJson")); + check("updatePageJson: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id); // 6b. images: upload / insert / replace (clean src, fresh attachment on replace). - // insert_image / replace_image take an http(s) URL that the SERVER fetches; + // insertImage / replaceImage take an http(s) URL that the SERVER fetches; // local file paths are intentionally unsupported. The Docmost server runs on // the same host as this test, so serve the PNG bytes over a throwaway // localhost HTTP server it can reach. @@ -186,13 +186,13 @@ async function main() { validateStatus: () => true, }); - // insert_image: append the first PNG, src must be clean (no ?v=) and fetchable. + // insertImage: append the first PNG, src must be clean (no ?v=) and fetchable. const ins = await client.insertImage(pageId, urlA); - check("insert_image: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src); + check("insertImage: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src); const fileA = await fetchFile(ins.src); - check("insert_image: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`); + check("insertImage: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`); check( - "insert_image: content-type is image/*", + "insertImage: content-type is image/*", String(fileA.headers["content-type"] || "").startsWith("image/"), String(fileA.headers["content-type"]), ); @@ -209,25 +209,25 @@ async function main() { }; const imgNode = findImage(pjImg.content.content); const oldAttachmentId = imgNode?.attrs?.attachmentId; - check("insert_image: image node present after persist", !!oldAttachmentId, oldAttachmentId); + check("insertImage: image node present after persist", !!oldAttachmentId, oldAttachmentId); - // replace_image: must create a NEW attachment with a clean, fetchable URL. + // replaceImage: must create a NEW attachment with a clean, fetchable URL. // The 200 fetch is the assertion that catches the in-place-overwrite HTTP 500 regression. const rep = await client.replaceImage(pageId, oldAttachmentId, urlB); - check("replace_image: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`); - check("replace_image: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src); + check("replaceImage: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`); + check("replaceImage: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src); const fileB = await fetchFile(rep.src); - check("replace_image: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`); + check("replaceImage: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`); check( - "replace_image: new content-type is image/*", + "replaceImage: new content-type is image/*", String(fileB.headers["content-type"] || "").startsWith("image/"), String(fileB.headers["content-type"]), ); await new Promise((r) => setTimeout(r, 16000)); const pjImg2 = await client.getPageJson(pageId); - check("replace_image: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId); - check("replace_image: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId); + check("replaceImage: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId); + check("replaceImage: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId); } finally { imgServer.close(); } @@ -275,10 +275,10 @@ async function main() { await client.editPageText(fid, [{ find: "PRICEMARK", replace: "$& costs $100" }]); await new Promise((r) => setTimeout(r, 16000)); const ftext = JSON.stringify((await client.getPageJson(fid)).content); - check("feature: edit_page_text inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs")); + check("feature: editPageText inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs")); let badThrew = false; try { await client.replaceImage(fid, "00000000-0000-0000-0000-000000000000", featPng); } catch (e) { badThrew = /no image with attachmentId/.test(e.message); } - check("feature: replace_image with unknown id throws (no orphan upload)", badThrew); + check("feature: replaceImage with unknown id throws (no orphan upload)", badThrew); } finally { try { await client.deletePage(fid); } catch {} try { unlinkSync(featPng); } catch {} @@ -286,7 +286,7 @@ async function main() { } // 6d. node ops: patch / insert / delete a block by id on a throwaway page. - // Three paragraphs are written with KNOWN ids via update_page_json so the + // Three paragraphs are written with KNOWN ids via updatePageJson so the // ids can be targeted directly; each op is verified via getPageJson after // the standard 16s persistence wait. { @@ -348,7 +348,7 @@ async function main() { } } - // 6e. rename_page: title-only update must leave the content untouched. + // 6e. renamePage: title-only update must leave the content untouched. { const rp = await client.createPage("E2E rename before " + Date.now(), "Rename body marker RENAMEBODY.", spaceId); const rid = rp.data.id; @@ -357,19 +357,19 @@ async function main() { const beforeContent = JSON.stringify(beforeJson); const newTitle = "E2E rename AFTER " + Date.now(); const rr = await client.renamePage(rid, newTitle); - check("rename_page: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr)); + check("renamePage: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr)); await new Promise((r) => setTimeout(r, 16000)); const afterJson = await client.getPageJson(rid); - check("rename_page: title changed", afterJson.title === newTitle, afterJson.title); - check("rename_page: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY")); + check("renamePage: title changed", afterJson.title === newTitle, afterJson.title); + check("renamePage: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY")); const afterMd = (await client.getPage(rid)).data; - check("rename_page: get_page reflects new title", afterMd.title === newTitle, afterMd.title); + check("renamePage: getPage reflects new title", afterMd.title === newTitle, afterMd.title); } finally { try { await client.deletePage(rid); } catch {} } } - // 6f. update_page_json title-only: omitting content updates the title and + // 6f. updatePageJson title-only: omitting content updates the title and // leaves the body intact; supplying neither content nor title throws. { const up = await client.createPage("E2E upj-title before " + Date.now(), "Title-only body marker UPJTITLEBODY.", spaceId); @@ -378,20 +378,20 @@ async function main() { const beforeContent = JSON.stringify((await client.getPageJson(uid)).content); const newTitle = "E2E upj-title AFTER " + Date.now(); const ur = await client.updatePageJson(uid, undefined, newTitle); - check("update_page_json title-only: succeeds", ur.success === true, JSON.stringify(ur)); + check("updatePageJson title-only: succeeds", ur.success === true, JSON.stringify(ur)); await new Promise((r) => setTimeout(r, 16000)); const afterJson = await client.getPageJson(uid); - check("update_page_json title-only: title updated", afterJson.title === newTitle, afterJson.title); - check("update_page_json title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY")); + check("updatePageJson title-only: title updated", afterJson.title === newTitle, afterJson.title); + check("updatePageJson title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY")); let upjErr = ""; try { await client.updatePageJson(uid); } catch (e) { upjErr = e.message; } - check("update_page_json: neither content nor title throws", upjErr.includes("nothing to update"), upjErr); + check("updatePageJson: neither content nor title throws", upjErr.includes("nothing to update"), upjErr); } finally { try { await client.deletePage(uid); } catch {} } } - // 6g. copy_page_content: B's body becomes a copy of A's body, server-side, + // 6g. copyPageContent: B's body becomes a copy of A's body, server-side, // while B's title/slugId stay put. Both pages are throwaways. { let aid = null; @@ -409,24 +409,24 @@ async function main() { const aNodeCount = aJson.content.content.length; const cr = await client.copyPageContent(aid, bid); - check("copy_page_content: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr)); + check("copyPageContent: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr)); await new Promise((r) => setTimeout(r, 16000)); const bAfter = await client.getPageJson(bid); const bText = JSON.stringify(bAfter.content); - check("copy_page_content: B now has A's marker", bText.includes("COPYSOURCE")); - check("copy_page_content: B's old marker gone", !bText.includes("COPYTARGET")); - check("copy_page_content: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`); - check("copy_page_content: B title unchanged", bAfter.title === bTitleBefore, bAfter.title); - check("copy_page_content: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId); + check("copyPageContent: B now has A's marker", bText.includes("COPYSOURCE")); + check("copyPageContent: B's old marker gone", !bText.includes("COPYTARGET")); + check("copyPageContent: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`); + check("copyPageContent: B title unchanged", bAfter.title === bTitleBefore, bAfter.title); + check("copyPageContent: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId); // Source must be left untouched by the copy. const aAfter = JSON.stringify((await client.getPageJson(aid)).content); - check("copy_page_content: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE")); + check("copyPageContent: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE")); let copyErr = ""; try { await client.copyPageContent(aid, aid); } catch (e) { copyErr = e.message; } - check("copy_page_content: self-copy rejected", copyErr.includes("same page"), copyErr); + check("copyPageContent: self-copy rejected", copyErr.includes("same page"), copyErr); } finally { try { if (bid) await client.deletePage(bid); } catch {} try { if (aid) await client.deletePage(aid); } catch {} @@ -435,22 +435,22 @@ async function main() { // 7. shares: create (idempotent), public access, list, unshare const share = await client.sharePage(pageId); - check("share_page: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl); + check("sharePage: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl); const share2 = await client.sharePage(pageId); - check("share_page: idempotent", share2.key === share.key); + check("sharePage: idempotent", share2.key === share.key); const anon = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true }); - check("share_page: anonymous access works", anon.status === 200); + check("sharePage: anonymous access works", anon.status === 200); const shares = await client.listShares(); - check("list_shares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl)); + check("listShares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl)); const un = await client.unsharePage(pageId); - check("unshare_page: success", un.success === true); + check("unsharePage: success", un.success === true); const anon2 = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true }); - check("unshare_page: public access revoked", anon2.status !== 200, `status=${anon2.status}`); + check("unsharePage: public access revoked", anon2.status !== 200, `status=${anon2.status}`); - // 8. get_page markdown round-trip sanity (table separator present) + // 8. getPage markdown round-trip sanity (table separator present) const md = await client.getPage(pageId); - check("get_page md: table separator emitted", md.data.content.includes("| --- |"), ""); - check("get_page md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]")); + check("getPage md: table separator emitted", md.data.content.includes("| --- |"), ""); + check("getPage md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]")); // 9. comments: create / list / reply / update / check_new / delete const beforeComments = new Date(Date.now() - 1000).toISOString(); @@ -458,34 +458,34 @@ async function main() { // that exists in the persisted page to anchor on. "Добавленный абзац." is a // plain paragraph re-imported in section 5 and still present here. const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).", "inline", "Добавленный абзац."); - check("create_comment: created", !!c1.data.id, c1.data.id); - check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content); + check("createComment: created", !!c1.data.id, c1.data.id); + check("createComment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content); const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id); - check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id); + check("createComment: reply has parent", reply.data.parentCommentId === c1.data.id); const list = (await client.listComments(pageId)).items; - check("list_comments: both visible", list.length === 2, `count=${list.length}`); + check("listComments: both visible", list.length === 2, `count=${list.length}`); await client.updateComment(c1.data.id, "Обновлённый текст комментария."); const got = await client.getComment(c1.data.id); - check("update_comment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content); + check("updateComment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content); const news = await client.checkNewComments(spaceId, beforeComments, pageId); - check("check_new_comments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`); - // resolve_comment: close the top-level thread, verify resolvedAt surfaces, then reopen + check("checkNewComments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`); + // resolveComment: close the top-level thread, verify resolvedAt surfaces, then reopen const resolvedRes = await client.resolveComment(c1.data.id, true); - check("resolve_comment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true); + check("resolveComment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true); // c1 is now resolved; the default feed hides resolved threads, so pass // includeResolved:true to still see it and assert its resolvedAt (#328). const listResolved = (await client.listComments(pageId, true)).items; const c1Resolved = listResolved.find((c) => c.id === c1.data.id); - check("resolve_comment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`); + check("resolveComment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`); const reopenedRes = await client.resolveComment(c1.data.id, false); - check("resolve_comment: reopen succeeds", reopenedRes.resolved === false); + check("resolveComment: reopen succeeds", reopenedRes.resolved === false); const listReopened = (await client.listComments(pageId)).items; const c1Reopened = listReopened.find((c) => c.id === c1.data.id); - check("resolve_comment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`); + check("resolveComment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`); await client.deleteComment(reply.data.id); await client.deleteComment(c1.data.id); const listAfter = (await client.listComments(pageId)).items; - check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`); + check("deleteComment: comments removed", listAfter.length === 0, `count=${listAfter.length}`); } finally { if (pageId) { await client.deletePage(pageId); diff --git a/packages/mcp/test/mock/ambiguous-node-id.test.mjs b/packages/mcp/test/mock/ambiguous-node-id.test.mjs index d8a55201..03eaf9bd 100644 --- a/packages/mcp/test/mock/ambiguous-node-id.test.mjs +++ b/packages/mcp/test/mock/ambiguous-node-id.test.mjs @@ -1,4 +1,4 @@ -// Mock collab regression for the AMBIGUOUS-id refusal in patch_node / delete_node +// Mock collab regression for the AMBIGUOUS-id refusal in patchNode / deleteNode // (#159, PR #185 review pt 1). When a page has TWO blocks sharing one attrs.id // (Docmost duplicates block ids on copy/paste), the transform's // `if (replaced !== 1) return null` / `if (deleted !== 1) return null` guard must @@ -126,7 +126,7 @@ after(async () => { ); }); -test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab", async () => { +test("patchNode REFUSES an ambiguous (duplicate) id without writing to collab", async () => { const { state, baseURL } = await spawnCollabStack(); const client = new DocmostClient(baseURL, "user@example.com", "pw"); @@ -137,7 +137,7 @@ test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab", content: [{ type: "text", text: "replacement" }], }), /ambiguous/i, - "patch_node must reject a duplicate-id target with an 'ambiguous' error", + "patchNode must reject a duplicate-id target with an 'ambiguous' error", ); assert.equal( @@ -147,14 +147,14 @@ test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab", ); }); -test("delete_node REFUSES an ambiguous (duplicate) id without writing to collab", async () => { +test("deleteNode REFUSES an ambiguous (duplicate) id without writing to collab", async () => { const { state, baseURL } = await spawnCollabStack(); const client = new DocmostClient(baseURL, "user@example.com", "pw"); await assert.rejects( () => client.deleteNode("22222222-2222-4222-8222-222222222222", DUP_ID), /ambiguous/i, - "delete_node must reject a duplicate-id target with an 'ambiguous' error", + "deleteNode must reject a duplicate-id target with an 'ambiguous' error", ); assert.equal( diff --git a/packages/mcp/test/mock/drawio-tools.test.mjs b/packages/mcp/test/mock/drawio-tools.test.mjs index 9183bcff..464dbfd9 100644 --- a/packages/mcp/test/mock/drawio-tools.test.mjs +++ b/packages/mcp/test/mock/drawio-tools.test.mjs @@ -1,4 +1,4 @@ -// Contract tests for the drawio_get / drawio_create / drawio_update client +// Contract tests for the drawioGet / drawioCreate / drawioUpdate client // methods (issue #423). Follows the repo's seam-override pattern (see // full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O // seams (auth, collab token, page read, attachment upload/fetch, the mutatePage @@ -114,9 +114,9 @@ function findDrawio(node, acc = []) { return acc; } -// --- drawio_create --------------------------------------------------------- +// --- drawioCreate --------------------------------------------------------- -test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node", async () => { +test("drawioCreate: lints, builds the .drawio.svg, uploads and inserts a node", async () => { const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }], @@ -148,7 +148,7 @@ test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node", assert.equal(n.attrs.title, "My diagram"); }); -test("drawio_create: a lint violation throws before any upload", async () => { +test("drawioCreate: a lint violation throws before any upload", async () => { const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } }); // Edge with no child geometry -> edge-geometry rule. const bad = @@ -162,7 +162,7 @@ test("drawio_create: a lint violation throws before any upload", async () => { assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure"); }); -test("drawio_create: before/after requires exactly one anchor", async () => { +test("drawioCreate: before/after requires exactly one anchor", async () => { const { client } = makeClient({ pageDoc: { type: "doc", content: [] } }); await assert.rejects( () => client.drawioCreate("page1", { position: "before" }, MODEL), @@ -170,9 +170,9 @@ test("drawio_create: before/after requires exactly one anchor", async () => { ); }); -// --- drawio_get ------------------------------------------------------------ +// --- drawioGet ------------------------------------------------------------ -test("drawio_get: decodes the model and returns meta with a hash", async () => { +test("drawioGet: decodes the model and returns meta with a hash", async () => { const pageDoc = { type: "doc", content: [ @@ -198,7 +198,7 @@ test("drawio_get: decodes the model and returns meta with a hash", async () => { assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL))); }); -test("drawio_get: format=svg returns the raw .drawio.svg", async () => { +test("drawioGet: format=svg returns the raw .drawio.svg", async () => { const svg = svgFor(MODEL); const pageDoc = { type: "doc", @@ -211,7 +211,7 @@ test("drawio_get: format=svg returns the raw .drawio.svg", async () => { assert.equal(res.content, svg); }); -test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => { +test("drawioGet: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => { const pageDoc = { type: "doc", content: [ @@ -223,7 +223,7 @@ test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", asy assert.equal(res.content, normalizeXml(MODEL)); }); -// --- drawio_update --------------------------------------------------------- +// --- drawioUpdate --------------------------------------------------------- const UPDATED_MODEL = '' + @@ -250,7 +250,7 @@ function updatePageDoc() { }; } -test("drawio_update: stale baseHash -> conflict, no upload", async () => { +test("drawioUpdate: stale baseHash -> conflict, no upload", async () => { const { client, calls } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL), @@ -262,7 +262,7 @@ test("drawio_update: stale baseHash -> conflict, no upload", async () => { assert.equal(calls.uploads.length, 0, "no upload on conflict"); }); -test("drawio_update: current baseHash -> uploads new attachment and repoints node dims", async () => { +test("drawioUpdate: current baseHash -> uploads new attachment and repoints node dims", async () => { const currentHash = mxHash(normalizeXml(MODEL)); const { client, calls } = makeClient({ pageDoc: updatePageDoc(), @@ -285,7 +285,7 @@ test("drawio_update: current baseHash -> uploads new attachment and repoints nod assert.equal(n.attrs.id, undefined); }); -test("drawio_update: baseHash is mandatory", async () => { +test("drawioUpdate: baseHash is mandatory", async () => { const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) }); await assert.rejects( () => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""), @@ -295,7 +295,7 @@ test("drawio_update: baseHash is mandatory", async () => { // --- Fix 1: the create handle must resolve on the SAVED doc (no id) --------- -test("drawio_create -> get/update: returned # handle resolves on the saved doc (id dropped)", async () => { +test("drawioCreate -> get/update: returned # handle resolves on the saved doc (id dropped)", async () => { // Create appends a drawio node after the existing paragraph. const createDoc = { type: "doc", @@ -316,13 +316,13 @@ test("drawio_create -> get/update: returned # handle resolves on the save const savedDoc = create.calls.mutations[0].doc; assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined); - // drawio_get with the returned handle resolves the just-created node. + // drawioGet with the returned handle resolves the just-created node. const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) }); const got = await getClient.client.drawioGet("page1", res.nodeId, "xml"); assert.equal(got.nodeId, res.nodeId); assert.equal(got.content, normalizeXml(MODEL)); - // drawio_update with the same handle + the hash from get repoints that node. + // drawioUpdate with the same handle + the hash from get repoints that node. const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) }); const upd = await upClient.client.drawioUpdate( "page1", @@ -342,7 +342,7 @@ test("drawio_create -> get/update: returned # handle resolves on the save // --- error paths: the LLM must get a clean error, not a crash -------------- -test("drawio_get: a bad node ref -> clean 'no node found' error", async () => { +test("drawioGet: a bad node ref -> clean 'no node found' error", async () => { // Page has one paragraph; the requested ref resolves to nothing. const pageDoc = { type: "doc", @@ -355,7 +355,7 @@ test("drawio_get: a bad node ref -> clean 'no node found' error", async () => { ); }); -test("drawio_get: a drawio node with no src -> clean 'has no src to read' error", async () => { +test("drawioGet: a drawio node with no src -> clean 'has no src to read' error", async () => { const pageDoc = { type: "doc", content: [ @@ -370,7 +370,7 @@ test("drawio_get: a drawio node with no src -> clean 'has no src to read' error" ); }); -test("drawio_update: the resolved node is NOT a drawio node -> clean error, no upload", async () => { +test("drawioUpdate: the resolved node is NOT a drawio node -> clean error, no upload", async () => { // "#0" resolves to a paragraph. The update must refuse cleanly rather than // crash or repoint the wrong node. const pageDoc = { @@ -386,7 +386,7 @@ test("drawio_update: the resolved node is NOT a drawio node -> clean error, no u assert.equal(calls.mutations.length, 0, "no write when the node is not a diagram"); }); -test("drawio_create: anchor not found -> clean error that reports the orphan attachment", async () => { +test("drawioCreate: anchor not found -> clean error that reports the orphan attachment", async () => { // The upload happens before the mutate transform; when the anchor cannot be // found the write is skipped and the (now unreferenced) attachment is named // in the error, exactly as the code documents. @@ -416,7 +416,7 @@ test("drawio_create: anchor not found -> clean error that reports the orphan att // --- Fix 2: update targets ONLY the resolved node -------------------------- -test("drawio_update: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => { +test("drawioUpdate: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => { // A copied diagram: two drawio nodes share one attachmentId. Updating via the // "#0" handle must touch node #0 only, never the sibling copy. const shared = { diff --git a/packages/mcp/test/mock/footnote-write.test.mjs b/packages/mcp/test/mock/footnote-write.test.mjs index 29196b39..eb5fd103 100644 --- a/packages/mcp/test/mock/footnote-write.test.mjs +++ b/packages/mcp/test/mock/footnote-write.test.mjs @@ -2,7 +2,7 @@ // (issue #228): // - insertFootnote (#11): the required-argument guards reject BEFORE any write, // and never touch the collab/mutate path. -// - transformPage / docmost_transform (#13): the auto-canonicalize step +// - transformPage / docmostTransform (#13): the auto-canonicalize step // (`result = canonicalizeFootnotes(raw)`) runs after every transform, so a // transform that introduces an orphan footnote definition is silently tidied // away — observable as an EMPTY diff in a dryRun preview. @@ -10,7 +10,7 @@ // These stand a local http.createServer in for Docmost and only exercise plain // HTTP routes (login / comments / pages.info), deliberately avoiding the live // Hocuspocus collab WebSocket: the insertFootnote guards short-circuit before it, -// and docmost_transform's dryRun preview never opens it. The collab mutate path +// and docmostTransform's dryRun preview never opens it. The collab mutate path // itself — abort-via-throw on a missing anchor with NO persisted write, and the // reused-vs-new response shaping — is covered in // test/mock/insert-footnote-wrapper.test.mjs (which overrides the mutatePage @@ -101,7 +101,7 @@ test("insertFootnote rejects an empty text before any write", async () => { }); // --------------------------------------------------------------------------- -// #13 docmost_transform auto-canonicalization: a transform that adds an orphan +// #13 docmostTransform auto-canonicalization: a transform that adds an orphan // footnote definition produces NO net change (the canonicalizer drops it), so a // dryRun preview reports an empty diff. Without the auto-canonicalize step the // orphan would survive and the diff would be non-empty. 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 a9a057ef..ba77d53f 100644 --- a/packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs +++ b/packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs @@ -1,5 +1,5 @@ // Footnote-canonicalization binding tests for the MCP FULL-document write tools -// (issue #228, review #4): update_page_json and copy_page_content must persist a +// (issue #228, review #4): updatePageJson and copyPageContent must persist a // 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 @@ -44,7 +44,7 @@ function makeClient(sourceDoc) { return { client, calls }; } -test("update_page_json canonicalizes the persisted full doc (out-of-order -> reference order)", async () => { +test("updatePageJson canonicalizes the persisted full doc (out-of-order -> reference order)", async () => { const { client, calls } = makeClient(); const outOfOrder = { type: "doc", @@ -60,7 +60,7 @@ test("update_page_json canonicalizes the persisted full doc (out-of-order -> ref assert.equal(findAll(calls.replaced[0].doc, "footnotesList").length, 1); }); -test("copy_page_content canonicalizes the persisted copy (orphan definition dropped)", async () => { +test("copyPageContent canonicalizes the persisted copy (orphan definition dropped)", async () => { const sourceDoc = { type: "doc", content: [ diff --git a/packages/mcp/test/mock/invalid-node-validation.test.mjs b/packages/mcp/test/mock/invalid-node-validation.test.mjs index 3e59c805..bb534581 100644 --- a/packages/mcp/test/mock/invalid-node-validation.test.mjs +++ b/packages/mcp/test/mock/invalid-node-validation.test.mjs @@ -1,6 +1,6 @@ // Mock regression for the FAIL-FAST invalid-node validation (#409). // -// A structural editor (patch_node / insert_node / update_page_json) given a doc +// A structural editor (patchNode / insertNode / updatePageJson) given a doc // whose NESTED child has an absent/unknown `type` (the exact shape the Yjs // encoder rejects with `Unknown node type: undefined`) must throw a RICH, // path-anchored error BEFORE it ever opens a collab session or takes a page @@ -23,7 +23,7 @@ import { Hocuspocus } from "@hocuspocus/server"; import { DocmostClient } from "../../build/client.js"; import { buildYDoc } from "../../build/lib/collaboration.js"; -// A minimal valid seed doc with a real block id, so the happy-path patch_node +// A minimal valid seed doc with a real block id, so the happy-path patchNode // finds its target. const SEED_ID = "seed-para-id"; function seedDoc() { @@ -130,14 +130,14 @@ const nestedUnknownTypeNode = () => ({ content: [{ type: "paragraf", content: [{ type: "text", text: "x" }] }], }); -test("patch_node fails fast on a nested typeless node — no collab connection", async () => { +test("patchNode fails fast on a nested typeless node — no collab connection", async () => { const { state, baseURL } = await spawnCollabStack(); const client = new DocmostClient(baseURL, "user@example.com", "pw"); await assert.rejects( () => client.patchNode(PAGE, SEED_ID, nestedTypelessNode()), (err) => { - assert.match(err.message, /patch_node: invalid node/); + assert.match(err.message, /patchNode: invalid node/); assert.match(err.message, /missing "type"/); assert.match(err.message, /content\[0\]/); // path-anchored return true; @@ -152,7 +152,7 @@ test("patch_node fails fast on a nested typeless node — no collab connection", assert.equal(state.changed, false, "the collab doc must never be written"); }); -test("insert_node fails fast on a nested UNKNOWN type — no collab connection", async () => { +test("insertNode fails fast on a nested UNKNOWN type — no collab connection", async () => { const { state, baseURL } = await spawnCollabStack(); const client = new DocmostClient(baseURL, "user@example.com", "pw"); @@ -162,7 +162,7 @@ test("insert_node fails fast on a nested UNKNOWN type — no collab connection", position: "append", }), (err) => { - assert.match(err.message, /insert_node: invalid node/); + assert.match(err.message, /insertNode: invalid node/); assert.match(err.message, /unknown node type "paragraf"/); return true; }, @@ -172,7 +172,7 @@ test("insert_node fails fast on a nested UNKNOWN type — no collab connection", assert.equal(state.changed, false); }); -test("update_page_json fails fast on a nested typeless node — no collab connection", async () => { +test("updatePageJson fails fast on a nested typeless node — no collab connection", async () => { const { state, baseURL } = await spawnCollabStack(); const client = new DocmostClient(baseURL, "user@example.com", "pw"); @@ -184,7 +184,7 @@ test("update_page_json fails fast on a nested typeless node — no collab connec await assert.rejects( () => client.updatePageJson(PAGE, badDoc), (err) => { - // update_page_json runs validateDocStructure first (string-type check), + // updatePageJson runs validateDocStructure first (string-type check), // which already rejects a typeless node — so the message may come from // either guard, but the write must not happen. assert.match(err.message, /type/i); @@ -196,7 +196,7 @@ test("update_page_json fails fast on a nested typeless node — no collab connec assert.equal(state.changed, false); }); -test("update_page_json fails fast on a nested UNKNOWN type name — rich #409 message", async () => { +test("updatePageJson fails fast on a nested UNKNOWN type name — rich #409 message", async () => { const { state, baseURL } = await spawnCollabStack(); const client = new DocmostClient(baseURL, "user@example.com", "pw"); @@ -210,7 +210,7 @@ test("update_page_json fails fast on a nested UNKNOWN type name — rich #409 me await assert.rejects( () => client.updatePageJson(PAGE, badDoc), (err) => { - assert.match(err.message, /update_page_json: invalid node/); + assert.match(err.message, /updatePageJson: invalid node/); assert.match(err.message, /unknown node type "paragraf"/); return true; }, @@ -220,7 +220,7 @@ test("update_page_json fails fast on a nested UNKNOWN type name — rich #409 me assert.equal(state.changed, false); }); -test("patch_node with a well-formed node proceeds to the collab write", async () => { +test("patchNode with a well-formed node proceeds to the collab write", async () => { const { state, baseURL } = await spawnCollabStack(); const client = new DocmostClient(baseURL, "user@example.com", "pw"); diff --git a/packages/mcp/test/mock/pagination-cursor.test.mjs b/packages/mcp/test/mock/pagination-cursor.test.mjs index 9bb61c71..a81a158e 100644 --- a/packages/mcp/test/mock/pagination-cursor.test.mjs +++ b/packages/mcp/test/mock/pagination-cursor.test.mjs @@ -155,7 +155,7 @@ test("listSidebarPages terminates (no dups) when the server ignores the cursor", // ----------------------------------------------------------------------------- // 3a) enumerateSpacePages happy path: a SINGLE /pages/tree request. // ----------------------------------------------------------------------------- -test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", async () => { +test("enumerateSpacePages (via listPages tree) uses one /pages/tree request", async () => { let treeRequests = 0; let sidebarRequests = 0; let treeBody = null; @@ -184,7 +184,7 @@ test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", a }); const client = new DocmostClient(baseURL, "user@example.com", "pw"); - // list_pages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree. + // listPages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree. const tree = await client.listPages("space-1", 50, true); assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space"); @@ -375,7 +375,7 @@ test("listComments terminates (no dups) when the server ignores the cursor", asy }); // ----------------------------------------------------------------------------- -// 4) check_new_comments subtree: the root is included in scope WITHOUT a +// 4) checkNewComments subtree: the root is included in scope WITHOUT a // separate getPageRaw (/pages/info) request for the parent. // ----------------------------------------------------------------------------- test("checkNewComments subtree includes the root without a separate getPageRaw", async () => { diff --git a/packages/mcp/test/mock/resolve-page-id-collab-doc-name.test.mjs b/packages/mcp/test/mock/resolve-page-id-collab-doc-name.test.mjs index feaf94c6..dddd581c 100644 --- a/packages/mcp/test/mock/resolve-page-id-collab-doc-name.test.mjs +++ b/packages/mcp/test/mock/resolve-page-id-collab-doc-name.test.mjs @@ -244,7 +244,7 @@ test("tableInsertRow with a slugId opens the collab doc by the resolved UUID (#2 ); }); -test("the generic mutate (insert_footnote) with a slugId opens by the resolved UUID (#260)", async () => { +test("the generic mutate (insertFootnote) with a slugId opens by the resolved UUID (#260)", async () => { const { state, baseURL } = await spawnCollabStack(); const client = new DocmostClient(baseURL, "user@example.com", "pw"); @@ -254,7 +254,7 @@ test("the generic mutate (insert_footnote) with a slugId opens by the resolved U assert.deepEqual( state.docNames, [`page.${UUID}`], - "insert_footnote (via the mutatePage seam) must open the collab doc by UUID", + "insertFootnote (via the mutatePage seam) must open the collab doc by UUID", ); }); diff --git a/packages/mcp/test/mock/stash-page-mcp-result.test.mjs b/packages/mcp/test/mock/stash-page-mcp-result.test.mjs index 629c7187..b18e2cb7 100644 --- a/packages/mcp/test/mock/stash-page-mcp-result.test.mjs +++ b/packages/mcp/test/mock/stash-page-mcp-result.test.mjs @@ -1,4 +1,4 @@ -// Server round-trip test for the stash_page MCP tool result shape. The in-app +// Server round-trip test for the stashPage MCP tool result shape. The in-app // path returns the full documented `{ uri, size, sha256, images }` object, but // the MCP transport must deliver the SAME shape: a resource_link (primary // payload) PLUS a `structuredContent` mirror carrying sha256 + image counts. @@ -107,7 +107,7 @@ async function buildBaseURL() { }); } -test("stash_page MCP tool returns a resource_link AND a structuredContent mirror", async () => { +test("stashPage MCP tool returns a resource_link AND a structuredContent mirror", async () => { const baseURL = await buildBaseURL(); const sandbox = makeSandbox(); const server = createDocmostMcpServer({ @@ -124,7 +124,7 @@ test("stash_page MCP tool returns a resource_link AND a structuredContent mirror try { const res = await client.callTool({ - name: "stash_page", + name: "stashPage", arguments: { pageId: "page-1" }, }); diff --git a/packages/mcp/test/mock/tool-timing-server.test.mjs b/packages/mcp/test/mock/tool-timing-server.test.mjs index 6e93842f..3e4d4262 100644 --- a/packages/mcp/test/mock/tool-timing-server.test.mjs +++ b/packages/mcp/test/mock/tool-timing-server.test.mjs @@ -20,11 +20,11 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createDocmostMcpServer } from "../../build/index.js"; -// The tool we drive. get_workspace has NO input schema, so protocol-level input +// The tool we drive. getWorkspace has NO input schema, so protocol-level input // validation cannot short-circuit before the handler runs — the wrapped handler // is guaranteed to execute (and then fail on the unreachable backend, which is // exactly what we want: the wrapper times in a finally on throw too). -const TOOL_NAME = "get_workspace"; +const TOOL_NAME = "getWorkspace"; test("the factory's registerTool monkeypatch times a live tool call and labels it with the registration name", async () => { const calls = []; diff --git a/packages/mcp/test/unit/comment-signal.test.mjs b/packages/mcp/test/unit/comment-signal.test.mjs index f89da5ee..59cc3c4e 100644 --- a/packages/mcp/test/unit/comment-signal.test.mjs +++ b/packages/mcp/test/unit/comment-signal.test.mjs @@ -152,7 +152,7 @@ test("tautological comment tools are excluded and never probe", async () => { const { comments, probeCalls, tracker } = makeWorld(); tracker.noteWorkingPage("p1"); comments.push({ createdAt: 9_999_999 }); - for (const name of ["listComments", "list_comments", "checkNewComments", "createComment"]) { + for (const name of ["listComments", "listComments", "checkNewComments", "createComment"]) { assert.equal(await tracker.maybeSignal(name), null); } assert.equal(probeCalls.length, 0); @@ -188,7 +188,7 @@ function fakeTracker({ line }) { noteWorkingPage: (p) => events.push(["note", p]), advanceWatermark: () => events.push(["advance"]), isExcludedTool: (n) => - new Set(["listComments", "list_comments"]).has(n), + new Set(["listComments", "listComments"]).has(n), maybeSignal: async () => line, }; } @@ -221,7 +221,7 @@ test("withCommentSignal: appends ONE extra text element when signalled", async ( test("withCommentSignal: excluded tool advances the watermark and does not append", async () => { const tracker = fakeTracker({ line: "SHOULD-NOT-APPEAR" }); const original = { content: [{ type: "text", text: "comments" }] }; - const wrapped = withCommentSignal("list_comments", async () => original, tracker); + const wrapped = withCommentSignal("listComments", async () => original, tracker); const result = await wrapped({ pageId: "p1" }); assert.equal(result, original); // unchanged assert.ok(tracker.events.some((e) => e[0] === "advance")); diff --git a/packages/mcp/test/unit/diff-verify.test.mjs b/packages/mcp/test/unit/diff-verify.test.mjs index 01af95dc..08eec6e7 100644 --- a/packages/mcp/test/unit/diff-verify.test.mjs +++ b/packages/mcp/test/unit/diff-verify.test.mjs @@ -114,7 +114,7 @@ test("summarizeChange treats a key-order-only difference as no change", () => { // (v) CRITICAL: a structural change that touches no text/marks — adding an // image node (images 0 -> 1) — must report changed:true and surface the // integrity delta in structure + summary, closing the verify blind spot for -// insert_image / delete_node on structural nodes. +// insertImage / deleteNode on structural nodes. // --------------------------------------------------------------------------- test("summarizeChange surfaces an image-count change (0->1)", () => { const before = doc(para(t("caption"))); diff --git a/packages/mcp/test/unit/drawio-guide.test.mjs b/packages/mcp/test/unit/drawio-guide.test.mjs index cc6dbf6e..46478050 100644 --- a/packages/mcp/test/unit/drawio-guide.test.mjs +++ b/packages/mcp/test/unit/drawio-guide.test.mjs @@ -1,4 +1,4 @@ -// Unit tests for the drawio_guide progressive-disclosure reference (issue #424). +// Unit tests for the drawioGuide progressive-disclosure reference (issue #424). // Acceptance #2: every section is returned and each is <= ~4KB so pulling one // does not bloat the model's context. import { test } from "node:test"; diff --git a/packages/mcp/test/unit/drawio-shapes.test.mjs b/packages/mcp/test/unit/drawio-shapes.test.mjs index 1521a744..8175f156 100644 --- a/packages/mcp/test/unit/drawio-shapes.test.mjs +++ b/packages/mcp/test/unit/drawio-shapes.test.mjs @@ -1,4 +1,4 @@ -// Unit tests for the drawio_shapes verified-stencil catalog (issue #424). +// Unit tests for the drawioShapes verified-stencil catalog (issue #424). // Covers acceptance #1: a "lambda" query returns a valid mxgraph.aws4 icon with // the right service/resource pattern + sizes; a blocklisted stencil query // returns its working replacement. @@ -22,7 +22,7 @@ test("the bundled index loads and is the real ~10k-shape catalog", () => { } }); -test('drawio_shapes("lambda") returns a valid mxgraph.aws4 service icon', () => { +test('drawioShapes("lambda") returns a valid mxgraph.aws4 service icon', () => { const results = searchShapes("lambda", { limit: 5 }); assert.ok(results.length > 0); // Acceptance #1: a valid aws4 service-level icon (resourceIcon + resIcon) diff --git a/packages/mcp/test/unit/drawio-stage2-registration.test.mjs b/packages/mcp/test/unit/drawio-stage2-registration.test.mjs index 37b40a4c..1e4a1fb7 100644 --- a/packages/mcp/test/unit/drawio-stage2-registration.test.mjs +++ b/packages/mcp/test/unit/drawio-stage2-registration.test.mjs @@ -7,16 +7,16 @@ import assert from "node:assert/strict"; import { SERVER_INSTRUCTIONS } from "../../build/index.js"; import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js"; -test("drawio_shapes and drawio_guide are in the shared registry", () => { - assert.equal(SHARED_TOOL_SPECS.drawioShapes.mcpName, "drawio_shapes"); - assert.equal(SHARED_TOOL_SPECS.drawioGuide.mcpName, "drawio_guide"); +test("drawioShapes and drawioGuide are in the shared registry", () => { + assert.equal(SHARED_TOOL_SPECS.drawioShapes.mcpName, "drawioShapes"); + assert.equal(SHARED_TOOL_SPECS.drawioGuide.mcpName, "drawioGuide"); // Deferred tier, matching the stage-1 drawio tools. assert.equal(SHARED_TOOL_SPECS.drawioShapes.tier, "deferred"); assert.equal(SHARED_TOOL_SPECS.drawioGuide.tier, "deferred"); }); test("the new tools are routed in SERVER_INSTRUCTIONS", () => { - for (const name of ["drawio_shapes", "drawio_guide"]) { + for (const name of ["drawioShapes", "drawioGuide"]) { assert.match(SERVER_INSTRUCTIONS, new RegExp(`\\b${name}\\b`), `${name} missing from guide`); } }); @@ -26,7 +26,7 @@ test("the hard-rules block is injected into create/update descriptions", () => { const d = SHARED_TOOL_SPECS[key].description; assert.match(d, /sentinels are MANDATORY/); assert.match(d, /vertex="1" XOR edge="1"/); - assert.match(d, /call drawio_shapes first/); + assert.match(d, /call drawioShapes first/); assert.match(d, /adaptiveColors="auto"/); assert.match(d, / /); } diff --git a/packages/mcp/test/unit/error-diagnostics.test.mjs b/packages/mcp/test/unit/error-diagnostics.test.mjs index ab1bdd56..d4211d14 100644 --- a/packages/mcp/test/unit/error-diagnostics.test.mjs +++ b/packages/mcp/test/unit/error-diagnostics.test.mjs @@ -258,7 +258,7 @@ test("a non-axios error is passed through untouched", () => { const GOOD_UUID = "019f499a-9f8c-7d68-b7be-ce100d7c6c56"; test("assertFullUuid accepts a full canonical UUID (any version nibble)", () => { - assert.doesNotThrow(() => assertFullUuid("resolve_comment", "commentId", GOOD_UUID)); + assert.doesNotThrow(() => assertFullUuid("resolveComment", "commentId", GOOD_UUID)); // A v4 id also passes (version/variant-agnostic). assert.doesNotThrow(() => assertFullUuid("get_comment", "commentId", "3d5b7c1e-2f4a-4b6c-8d9e-0f1a2b3c4d5e"), @@ -267,10 +267,10 @@ test("assertFullUuid accepts a full canonical UUID (any version nibble)", () => test("assertFullUuid rejects a truncated prefix", () => { assert.throws( - () => assertFullUuid("resolve_comment", "commentId", "019f499a"), + () => assertFullUuid("resolveComment", "commentId", "019f499a"), (e) => e.message.startsWith( - "resolve_comment: 'commentId' must be the FULL comment UUID", + "resolveComment: 'commentId' must be the FULL comment UUID", ) && e.message.includes("got '019f499a'") && e.message.includes("Copy the id verbatim"), @@ -279,11 +279,11 @@ test("assertFullUuid rejects a truncated prefix", () => { test("assertFullUuid rejects garbage and empty string", () => { assert.throws( - () => assertFullUuid("delete_comment", "commentId", "not-a-uuid"), + () => assertFullUuid("deleteComment", "commentId", "not-a-uuid"), /must be the FULL comment UUID.*got 'not-a-uuid'/s, ); assert.throws( - () => assertFullUuid("update_comment", "commentId", ""), + () => assertFullUuid("updateComment", "commentId", ""), /must be the FULL comment UUID.*got ''/s, ); }); @@ -436,14 +436,14 @@ test("all 5 comment-id call sites reject a bad id with ZERO network traffic", as const client = new DocmostClient(baseURL, "u@example.com", "pw"); const bad = "019f499a"; // truncated - await assert.rejects(() => client.resolveComment(bad, true), /resolve_comment: 'commentId'/); - await assert.rejects(() => client.updateComment(bad, "hi"), /update_comment: 'commentId'/); - await assert.rejects(() => client.deleteComment(bad), /delete_comment: 'commentId'/); + await assert.rejects(() => client.resolveComment(bad, true), /resolveComment: 'commentId'/); + await assert.rejects(() => client.updateComment(bad, "hi"), /updateComment: 'commentId'/); + await assert.rejects(() => client.deleteComment(bad), /deleteComment: 'commentId'/); await assert.rejects(() => client.getComment(bad), /get_comment: 'commentId'/); // createComment validates parentCommentId only when provided. await assert.rejects( () => client.createComment("page-1", "body", "inline", "sel", bad), - /create_comment: 'parentCommentId'/, + /createComment: 'parentCommentId'/, ); assert.equal(requests, 0, "no request (not even /auth/login) may be issued for a bad id"); diff --git a/packages/mcp/test/unit/footnote-warnings-import.test.mjs b/packages/mcp/test/unit/footnote-warnings-import.test.mjs index 60d4571d..6de6575c 100644 --- a/packages/mcp/test/unit/footnote-warnings-import.test.mjs +++ b/packages/mcp/test/unit/footnote-warnings-import.test.mjs @@ -9,7 +9,7 @@ import { // Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the // field is present only when legacy reference-style `[^id]:` syntax is used and -// omitted otherwise, AND `import_page_markdown` analyzes the BODY (after the +// omitted otherwise, AND `importPageMarkdown` analyzes the BODY (after the // docmost:meta / docmost:comments blocks) — so a footnote-like token inside those // JSON blocks never warns, while a real definition in the body does. // importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)` diff --git a/packages/mcp/test/unit/json-edit-refuse.test.mjs b/packages/mcp/test/unit/json-edit-refuse.test.mjs index c4d6b846..a00ae171 100644 --- a/packages/mcp/test/unit/json-edit-refuse.test.mjs +++ b/packages/mcp/test/unit/json-edit-refuse.test.mjs @@ -25,7 +25,7 @@ test("formatting-only edit (strip-toggle) is refused, not applied", () => { assert.equal(failed.length, 1, "one refused edit"); assert.equal(failed[0].find, "~~x~~"); assert.match(failed[0].reason, /cannot add or remove formatting marks/); - assert.match(failed[0].reason, /patch_node/); + assert.match(failed[0].reason, /patchNode/); // The document is untouched (the strike mark is preserved). assert.deepEqual(out, snapshot); }); @@ -143,7 +143,7 @@ test("typo fix wrapped in markdown still applies (not refused)", () => { // --------------------------------------------------------------------------- // (iv) #410 footnote token: a `replace` containing `^[...]` is refused into // failed[] (it would be written as a LITERAL string, never a real footnote). -// Nothing is applied; the reason points at insert_footnote. +// Nothing is applied; the reason points at insertFootnote. // --------------------------------------------------------------------------- test("replace containing a `^[...]` footnote token is refused, not applied", () => { const input = doc(paragraph(textNode("The claim stands."))); @@ -156,7 +156,7 @@ test("replace containing a `^[...]` footnote token is refused, not applied", () assert.equal(results.length, 0, "nothing applied"); assert.equal(failed.length, 1, "one refused edit"); assert.equal(failed[0].find, "The claim stands."); - assert.match(failed[0].reason, /insert_footnote/); + assert.match(failed[0].reason, /insertFootnote/); // The document is byte-for-byte untouched — no literal `^[` was written. assert.deepEqual(out, snapshot); }); diff --git a/packages/mcp/test/unit/node-ops.test.mjs b/packages/mcp/test/unit/node-ops.test.mjs index b616a52f..25923837 100644 --- a/packages/mcp/test/unit/node-ops.test.mjs +++ b/packages/mcp/test/unit/node-ops.test.mjs @@ -486,34 +486,34 @@ test("insertNodeRelative truly-missing anchor still returns inserted:false", () assert.equal(inserted, false); }); -// assertUnambiguousMatch (#159, #185 review pt 2): the patch_node/delete_node +// assertUnambiguousMatch (#159, #185 review pt 2): the patchNode/deleteNode // guard. Docmost duplicates block ids on copy/paste, so a write by id that // matches >1 node must be REFUSED (the caller already skipped the write for any // count !== 1; this reports the error). The duplicate COUNT itself is covered by // the replaceNodeById/deleteNodeById tests above (count===2 for a 2-dup doc). test("assertUnambiguousMatch: count 0 throws 'no node found'", () => { assert.throws( - () => assertUnambiguousMatch("patch_node", "replace", 0, "n1", "p1"), - /patch_node: no node with id "n1" found on page p1/, + () => assertUnambiguousMatch("patchNode", "replace", 0, "n1", "p1"), + /patchNode: no node with id "n1" found on page p1/, ); }); test("assertUnambiguousMatch: count > 1 refuses with an 'ambiguous' error", () => { assert.throws( - () => assertUnambiguousMatch("patch_node", "replace", 2, "dup", "p1"), + () => assertUnambiguousMatch("patchNode", "replace", 2, "dup", "p1"), /ambiguous.*Refusing to replace all of them; nothing was changed/, ); assert.throws( - () => assertUnambiguousMatch("delete_node", "delete", 3, "dup", "p1"), + () => assertUnambiguousMatch("deleteNode", "delete", 3, "dup", "p1"), /ambiguous.*Refusing to delete all of them; nothing was changed/, ); }); test("assertUnambiguousMatch: exactly one match does NOT throw", () => { assert.doesNotThrow(() => - assertUnambiguousMatch("patch_node", "replace", 1, "n1", "p1"), + assertUnambiguousMatch("patchNode", "replace", 1, "n1", "p1"), ); assert.doesNotThrow(() => - assertUnambiguousMatch("delete_node", "delete", 1, "n1", "p1"), + assertUnambiguousMatch("deleteNode", "delete", 1, "n1", "p1"), ); }); diff --git a/packages/mcp/test/unit/table-ops.test.mjs b/packages/mcp/test/unit/table-ops.test.mjs index 280845c6..3d42bcac 100644 --- a/packages/mcp/test/unit/table-ops.test.mjs +++ b/packages/mcp/test/unit/table-ops.test.mjs @@ -155,7 +155,7 @@ test("insertTableRow at index 0 inserts before the header and pads to 3 cells", test("insertTableRow throws when given more cells than columns", () => { assert.throws( () => insertTableRow(makeDoc(), "#1", ["a", "b", "c", "d"]), - /table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/, + /tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/, ); }); @@ -232,7 +232,7 @@ test("insertTableRow uses the max column count across all rows (ragged table)", // ...but 4 cells exceed the widest row and throw. assert.throws( () => insertTableRow(makeRaggedDoc(), "#0", ["a", "b", "c", "d"]), - /table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/, + /tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/, ); }); @@ -286,7 +286,7 @@ test("deleteTableRow removes the 3rd row -> rows:2", () => { test("deleteTableRow out-of-range index throws", () => { assert.throws( () => deleteTableRow(makeDoc(), "#1", 9), - /table_delete_row: row index 9 out of range \(table has 3 row\(s\)\)/, + /tableDeleteRow: row index 9 out of range \(table has 3 row\(s\)\)/, ); }); @@ -329,10 +329,10 @@ test("updateTableCell sets cell [1,1] to 'Z' and preserves the paragraph id", () test("updateTableCell out-of-range row/col throws", () => { assert.throws( () => updateTableCell(makeDoc(), "#1", 9, 0, "x"), - /table_update_cell: cell \[9,0\] out of range/, + /tableUpdateCell: cell \[9,0\] out of range/, ); assert.throws( () => updateTableCell(makeDoc(), "#1", 0, 9, "x"), - /table_update_cell: cell \[0,9\] out of range/, + /tableUpdateCell: cell \[0,9\] out of range/, ); }); diff --git a/packages/mcp/test/unit/tool-inventory.test.mjs b/packages/mcp/test/unit/tool-inventory.test.mjs index 3e518228..a3eadf16 100644 --- a/packages/mcp/test/unit/tool-inventory.test.mjs +++ b/packages/mcp/test/unit/tool-inventory.test.mjs @@ -35,13 +35,13 @@ function registeredToolNames() { const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8"); const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8"); const names = new Set(); - for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) { + for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-zA-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_]+)['"]/); + const nameMatch = block.match(/mcpName:\s*['"]([a-zA-Z0-9_]+)['"]/); if (!nameMatch) continue; if (/inAppOnly:\s*true/.test(block)) continue; names.add(nameMatch[1]); @@ -82,27 +82,28 @@ 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", () => { +// #411: the external MCP surface gains updatePageMarkdown and LOSES +// importPageMarkdown (now inAppOnly). The in-app agent still keeps +// importPageMarkdown — asserted in the server-side contract spec. (#412 renamed +// both public MCP tool names to camelCase.) +test("updatePageMarkdown is on the MCP surface; importPageMarkdown 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", + inventory.has("updatePageMarkdown"), + "updatePageMarkdown 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)", + !inventory.has("importPageMarkdown"), + "importPageMarkdown 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", + !ROUTING_PROSE.includes("importPageMarkdown"), + "ROUTING_PROSE still mentions the removed importPageMarkdown", ); assert.ok( - ROUTING_PROSE.includes("update_page_markdown"), - "ROUTING_PROSE should mention update_page_markdown", + ROUTING_PROSE.includes("updatePageMarkdown"), + "ROUTING_PROSE should mention updatePageMarkdown", ); }); diff --git a/packages/mcp/test/unit/tool-specs.test.mjs b/packages/mcp/test/unit/tool-specs.test.mjs index 7c06d6ce..b3b84bd3 100644 --- a/packages/mcp/test/unit/tool-specs.test.mjs +++ b/packages/mcp/test/unit/tool-specs.test.mjs @@ -22,10 +22,13 @@ test("every spec exposes mcpName + inAppKey, and the key matches inAppKey", () = } }); -test("mcpName uses snake_case and inAppKey uses camelCase", () => { +// Since issue #412 the external MCP name equals the in-app key: both are the +// same camelCase identifier (mcpName === inAppKey). +test("mcpName and inAppKey are the same camelCase identifier", () => { for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) { - assert.match(spec.mcpName, /^[a-z0-9]+(_[a-z0-9]+)*$/, `${key}: mcpName not snake_case`); + assert.match(spec.mcpName, /^[a-z][a-zA-Z0-9]*$/, `${key}: mcpName not camelCase`); assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`); + assert.equal(spec.mcpName, spec.inAppKey, `${key}: mcpName must equal inAppKey`); } }); @@ -59,7 +62,7 @@ test("buildShape (when present) returns a usable ZodRawShape with a real zod", ( test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => { const spec = SHARED_TOOL_SPECS.editPageText; - assert.equal(spec.mcpName, "edit_page_text"); + assert.equal(spec.mcpName, "editPageText"); const shape = spec.buildShape(z); assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]); // A valid edits batch parses. @@ -86,7 +89,7 @@ test("getNode builder produces exactly { pageId, nodeId }", () => { test("patchNode spec exists, merges BOTH descriptions, builds { pageId, nodeId, node }", () => { const spec = SHARED_TOOL_SPECS.patchNode; assert.ok(spec, "patchNode spec missing"); - assert.equal(spec.mcpName, "patch_node"); + assert.equal(spec.mcpName, "patchNode"); assert.equal(spec.inAppKey, "patchNode"); // The canonical description must carry the key guidance from BOTH originals: @@ -114,7 +117,7 @@ test("patchNode spec exists, merges BOTH descriptions, builds { pageId, nodeId, test("insertNode spec exists, merges BOTH descriptions, builds the full anchor shape", () => { const spec = SHARED_TOOL_SPECS.insertNode; assert.ok(spec, "insertNode spec missing"); - assert.equal(spec.mcpName, "insert_node"); + assert.equal(spec.mcpName, "insertNode"); assert.equal(spec.inAppKey, "insertNode"); // Canonical description must keep BOTH sides' nuance: @@ -150,7 +153,7 @@ test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => 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.mcpName, "updatePageMarkdown"); assert.equal(spec.inAppKey, "updatePageMarkdown"); // Registered on BOTH hosts (a shared spec, no inAppOnly/mcpOnly flag). assert.notEqual(spec.inAppOnly, true); @@ -168,13 +171,13 @@ test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId assert.match(spec.description, /\^\[/); }); -// #411: import_page_markdown is dropped from the EXTERNAL MCP surface but stays +// #411: importPageMarkdown 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.mcpName, "importPageMarkdown"); assert.equal(spec.inAppKey, "importPageMarkdown"); }); diff --git a/packages/mcp/test/unit/tool-timing.test.mjs b/packages/mcp/test/unit/tool-timing.test.mjs index 543bb04f..afbd4bb4 100644 --- a/packages/mcp/test/unit/tool-timing.test.mjs +++ b/packages/mcp/test/unit/tool-timing.test.mjs @@ -13,7 +13,7 @@ test("times a tool and preserves the handler's return value", async () => { const onMetric = (name, value, labels) => calls.push({ name, value, labels }); const handler = async (arg) => ({ ok: true, echo: arg }); - const wrapped = timeToolHandler("get_page", handler, onMetric); + const wrapped = timeToolHandler("getPage", handler, onMetric); const result = await wrapped("hello"); // Return value passes through untouched. @@ -22,7 +22,7 @@ test("times a tool and preserves the handler's return value", async () => { // Exactly one sample, correct name/labels, numeric non-negative duration. assert.equal(calls.length, 1); assert.equal(calls[0].name, "mcp_tool_duration_seconds"); - assert.deepEqual(calls[0].labels, { tool: "get_page" }); + assert.deepEqual(calls[0].labels, { tool: "getPage" }); assert.equal(typeof calls[0].value, "number"); assert.ok(calls[0].value >= 0, "duration must be non-negative seconds"); }); diff --git a/packages/prosemirror-markdown/src/lib/docmost-schema.ts b/packages/prosemirror-markdown/src/lib/docmost-schema.ts index a1f4d734..9b7dd006 100644 --- a/packages/prosemirror-markdown/src/lib/docmost-schema.ts +++ b/packages/prosemirror-markdown/src/lib/docmost-schema.ts @@ -334,7 +334,7 @@ const DocmostAttributes = Extension.create({ * Docmost inline comment mark. Anchors a comment thread to a text range via * `commentId`. Without it, any document containing comment highlights fails to * round-trip through the schema ("There is no mark type comment in this schema"), - * which breaks update_page_json and edit_page_text on every commented page. + * which breaks updatePageJson and editPageText on every commented page. * Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved). */ const Comment = Mark.create({ diff --git a/packages/prosemirror-markdown/src/lib/node-ops.ts b/packages/prosemirror-markdown/src/lib/node-ops.ts index 50d1d69f..eadc5db0 100644 --- a/packages/prosemirror-markdown/src/lib/node-ops.ts +++ b/packages/prosemirror-markdown/src/lib/node-ops.ts @@ -263,7 +263,7 @@ export function deleteNodeById( * changed. No-op for the unambiguous single-match case. */ export function assertUnambiguousMatch( - op: "patch_node" | "delete_node", + op: "patchNode" | "deleteNode", verb: "replace" | "delete", count: number, nodeId: string, @@ -631,7 +631,7 @@ export function insertNodeRelative( // top level — appending one would produce invalid nesting. if (isStructural) { throw new Error( - `insert_node: cannot append a ${node.type} at the top level; use ` + + `insertNode: cannot append a ${node.type} at the top level; use ` + `position before/after with an anchor inside the target table`, ); } @@ -666,7 +666,7 @@ export function insertNodeRelative( if (containerIdx === -1) { throw new Error( - `insert_node: cannot insert a ${node.type} here — the anchor is not ` + + `insertNode: cannot insert a ${node.type} here — the anchor is not ` + `inside a ${containerType}. Anchor on a cell's text or a block id ` + `that lives inside the target table.`, ); @@ -854,7 +854,7 @@ function makeCellParagraph(id: string, text: string): any { * width. * - `cells`: `string[][]` of each cell's `blockPlainText`. * - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null), - * so callers can `patch_node` a cell for rich-formatted edits. + * so callers can `patchNode` a cell for rich-formatted edits. * - `path`: index path of the table within the doc. */ export function readTable( @@ -884,7 +884,7 @@ export function readTable( const rowIds: (string | null)[] = []; for (const cellNode of cellNodes) { rowText.push(blockPlainText(cellNode)); - // The cell's first paragraph carries the id used for patch_node. + // The cell's first paragraph carries the id used for patchNode. const firstPara = Array.isArray(cellNode?.content) ? cellNode.content[0] : undefined; @@ -940,7 +940,7 @@ export function insertTableRow( if (Array.isArray(cells) && cells.length > colCount) { throw new Error( - `table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`, + `tableInsertRow: got ${cells.length} cell(s) but the table has ${colCount} column(s)`, ); } @@ -1006,12 +1006,12 @@ export function deleteTableRow( if (!Number.isInteger(index) || index < 0 || index >= rows) { throw new Error( - `table_delete_row: row index ${index} out of range (table has ${rows} row(s))`, + `tableDeleteRow: row index ${index} out of range (table has ${rows} row(s))`, ); } if (rows <= 1) { throw new Error( - "table_delete_row: refusing to delete the only row of the table", + "tableDeleteRow: refusing to delete the only row of the table", ); } @@ -1055,7 +1055,7 @@ export function updateTableCell( col < 0 || col >= cols ) { - throw new Error(`table_update_cell: cell [${row},${col}] out of range`); + throw new Error(`tableUpdateCell: cell [${row},${col}] out of range`); } const cellNode = rowNode.content[col]; diff --git a/packages/prosemirror-markdown/src/lib/parse-node-arg.ts b/packages/prosemirror-markdown/src/lib/parse-node-arg.ts index 5ba764fb..cbc4c085 100644 --- a/packages/prosemirror-markdown/src/lib/parse-node-arg.ts +++ b/packages/prosemirror-markdown/src/lib/parse-node-arg.ts @@ -1,7 +1,7 @@ // The model sometimes serializes a ProseMirror node arg as a JSON string // instead of an object. Normalize: parse a string to an object (throwing on -// invalid JSON), pass an object through unchanged. Shared by patch_node / -// insert_node (and the analogous update_page_json content parsing). +// invalid JSON), pass an object through unchanged. Shared by patchNode / +// insertNode (and the analogous updatePageJson content parsing). // // This lives in the converter package (#414) so BOTH consumers import the ONE // copy: `@docmost/mcp` (ESM) and the CommonJS server app. The server cannot