From 8c74659d9111ae2c88e6ec6db86b2174c3b19718 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 08:59:40 +0300 Subject: [PATCH] =?UTF-8?q?refactor(tools):=20execute-=D0=BC=D0=B0=D0=BF?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D0=B3=20=D0=B2=20SHARED=5FTOOL=5FSPECS=20+?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D1=80=D0=BE=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BE=D0=B8=D1=85=20=D1=85=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=20(#445)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ядро Фазы 1б. Реестр (#294) шарил только метаданные (имя/схема/описание/tier), но НЕ execute-логику — у каждого shared-тула было ДВА рукописных execute-тела с копией маппинга аргументов (MCP registerShared в index.ts; in-app sharedTool в ai-chat-tools.service, зеркалящий MCP-транспорт вручную). Корень повторяющихся parity-багов (f46d89ea drawio, f8d26420 stashPage, fc9088b7 node-args): добавление одного тула = 7-9 согласованных ручных правок в двух пакетах. - SharedToolSpec расширен: канонический execute(client, args) (чистый JS — свободно пересекает zod-мажорную границу v3/v4) + оверрайды mcpExecute/inAppExecute/mcpOnly/inAppOnly для ОСОЗНАННЫХ per-layer различий. client: DocmostClientLike (Pick из #446). Канон возвращает СЫРЬЁ, каждый хост накладывает свой конверт (MCP jsonContent, in-app как есть); override владеет результатом хоста целиком. - Оба хоста → циклы по Object.values(SHARED_TOOL_SPECS): index.ts registerShared 39→0 (цикл), ai-chat-tools.service sharedTool ~40→1 (цикл). Добавление спека автоматически регистрирует тул в ОБОИХ хостах — сценарий PR #434 невозможен по построению. - Осознанные различия через overrides (ни одно не сплющено к одному хосту): оба mcpExecute+inAppExecute — createPage/movePage/deletePage/ exportPageMarkdown/createComment (guardrails, конверты, проекции, тексты ошибок); execute+inAppExecute — getPage/renamePage/resolveComment; execute+mcpExecute — stashPage (resource_link+structuredContent), checkNewComments (since-guard только на MCP). - Оставлены inline (по делу): update_comment/delete_comment (MCP-only, in-app не даёт хард-правку/удаление комментов), search/transformPage (per-transport дивергенция — hybrid RRF / без deleteComments), table_get (noun-vs-verb naming clash — уедет после camelCase #412), getCurrentPage/updatePageContent/ listSidebarPages/getComment/getPageHistory (in-app-only, per-request state). - Guard-тесты (contract-parity, phantom-catalog) сохранены — теперь инварианты, не «последняя линия». Внутреннее ревью: APPROVE, построчная BEFORE/AFTER-сверка по каждому shared-тулу на обоих хостах — ни одного изменённого per-host поведения (порядок/дефолты аргументов, guard'ы, конверты, проекции сохранены), множества тулов побайтово совпадают (48 in-app, 45 MCP), кросс-zod-граница чистая (нет z. в execute), 611 mcp + 260 server тестов зелёные. Ядро Фазы 1б, стоит на #446 — мержить после. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/tools/ai-chat-tools.service.ts | 430 ++----------- packages/mcp/src/index.ts | 576 ++---------------- packages/mcp/src/tool-specs.ts | 510 ++++++++++++++++ 3 files changed, 590 insertions(+), 926 deletions(-) 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 a6fa4ed5..c40bdbfd 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 @@ -292,6 +292,17 @@ export class AiChatToolsService { execute, }); + // The in-app toolset. It starts with the tools kept INLINE here for a + // documented per-layer reason: an intentional behaviour/schema divergence from + // the standalone MCP surface (searchPages' hybrid RRF, updatePageContent's + // Markdown write, transformPage's guardrailed shorter schema), a + // snake_case/camelCase naming clash the shared registry forbids (getTable vs + // the MCP `table_get`), per-request state the registry loop cannot provide + // (getCurrentPage reads the resolved openedPage; searchPages closes over the + // per-request user/embedding deps), or a tool with no MCP twin + // (listSidebarPages/getComment/getPageHistory). Every SHARED tool is then added + // by the registry loop below (see it), so there is exactly one arg-mapping per + // shared tool and it can never drift from the MCP host again (#445). const tools: Record = { // INTENTIONAL per-transport divergence (not in the shared registry): this // in-app search runs a semantic + keyword hybrid (RRF) with in-process @@ -426,44 +437,8 @@ export class AiChatToolsService { execute: async () => resolveCurrentPageResult(openedPage), }), - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - // The execute body keeps this layer's { title, markdown } projection. - getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => { - // getPage(pageId) -> { data: filterPage(page, markdown), success }. - const result = await client.getPage(pageId); - const data = (result?.data ?? {}) as { - title?: string; - content?: string; - }; - return { - title: data.title ?? '', - markdown: typeof data.content === 'string' ? data.content : '', - }; - }), - // --- WRITE tools (all reversible — history/trash; §6.5 / D3) --- - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - createPage: sharedTool( - sharedToolSpecs.createPage, - async ({ title, content, spaceId, parentPageId }) => { - // createPage(title, content, spaceId, parentPageId?) -> - // { data: filterPage(page, markdown), success }. - const result = await client.createPage( - title, - content ?? '', - spaceId, - parentPageId, - ); - const data = (result?.data ?? {}) as { - id?: string; - slugId?: string; - title?: string; - }; - return { id: data.id ?? data.slugId, title: data.title ?? title }; - }, - ), - updatePageContent: tool({ description: "Replace a page's body with new Markdown content (and optionally its " + @@ -486,121 +461,6 @@ export class AiChatToolsService { }, }), - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - renamePage: sharedTool( - sharedToolSpecs.renamePage, - async ({ pageId, title }) => { - // renamePage(pageId, title) -> { success, pageId, title }. - await client.renamePage(pageId, title); - return { pageId, title }; - }, - ), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - // The shared schema adds the optional `position` field this layer lacked - // before; the execute now forwards it (the client already accepted it). - movePage: sharedTool( - sharedToolSpecs.movePage, - async ({ pageId, parentPageId, position }) => { - // movePage(pageId, parentPageId, position?) -> raw move response. - await client.movePage(pageId, parentPageId ?? null, position); - return { pageId, parentPageId: parentPageId ?? null, moved: true }; - }, - ), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - // GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so - // permanentlyDelete/forceDelete are never part of the input and can never - // be forwarded — the agent physically cannot permanently delete a page. - deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => { - // deletePage(pageId) hits POST /pages/delete with { pageId } only, - // which is the soft-delete (trash) path on the server. - await client.deletePage(pageId); - return { pageId, trashed: true }; - }), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - // This layer keeps only its own execute-side guards (require a selection - // for a top-level comment; reject suggestedText on a reply / without a - // selection) — the schema+description are shared. - createComment: sharedTool( - sharedToolSpecs.createComment, - async ({ - pageId, - content, - selection, - parentCommentId, - suggestedText, - }) => { - // createComment(pageId, content, type, selection?, parentCommentId?, - // suggestedText?). Top-level comments are inline and must carry a - // selection to anchor on; replies inherit the parent's anchor (no - // selection). Throwing here surfaces a tool error to the model (Vercel - // `ai` SDK) so the agent retries with a better selection — do not - // catch/suppress it. - if (!parentCommentId && (!selection || !selection.trim())) { - throw new Error( - "createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.", - ); - } - if (suggestedText !== undefined) { - if (parentCommentId) { - throw new Error( - "createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.", - ); - } - if (!selection || !selection.trim()) { - throw new Error( - "createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.", - ); - } - } - const result = await client.createComment( - pageId, - content, - 'inline', - selection, - parentCommentId, - suggestedText, - ); - const data = (result?.data ?? {}) as { id?: string }; - return { commentId: data.id, pageId }; - }, - ), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - resolveComment: sharedTool( - sharedToolSpecs.resolveComment, - async ({ commentId, resolved }) => { - // resolveComment(commentId, resolved) -> { success, commentId, resolved }. - await client.resolveComment(commentId, resolved); - return { commentId, resolved }; - }, - ), - - // --- READ tools (added) --- - - getWorkspace: sharedTool( - sharedToolSpecs.getWorkspace, - async () => await client.getWorkspace(), - ), - - listSpaces: sharedTool( - sharedToolSpecs.listSpaces, - async () => await client.getSpaces(), - ), - - // INTENTIONAL per-transport divergence (not shared): keeps the `tree:true` - // hierarchy mode but is worded for the in-app agent; the standalone MCP - // `list_pages` carries its own wording. Kept per-layer so each side tunes - // its own guidance. - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - listPages: sharedTool( - sharedToolSpecs.listPages, - async ({ spaceId, limit, tree }) => - await client.listPages(spaceId, limit, tree), - ), - listSidebarPages: tool({ description: 'List sidebar pages for a space. With no pageId, returns the ' + @@ -619,31 +479,6 @@ export class AiChatToolsService { await client.listSidebarPages(spaceId, pageId), }), - getOutline: sharedTool( - sharedToolSpecs.getOutline, - async ({ pageId }) => await client.getOutline(pageId), - ), - - getPageJson: sharedTool( - sharedToolSpecs.getPageJson, - async ({ pageId }) => await client.getPageJson(pageId), - ), - - getNode: sharedTool( - sharedToolSpecs.getNode, - async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId), - ), - - searchInPage: sharedTool( - sharedToolSpecs.searchInPage, - async ({ pageId, query, regex, caseSensitive, limit }) => - await client.searchInPage(pageId, query, { - regex, - caseSensitive, - limit, - }), - ), - // 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 @@ -666,13 +501,6 @@ export class AiChatToolsService { await client.getTable(pageId, table), }), - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - listComments: sharedTool( - sharedToolSpecs.listComments, - async ({ pageId, includeResolved }) => - await client.listComments(pageId, includeResolved), - ), - getComment: tool({ description: 'Fetch a single comment by id (content as Markdown).', inputSchema: modelFriendlyInput({ @@ -681,24 +509,6 @@ export class AiChatToolsService { execute: async ({ commentId }) => await client.getComment(commentId), }), - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - checkNewComments: sharedTool( - sharedToolSpecs.checkNewComments, - async ({ spaceId, since, parentPageId }) => - await client.checkNewComments(spaceId, since, parentPageId), - ), - - listShares: sharedTool( - sharedToolSpecs.listShares, - async () => await client.listShares(), - ), - - listPageHistory: sharedTool( - sharedToolSpecs.listPageHistory, - async ({ pageId, cursor }) => - await client.listPageHistory(pageId, cursor), - ), - getPageHistory: tool({ description: 'Fetch a single page-history version including its lossless ' + @@ -710,203 +520,8 @@ export class AiChatToolsService { await client.getPageHistory(historyId), }), - diffPageVersions: sharedTool( - sharedToolSpecs.diffPageVersions, - async ({ pageId, from, to }) => - await client.diffPageVersions(pageId, from, to), - ), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - exportPageMarkdown: sharedTool( - sharedToolSpecs.exportPageMarkdown, - async ({ pageId }) => { - const markdown = await client.exportPageMarkdown(pageId); - return { markdown }; - }, - ), - // --- WRITE tools (added; reversible via page history/trash) --- - editPageText: sharedTool( - sharedToolSpecs.editPageText, - async ({ pageId, edits }) => await client.editPageText(pageId, edits), - ), - - // Returns ONLY the short link object — never the document body — so a - // large page can be handed to an external consumer without bloating - // context. - stashPage: sharedTool( - sharedToolSpecs.stashPage, - async ({ pageId }) => await client.stashPage(pageId), - ), - - // Schema + description from the shared registry (identical across both - // transports). The execute body keeps its OWN parseNodeArg normalization: - // the model sometimes serializes the node as a JSON string, and we parse it - // before the client's typeof-object guard rejects it (parity with the - // standalone MCP server, index.ts patch_node). - patchNode: sharedTool( - sharedToolSpecs.patchNode, - async ({ pageId, nodeId, node }) => { - const parsedNode = parseNodeArg(node); - return await client.patchNode(pageId, nodeId, parsedNode); - }, - ), - - // Shared registry schema + description; execute retains parseNodeArg on the - // incoming node (parity with the standalone MCP server, index.ts - // insert_node). - insertNode: sharedTool( - sharedToolSpecs.insertNode, - async ({ pageId, node, position, anchorNodeId, anchorText }) => { - const parsedNode = parseNodeArg(node); - return await client.insertNode(pageId, parsedNode, { - position, - anchorNodeId, - anchorText, - }); - }, - ), - - deleteNode: sharedTool( - sharedToolSpecs.deleteNode, - async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId), - ), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - // The execute body keeps this layer's content normalization (parity with - // the standalone MCP server, index.ts update_page_json). - updatePageJson: sharedTool( - sharedToolSpecs.updatePageJson, - async ({ pageId, content, title }) => { - // undefined/null pass through as undefined (title-only / no-op); any - // string is JSON.parsed (so an empty string "" throws, matching the - // MCP server); an object is passed through unchanged. - let doc; - if (content === undefined || content === null) { - doc = undefined; - } else { - // String -> JSON.parse (throwing on invalid); object passes through. - doc = parseNodeArg(content, 'content was a string but not valid JSON'); - } - return await client.updatePageJson(pageId, doc, title); - }, - ), - - // Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410). - // Promoted from MCP-only so the in-app agent can attach a REAL footnote to - // already-written text instead of leaving a literal `^[...]` string. - insertFootnote: sharedTool( - sharedToolSpecs.insertFootnote, - async ({ pageId, anchorText, text }) => - await client.insertFootnote(pageId, anchorText, text), - ), - - // Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410). - // The schema field is `imageUrl`; the client method takes it positionally. - insertImage: sharedTool( - sharedToolSpecs.insertImage, - async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => - await client.insertImage(pageId, imageUrl, { - align, - alt, - replaceText, - afterText, - }), - ), - - // Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410). - replaceImage: sharedTool( - sharedToolSpecs.replaceImage, - async ({ pageId, attachmentId, imageUrl, align, alt }) => - await client.replaceImage(pageId, attachmentId, imageUrl, { - align, - alt, - }), - ), - - // Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423). - // meta.hash in the result is the baseHash drawioUpdate requires. - drawioGet: sharedTool( - sharedToolSpecs.drawioGet, - async ({ pageId, node, format }) => - await client.drawioGet(pageId, node, format ?? 'xml'), - ), - - // Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423). - // The flat schema fields are regrouped into the client's `where` object. - drawioCreate: sharedTool( - sharedToolSpecs.drawioCreate, - async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => - await client.drawioCreate( - pageId, - { position, anchorNodeId, anchorText }, - xml, - title, - ), - ), - - // Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423). - // baseHash is the optimistic lock: mismatch => structured conflict error. - drawioUpdate: sharedTool( - sharedToolSpecs.drawioUpdate, - async ({ pageId, node, xml, baseHash }) => - await client.drawioUpdate(pageId, node, xml, baseHash), - ), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - // The table reference parameter was unified to `table` (was `tableRef`). - tableInsertRow: sharedTool( - sharedToolSpecs.tableInsertRow, - async ({ pageId, table, cells, index }) => - await client.tableInsertRow(pageId, table, cells, index), - ), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - tableDeleteRow: sharedTool( - sharedToolSpecs.tableDeleteRow, - async ({ pageId, table, index }) => - await client.tableDeleteRow(pageId, table, index), - ), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - tableUpdateCell: sharedTool( - sharedToolSpecs.tableUpdateCell, - async ({ pageId, table, row, col, text }) => - await client.tableUpdateCell(pageId, table, row, col, text), - ), - - copyPageContent: sharedTool( - sharedToolSpecs.copyPageContent, - async ({ sourcePageId, targetPageId }) => - await client.copyPageContent(sourcePageId, targetPageId), - ), - - importPageMarkdown: sharedTool( - sharedToolSpecs.importPageMarkdown, - async ({ pageId, markdown }) => - await client.importPageMarkdown(pageId, markdown), - ), - - // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). - // Both layers already carried the security-confirmation framing, so there - // was no real divergence to preserve — only wording drift. - sharePage: sharedTool( - sharedToolSpecs.sharePage, - async ({ pageId, searchIndexing }) => - await client.sharePage(pageId, searchIndexing), - ), - - unsharePage: sharedTool( - sharedToolSpecs.unsharePage, - async ({ pageId }) => await client.unsharePage(pageId), - ), - - restorePageVersion: sharedTool( - sharedToolSpecs.restorePageVersion, - async ({ historyId }) => await client.restorePageVersion(historyId), - ), - // 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 @@ -935,6 +550,29 @@ export class AiChatToolsService { }), }; + // Add EVERY shared tool from the zod-agnostic registry in one loop (#445). + // The spec owns the canonical arg->client mapping; this host only decides + // 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); + // - use `inAppExecute` when the spec declares a DELIBERATE per-layer + // difference (a projected result shape, a different guardrail message); + // - otherwise use the canonical `execute` (raw client result, identical to + // the MCP host's before it wraps it as JSON). + // The execute receives the AI-SDK-validated, type-erased input; the spec reads + // the same fields its buildShape declares. This is the SINGLE place the in-app + // arg mapping lives — it can no longer silently drift from the MCP host. + for (const spec of Object.values(sharedToolSpecs)) { + if (spec.mcpOnly) continue; + const run = spec.inAppExecute ?? spec.execute; + if (!run) continue; // defensive: a shared spec always carries one of them. + tools[spec.inAppKey] = sharedTool( + spec, + (async (args) => + run(client, args as Record)) as Tool['execute'], + ); + } + // Passive "new comments: N" signal (#417). PER-TURN state (forUser runs once // per turn), so the watermark starts now and only comments a human leaves // WHILE this turn runs are signalled — exactly the mid-turn loop; between-turn diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 1f6fa7ab..6b850faf 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -202,10 +202,10 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer { { instructions: SERVER_INSTRUCTIONS }, ); - // Single choke point for MCP tool timing. Both `registerShared` (below) and - // the inline `server.registerTool(...)` calls funnel through this one method, - // so monkeypatching it HERE — before any tool is registered and before - // `registerShared` captures a reference to it — times every tool with no + // Single choke point for MCP tool timing. Both `registerSharedFromSpec` (below) + // and the inline `server.registerTool(...)` calls funnel through this one + // method, so monkeypatching it HERE — before any tool is registered and before + // the registry loop captures a reference to it — times every tool with no // per-tool boilerplate. The wrapped handler records wall-clock duration and, // in a `finally`, feeds the host's dependency-neutral sink // `config.onMetric("mcp_tool_duration_seconds", seconds, { tool })`. The tool @@ -261,92 +261,56 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer { return originalRegisterTool(...args.slice(0, -1), signalledHandler); }; - // Register a tool from the shared, zod-agnostic spec registry. The spec owns - // the canonical name + model-facing description + (optional) schema builder; - // only the execute body is supplied per call. buildShape is invoked with THIS - // package's zod (v3); the in-app layer passes its own zod (v4). - // - // The spec's schema builder returns a plain ZodRawShape (Record in the shared module since it must stay zod-agnostic), so the - // McpServer.registerTool overloads cannot infer the execute arg's shape from - // it. We type `execute` loosely and cast the call through `any`; runtime - // behaviour is unchanged — each execute body destructures the same fields the - // builder declares. - const registerShared = ( - spec: SharedToolSpec, - execute: (args: any) => Promise<{ content: { type: "text"; text: string }[] }>, - ) => - (server.registerTool as any)( + // Register EVERY shared tool from the zod-agnostic registry in one loop (#445). + // The spec owns the canonical name + description + (optional) schema builder AND + // the canonical execute mapping; the host only supplies the RESULT ENVELOPE. For + // each spec: + // - skip `inAppOnly` specs (they belong to the in-app host only); + // - if the spec has an `mcpExecute` override (a deliberate per-layer + // difference — a guardrail, an omitted param, or a non-JSON envelope like a + // resource_link/bare success line), the override OWNS the full MCP content + // result and is used VERBATIM; + // - otherwise the canonical `execute` returns RAW data and this host wraps it + // in the standard JSON text envelope (jsonContent), exactly as the old inline + // bodies did. + // buildShape is invoked with THIS package's zod (v3); the in-app layer passes its + // own zod (v4). The registry's execute returns `unknown` (it is zod-agnostic), so + // the wrapping is typed loosely and cast — runtime behaviour is unchanged. + const registerSharedFromSpec = (spec: SharedToolSpec) => { + if (spec.inAppOnly) return; + const handler = async (args: any) => { + if (spec.mcpExecute) { + // The override owns the full MCP result envelope (not re-wrapped). + return (await spec.mcpExecute(docmostClient, args)) as { + content: { type: "text"; text: string }[]; + }; + } + // Canonical execute returns raw data; wrap it as JSON text content. + const raw = await spec.execute!(docmostClient, args); + return jsonContent(raw); + }; + return (server.registerTool as any)( spec.mcpName, spec.buildShape ? { description: spec.description, inputSchema: spec.buildShape(z) } : { description: spec.description }, - execute, + handler, ); + }; - // Tool: get_workspace - registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => { - const workspace = await docmostClient.getWorkspace(); - return jsonContent(workspace); - }); + for (const spec of Object.values(SHARED_TOOL_SPECS)) { + registerSharedFromSpec(spec as SharedToolSpec); + } - // Tool: list_spaces - registerShared(SHARED_TOOL_SPECS.listSpaces, async () => { - const spaces = await docmostClient.getSpaces(); - return jsonContent(spaces); - }); + // --- 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 + // edit/delete tool). -// Tool: list_pages -// INTENTIONAL per-transport divergence (not in the shared registry): this -// transport exposes a `tree:true` mode that returns the full nested hierarchy; -// the in-app copy keeps the same tree option but is worded for the in-app agent. -// Kept per-layer so each side can tune its own guidance. -// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This -// transport keeps applying its own defaults (limit=50, tree=false) in execute. -registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => { - const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false); - return jsonContent(result); -}); - -// Tool: get_page -// Schema + description now live in the shared registry (#294). -registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => { - const page = await docmostClient.getPage(pageId); - return jsonContent(page); -}); - -// Tool: get_page_json -registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => { - const page = await docmostClient.getPageJson(pageId); - return jsonContent(page); -}); - -// Tool: get_outline -registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => { - const result = await docmostClient.getOutline(pageId); - return jsonContent(result); -}); - -// Tool: get_node -registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => { - const result = await docmostClient.getNode(pageId, nodeId); - return jsonContent(result); -}); - -// Tool: search_in_page -registerShared( - SHARED_TOOL_SPECS.searchInPage, - async ({ pageId, query, regex, caseSensitive, limit }) => { - const result = await docmostClient.searchInPage(pageId, query, { - regex, - caseSensitive, - limit, - }); - return jsonContent(result); - }, -); - -// Tool: table_get + // Tool: table_get // NOT in the shared registry: the MCP tool name `table_get` 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). @@ -372,383 +336,6 @@ server.registerTool( }, ); -// Tool: table_insert_row -// Schema + description now live in the shared registry (#294); the `table` -// parameter name is the canonical one (the in-app layer was unified to it). -registerShared( - SHARED_TOOL_SPECS.tableInsertRow, - async ({ pageId, table, cells, index }) => { - const result = await docmostClient.tableInsertRow( - pageId, - table, - cells, - index, - ); - return jsonContent(result); - }, -); - -// Tool: table_delete_row -// Schema + description now live in the shared registry (#294). -registerShared( - SHARED_TOOL_SPECS.tableDeleteRow, - async ({ pageId, table, index }) => { - const result = await docmostClient.tableDeleteRow(pageId, table, index); - return jsonContent(result); - }, -); - -// Tool: table_update_cell -// Schema + description now live in the shared registry (#294). -registerShared( - SHARED_TOOL_SPECS.tableUpdateCell, - async ({ pageId, table, row, col, text }) => { - const result = await docmostClient.tableUpdateCell( - pageId, - table, - row, - col, - text, - ); - return jsonContent(result); - }, -); - -// Tool: create_page -// Schema + description now live in the shared registry (#294). -registerShared( - SHARED_TOOL_SPECS.createPage, - async ({ title, content, spaceId, parentPageId }) => { - const result = await docmostClient.createPage( - title, - content, - spaceId, - parentPageId, - ); - return jsonContent(result); - }, -); - -// Tool: update_page_json -// Schema + description now live in the shared registry (#294). The execute body -// keeps this transport's content normalization (parse a JSON-string content, -// pass undefined/null through for a title-only/no-op update). -registerShared( - SHARED_TOOL_SPECS.updatePageJson, - async ({ pageId, content, title }) => { - // Only parse/validate the document when it was actually supplied; when it - // is omitted, pass it straight through so the client performs a title-only - // (or no-op) update. - let doc; - if (content === undefined || content === null) { - doc = undefined; - } else { - // String -> JSON.parse (throwing on invalid); object passes through. - doc = parseNodeArg(content, "content was a string but not valid JSON"); - } - const result = await docmostClient.updatePageJson(pageId, doc, title); - return jsonContent(result); - }, -); - -// Tool: export_page_markdown -// Schema + description now live in the shared registry (#294). -registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => { - const md = await docmostClient.exportPageMarkdown(pageId); - return { content: [{ type: "text" as const, text: md }] }; -}); - -// Tool: import_page_markdown -registerShared( - SHARED_TOOL_SPECS.importPageMarkdown, - async ({ pageId, markdown }) => { - const res = await docmostClient.importPageMarkdown(pageId, markdown); - return jsonContent(res); - }, -); - -// Tool: copy_page_content -registerShared( - SHARED_TOOL_SPECS.copyPageContent, - async ({ sourcePageId, targetPageId }) => { - const result = await docmostClient.copyPageContent( - sourcePageId, - targetPageId, - ); - return jsonContent(result); - }, -); - -// Tool: rename_page -// Schema + description now live in the shared registry (#294). -registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => { - const result = await docmostClient.renamePage(pageId, title); - return jsonContent(result); -}); - -// Tool: edit_page_text -registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => { - const result = await docmostClient.editPageText(pageId, edits); - return jsonContent(result); -}); - -// Tool: stash_page — returns a resource_link (NOT embedded text) so the doc -// body never enters the model context. Registered directly (not via -// registerShared) because that helper only emits text content. Also returns -// `structuredContent` carrying the full documented `{uri, sha256, size, images}` -// shape alongside the resource_link, so MCP clients receive the blob's sha256 -// (its ETag, for integrity) and mirror counts, not just the link. -server.registerTool( - SHARED_TOOL_SPECS.stashPage.mcpName, - { - description: SHARED_TOOL_SPECS.stashPage.description, - inputSchema: SHARED_TOOL_SPECS.stashPage.buildShape!(z), - }, - async ({ pageId }: { pageId: string }) => { - const result = await docmostClient.stashPage(pageId); - return { - content: [ - { - type: "resource_link" as const, - uri: result.uri, - name: "page.json", - mimeType: "application/json", - size: result.size, - }, - ], - // Mirror the full documented result shape ({ uri, size, sha256, images }) - // as structuredContent so MCP clients get the blob's sha256 (its ETag, for - // integrity) and the mirror counts, not just the resource_link. - structuredContent: { - uri: result.uri, - sha256: result.sha256, - size: result.size, - images: result.images, - }, - }; - }, -); - -// Tool: patch_node — schema + description from the shared registry (identical -// across both transports). The execute body keeps its own parseNodeArg -// normalization (the model sometimes serializes `node` as a JSON string). -registerShared( - SHARED_TOOL_SPECS.patchNode, - async ({ pageId, nodeId, node }) => { - const parsedNode = parseNodeArg(node); - const result = await docmostClient.patchNode(pageId, nodeId, parsedNode); - return jsonContent(result); - }, -); - -// Tool: insert_node — schema + description from the shared registry. As with -// patch_node, the execute body retains parseNodeArg on the incoming node. -registerShared( - SHARED_TOOL_SPECS.insertNode, - async ({ pageId, node, position, anchorNodeId, anchorText }) => { - const parsedNode = parseNodeArg(node); - const result = await docmostClient.insertNode(pageId, parsedNode, { - position, - anchorNodeId, - anchorText, - }); - return jsonContent(result); - }, -); - -// Tool: delete_node -registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => { - const result = await docmostClient.deleteNode(pageId, nodeId); - return jsonContent(result); -}); - -// Tool: insert_image -// Schema + description now live in the shared registry (#410) so BOTH this MCP -// server and the in-app AI-chat agent expose it. The execute body is unchanged. -registerShared( - SHARED_TOOL_SPECS.insertImage, - async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => { - const result = await docmostClient.insertImage(pageId, imageUrl, { - align, - alt, - replaceText, - afterText, - }); - return jsonContent(result); - }, -); - -// Tool: replace_image -// Schema + description now live in the shared registry (#410). -registerShared( - SHARED_TOOL_SPECS.replaceImage, - async ({ pageId, attachmentId, imageUrl, align, alt }) => { - const result = await docmostClient.replaceImage( - pageId, - attachmentId, - imageUrl, - { - align, - alt, - }, - ); - return jsonContent(result); - }, -); - -// Tool: drawio_get — read a draw.io diagram as mxGraph XML (or the raw SVG). -registerShared( - SHARED_TOOL_SPECS.drawioGet, - async ({ pageId, node, format }) => { - const result = await docmostClient.drawioGet(pageId, node, format ?? "xml"); - return jsonContent(result); - }, -); - -// Tool: drawio_create — lint mxGraph XML, build the .drawio.svg, insert a node. -registerShared( - SHARED_TOOL_SPECS.drawioCreate, - async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => { - const result = await docmostClient.drawioCreate( - pageId, - { position, anchorNodeId, anchorText }, - xml, - title, - ); - return jsonContent(result); - }, -); - -// Tool: drawio_update — optimistic-locked full replacement of a diagram. -registerShared( - SHARED_TOOL_SPECS.drawioUpdate, - async ({ pageId, node, xml, baseHash }) => { - const result = await docmostClient.drawioUpdate(pageId, node, xml, baseHash); - return jsonContent(result); - }, -); - -// Tool: share_page -// Schema + description now live in the shared registry (#294). The execute body -// keeps this transport's own `searchIndexing ?? true` default. -registerShared( - SHARED_TOOL_SPECS.sharePage, - async ({ pageId, searchIndexing }) => { - const result = await docmostClient.sharePage(pageId, searchIndexing ?? true); - return jsonContent(result); - }, -); - -// Tool: unshare_page -registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => { - const result = await docmostClient.unsharePage(pageId); - return jsonContent(result); -}); - -// Tool: list_shares -registerShared(SHARED_TOOL_SPECS.listShares, async () => { - const result = await docmostClient.listShares(); - return jsonContent(result); -}); - -// Tool: move_page -// Schema + description now live in the shared registry (#294). The execute body -// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and -// its positive-confirmation check on the move response. -registerShared( - SHARED_TOOL_SPECS.movePage, - async ({ pageId, parentPageId, position }) => { - const finalParentId = - parentPageId === "" || parentPageId === "null" ? null : parentPageId; - - // Cheap cycle guard: a page cannot be moved directly under itself. - // (Deeper descendant-cycle detection is intentionally out of scope.) - if (finalParentId !== null && finalParentId === pageId) { - throw new Error("cannot move a page under itself"); - } - - const result = await docmostClient.movePage( - pageId, - finalParentId || null, - position, - ); - - // Require POSITIVE confirmation: the live /pages/move success shape is - // exactly { success: true, status: 200 }. An empty body, a 204, or any odd - // shape lacking success === true must NOT be reported as a successful move, - // so we surface the raw API result instead of declaring success. - if (!(result && typeof result === "object" && result.success === true)) { - throw new Error( - `Failed to move page ${pageId}: ${JSON.stringify(result)}`, - ); - } - - return jsonContent({ - message: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`, - result, - }); - }, -); - -// Tool: delete_page -// Schema + description now live in the shared registry (#294). The shared schema -// exposes ONLY pageId, so no permanent/force-delete flag can reach the client. -registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => { - await docmostClient.deletePage(pageId); - return { - content: [ - { type: "text" as const, text: `Successfully deleted page ${pageId}` }, - ], - }; -}); - -// --- Comment tools (ported from upstream PR #3 by Max Nikitin) --- - -// Tool: list_comments -registerShared( - SHARED_TOOL_SPECS.listComments, - async ({ pageId, includeResolved }) => { - const comments = await docmostClient.listComments(pageId, includeResolved); - return jsonContent(comments); - }, -); - -// Tool: create_comment -// Schema + description now live in the shared registry (#294). The execute body -// keeps this transport's own guards (require a selection for a top-level -// comment; reject suggestedText on a reply / without a selection). -registerShared( - SHARED_TOOL_SPECS.createComment, - async ({ pageId, content, selection, parentCommentId, suggestedText }) => { - if (!parentCommentId && (!selection || !selection.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.", - ); - } - 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.", - ); - } - if (!selection || !selection.trim()) { - throw new Error( - "create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.", - ); - } - } - const result = await docmostClient.createComment( - pageId, - content, - "inline", - selection, - parentCommentId, - suggestedText, - ); - return jsonContent(result); - }, -); - // Tool: update_comment server.registerTool( "update_comment", @@ -793,39 +380,6 @@ server.registerTool( }, ); -// Tool: resolve_comment -// Schema + description now live in the shared registry (#294). -registerShared( - SHARED_TOOL_SPECS.resolveComment, - async ({ commentId, resolved }) => { - const result = await docmostClient.resolveComment(commentId, resolved); - return jsonContent(result); - }, -); - -// Tool: check_new_comments -// Schema + description now live in the shared registry (#294). The execute body -// keeps this transport's own guard rejecting an unparseable `since` timestamp. -registerShared( - SHARED_TOOL_SPECS.checkNewComments, - async ({ spaceId, since, parentPageId }) => { - // Reject an unparseable timestamp up front: otherwise the comparison - // against NaN silently treats every comment as "not new" and the tool - // returns zero results without signalling the bad input. - if (Number.isNaN(Date.parse(since))) { - throw new Error( - `Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`, - ); - } - const result = await docmostClient.checkNewComments( - spaceId, - since, - parentPageId, - ); - return jsonContent(result); - }, -); - // Tool: search // INTENTIONAL per-transport divergence (not shared): the in-app `searchPages` // runs a semantic + keyword hybrid (RRF) with in-process access control and a @@ -935,43 +489,5 @@ server.registerTool( }, ); -// Tool: insert_footnote -// Schema + description now live in the shared registry (#410) so the in-app -// AI-chat agent exposes it too. The execute body is unchanged. -registerShared( - SHARED_TOOL_SPECS.insertFootnote, - async ({ pageId, anchorText, text }) => { - const result = await docmostClient.insertFootnote(pageId, anchorText, text); - return jsonContent(result); - }, -); - -// Tool: diff_page_versions -registerShared( - SHARED_TOOL_SPECS.diffPageVersions, - async ({ pageId, from, to }) => { - const result = await docmostClient.diffPageVersions(pageId, from, to); - return jsonContent(result); - }, -); - -// Tool: list_page_history -registerShared( - SHARED_TOOL_SPECS.listPageHistory, - async ({ pageId, cursor }) => { - const result = await docmostClient.listPageHistory(pageId, cursor); - return jsonContent(result); - }, -); - -// Tool: restore_page_version -registerShared( - SHARED_TOOL_SPECS.restorePageVersion, - async ({ historyId }) => { - const result = await docmostClient.restorePageVersion(historyId); - return jsonContent(result); - }, -); - return server; } diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index 1516f62e..6ee0e6a7 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -24,6 +24,92 @@ // 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 +// 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; +// putting it in a shared execute here keeps that single normalization in one +// place instead of hand-mirrored per host. Pure — safe across the zod boundary. +import { parseNodeArg } from '@docmost/prosemirror-markdown'; +// Type-only import (erased at compile) of the real client so `DocmostClientLike` +// is DERIVED from it (issue #446), not hand-mirrored. The loosest correct client +// surface both hosts satisfy: the in-app host passes its own DERIVED +// `DocmostClientLike` (a Pick of the same class) and the MCP host passes the real +// `DocmostClient`, so both are structurally assignable to this shared alias. +import type { DocmostClient } from './client.js'; + +/** + * The client surface a shared `execute` may call — the LOOSEST correct type both + * hosts satisfy: a `Pick` of the real `DocmostClient` methods the executes below + * use. DERIVED from the real class (issue #446) so a signature change to any + * consumed method surfaces as a compile error in the execute bodies here, not a + * silent runtime "wrong argument". Kept as a Pick (not the whole class) so the + * standalone MCP host's full `DocmostClient` AND the in-app host's OWN narrower + * `DocmostClientLike` (also a Pick of the same class, a superset of these methods) + * are both structurally assignable to it. `import type` is fully erased, so + * tool-specs.ts pulls in no runtime dependency on the client and still crosses the + * zod-major boundary freely. When you add a client call to an execute below, add + * its method name here too (a compile error will point you at it). + */ +export type DocmostClientLike = Pick< + DocmostClient, + | 'getWorkspace' + | 'getSpaces' + | 'listShares' + | 'listPages' + | 'getPage' + | 'getPageJson' + | 'getOutline' + | 'getNode' + | 'searchInPage' + | 'listComments' + | 'checkNewComments' + | 'listPageHistory' + | 'diffPageVersions' + | 'exportPageMarkdown' + | 'createPage' + | 'renamePage' + | 'movePage' + | 'deletePage' + | 'editPageText' + | 'patchNode' + | 'insertNode' + | 'deleteNode' + | 'updatePageJson' + | 'tableInsertRow' + | 'tableDeleteRow' + | 'tableUpdateCell' + | 'copyPageContent' + | 'importPageMarkdown' + | 'sharePage' + | 'unsharePage' + | 'restorePageVersion' + | 'stashPage' + | 'insertFootnote' + | 'insertImage' + | 'replaceImage' + | 'drawioGet' + | 'drawioCreate' + | 'drawioUpdate' + | 'createComment' + | 'resolveComment' +>; + +/** + * A shared tool `execute`: the single canonical mapping from validated schema + * args to the client call. Plain JS — it crosses the zod-major boundary (v3 in + * the MCP package, v4 on the server) freely, receiving the already-validated, + * type-erased args from whichever host invoked it. It returns RAW data; the host + * applies its own result envelope (the MCP transport wraps it as JSON text + * content, the in-app AI-SDK host returns it as-is). Host-specific overrides + * (`mcpExecute`/`inAppExecute`) return a value the host uses instead of wrapping. + */ +export type SharedToolExecute = ( + client: DocmostClientLike, + args: Record, +) => Promise; + export interface SharedToolSpec { /** snake_case tool name passed to McpServer.registerTool. */ mcpName: string; @@ -54,8 +140,53 @@ export interface SharedToolSpec { * in-app side uses z.object({})). */ buildShape?: (z: ZodLike) => Record; + /** + * Single canonical mapping from validated schema args to the client call, + * shared by BOTH hosts. Returns RAW data — the MCP host wraps it as JSON text + * content (jsonContent), the in-app host returns it as-is. Present on tools + * whose mapping AND raw result are identical across the two layers. When a host + * needs a genuinely different mapping or result shape, it supplies an override + * (below) and the host uses that INSTEAD of `execute`. + */ + execute?: SharedToolExecute; + /** + * MCP-host override for a DELIBERATE per-layer difference (a guardrail, an + * omitted param, or a non-JSON result envelope like a resource_link / a bare + * success line). When present, the MCP host calls this and uses its return + * value VERBATIM (it is NOT re-wrapped in jsonContent), so this override owns + * the full MCP content envelope. + */ + mcpExecute?: SharedToolExecute; + /** + * In-app-host override for a DELIBERATE per-layer difference (a projected + * result shape, a different guardrail message). When present, the in-app host + * calls this and returns its value as the tool result (no wrapping). + */ + inAppExecute?: SharedToolExecute; + /** Registered only on the MCP host (skipped by the in-app registry loop). */ + mcpOnly?: boolean; + /** Registered only on the in-app host (skipped by the MCP registry loop). */ + inAppOnly?: boolean; } +// --- Shared execute helpers ------------------------------------------------- +// +// Each helper is the ONE canonical arg->client mapping for a tool (or a host +// override where the two layers deliberately differ). They are attached to their +// spec below. Kept as named functions (not inline) so the spec table stays +// readable and each mapping is individually greppable/testable. +// +// The `args` are the host's already-validated, zod-erased input; we read the +// same fields the tool's buildShape declares. Return RAW data unless the name is +// an mcp*/inApp* override that owns the host's full result shape. + +/** Format a JSON payload as the MCP transport's text-content envelope. Mirrors + * the private `jsonContent` in index.ts so an mcpExecute override that must NOT + * be re-wrapped can still emit the standard envelope for the data part. */ +const mcpJson = (data: unknown) => ({ + content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }], +}); + export const SHARED_TOOL_SPECS = { // --- no-argument read tools --- @@ -65,6 +196,7 @@ export const SHARED_TOOL_SPECS = { description: 'Fetch metadata about the current workspace (name, settings).', tier: 'core', catalogLine: 'getWorkspace — fetch current workspace metadata (name, settings).', + execute: (client) => client.getWorkspace(), }, listSpaces: { @@ -75,6 +207,7 @@ export const SHARED_TOOL_SPECS = { 'spaces (id, name, slug, ...).', tier: 'core', catalogLine: 'listSpaces — list the spaces the user can access (id, name, slug).', + execute: (client) => client.getSpaces(), }, listShares: { @@ -84,6 +217,7 @@ export const SHARED_TOOL_SPECS = { 'List all public shares in the workspace with page titles and public URLs.', tier: 'deferred', catalogLine: 'listShares — list all public shares in the workspace with their URLs.', + execute: (client) => client.listShares(), }, // --- single-pageId read tools --- @@ -102,6 +236,7 @@ export const SHARED_TOOL_SPECS = { buildShape: (z) => ({ pageId: z.string().min(1), }), + execute: (client, { pageId }) => client.getPageJson(pageId as string), }, getOutline: { @@ -119,6 +254,7 @@ export const SHARED_TOOL_SPECS = { buildShape: (z) => ({ pageId: z.string().min(1), }), + execute: (client, { pageId }) => client.getOutline(pageId as string), }, // --- two-id read tool --- @@ -139,6 +275,8 @@ export const SHARED_TOOL_SPECS = { pageId: z.string().min(1), nodeId: z.string().min(1), }), + execute: (client, { pageId, nodeId }) => + client.getNode(pageId as string, nodeId as string), }, // --- in-page occurrence search (client-side, over ProseMirror plain text) --- @@ -196,6 +334,12 @@ export const SHARED_TOOL_SPECS = { .optional() .describe('Max matches to RETURN (default 50, max 200); total is always reported.'), }), + execute: (client, { pageId, query, regex, caseSensitive, limit }) => + client.searchInPage(pageId as string, query as string, { + regex: regex as boolean | undefined, + caseSensitive: caseSensitive as boolean | undefined, + limit: limit as number | undefined, + }), }, // --- node delete --- @@ -212,6 +356,8 @@ export const SHARED_TOOL_SPECS = { pageId: z.string().min(1), nodeId: z.string().min(1), }), + execute: (client, { pageId, nodeId }) => + client.deleteNode(pageId as string, nodeId as string), }, // --- single-block structural write (patch / insert) --- @@ -259,6 +405,11 @@ export const SHARED_TOOL_SPECS = { 'JSON object or JSON string both accepted.', ), }), + // parseNodeArg normalizes a JSON-string node into an object (the model + // sometimes serializes it as a string) before the client's typeof-object + // guard rejects it — identical on both hosts. + execute: (client, { pageId, nodeId, node }) => + client.patchNode(pageId as string, nodeId as string, parseNodeArg(node)), }, insertNode: { @@ -312,6 +463,12 @@ export const SHARED_TOOL_SPECS = { 'are tolerated as a fallback; prefer plain text or anchorNodeId.', ), }), + execute: (client, { pageId, node, position, anchorNodeId, anchorText }) => + client.insertNode(pageId as string, parseNodeArg(node), { + position: position as 'before' | 'after' | 'append', + anchorNodeId: anchorNodeId as string | undefined, + anchorText: anchorText as string | undefined, + }), }, // --- share management --- @@ -342,6 +499,11 @@ export const SHARED_TOOL_SPECS = { .optional() .describe('Allow public search engines to index it (default true).'), }), + // `searchIndexing ?? true` is a no-op default: the client method already + // defaults searchIndexing to true, so passing `undefined` (the in-app form) + // and `?? true` (the old MCP form) are byte-identical — one canonical mapping. + execute: (client, { pageId, searchIndexing }) => + client.sharePage(pageId as string, (searchIndexing as boolean | undefined) ?? true), }, unsharePage: { @@ -353,6 +515,7 @@ export const SHARED_TOOL_SPECS = { buildShape: (z) => ({ pageId: z.string().min(1).describe('ID of the page to unshare'), }), + execute: (client, { pageId }) => client.unsharePage(pageId as string), }, // --- version history --- @@ -381,6 +544,12 @@ export const SHARED_TOOL_SPECS = { .optional() .describe("historyId, or 'current'/omit for current content"), }), + execute: (client, { pageId, from, to }) => + client.diffPageVersions( + pageId as string, + from as string | undefined, + to as string | undefined, + ), }, listPageHistory: { @@ -400,6 +569,8 @@ export const SHARED_TOOL_SPECS = { .optional() .describe('Pagination cursor from a previous nextCursor'), }), + execute: (client, { pageId, cursor }) => + client.listPageHistory(pageId as string, cursor as string | undefined), }, restorePageVersion: { @@ -416,6 +587,8 @@ export const SHARED_TOOL_SPECS = { buildShape: (z) => ({ historyId: z.string().min(1), }), + execute: (client, { historyId }) => + client.restorePageVersion(historyId as string), }, // --- markdown round-trip --- @@ -437,6 +610,8 @@ export const SHARED_TOOL_SPECS = { pageId: z.string().min(1), markdown: z.string().min(1), }), + execute: (client, { pageId, markdown }) => + client.importPageMarkdown(pageId as string, markdown as string), }, // --- server-side content copy --- @@ -459,6 +634,8 @@ export const SHARED_TOOL_SPECS = { .min(1) .describe('Page whose content is REPLACED (title/slug kept)'), }), + execute: (client, { sourcePageId, targetPageId }) => + client.copyPageContent(sourcePageId as string, targetPageId as string), }, // --- surgical text edit (folds in the documented drift-bug fix) --- @@ -508,6 +685,11 @@ export const SHARED_TOOL_SPECS = { .min(1) .describe('List of find/replace operations, applied in order'), }), + execute: (client, { pageId, edits }) => + client.editPageText( + pageId as string, + edits as Parameters[1], + ), }, // --- hand a large page to an external consumer without bloating context --- @@ -536,6 +718,33 @@ export const SHARED_TOOL_SPECS = { buildShape: (z) => ({ pageId: z.string().min(1), }), + // In-app returns the full documented `{ uri, size, sha256, images }` object + // as-is (the canonical execute). + execute: (client, { pageId }) => client.stashPage(pageId as string), + // The MCP transport must deliver the body as a resource_link (so it never + // enters the model context) PLUS a structuredContent mirror of the documented + // shape (sha256 = the blob's ETag, mirror counts). Owns its full envelope, so + // it is NOT wrapped in jsonContent. + mcpExecute: async (client, { pageId }) => { + const result = await client.stashPage(pageId as string); + return { + content: [ + { + type: 'resource_link' as const, + uri: result.uri, + name: 'page.json', + mimeType: 'application/json', + size: result.size, + }, + ], + structuredContent: { + uri: result.uri, + sha256: result.sha256, + size: result.size, + images: result.images, + }, + }; + }, }, // --- page tools (unified from the per-layer inline definitions, #294) --- @@ -562,6 +771,20 @@ export const SHARED_TOOL_SPECS = { buildShape: (z) => ({ pageId: z.string().min(1).describe('The id (or slugId) of the page.'), }), + // MCP wraps the raw `{ data, success }` as JSON. The in-app host instead + // projects a token-efficient `{ title, markdown }` (its long-standing shape). + execute: (client, { pageId }) => client.getPage(pageId as string), + inAppExecute: async (client, { pageId }) => { + // getPage(pageId) -> { data: filterPage(page, markdown), success }. + const result = (await client.getPage(pageId as string)) as { + data?: { title?: string; content?: string }; + }; + const data = result?.data ?? {}; + return { + title: data.title ?? '', + markdown: typeof data.content === 'string' ? data.content : '', + }; + }, }, listPages: { @@ -596,6 +819,15 @@ export const SHARED_TOOL_SPECS = { 'Requires spaceId; ignores limit.', ), }), + // `limit ?? 50` / `tree ?? false` are no-op defaults: the client method + // already defaults limit=50, tree=false, so the old MCP explicit-default form + // and the in-app pass-through form are byte-identical — one canonical mapping. + execute: (client, { spaceId, limit, tree }) => + client.listPages( + spaceId as string | undefined, + (limit as number | undefined) ?? 50, + (tree as boolean | undefined) ?? false, + ), }, createPage: { @@ -624,6 +856,28 @@ export const SHARED_TOOL_SPECS = { .optional() .describe('Optional parent page id to nest the new page under.'), }), + // MCP wraps the raw create response as JSON. In-app projects `{ id, title }` + // and defensively coerces a missing body to '' (the schema makes content a + // required string, so `?? ''` only guards an absent field — preserved). + execute: (client, { title, content, spaceId, parentPageId }) => + client.createPage( + title as string, + content as string, + spaceId as string, + parentPageId as string | undefined, + ), + inAppExecute: async (client, { title, content, spaceId, parentPageId }) => { + // createPage(title, content, spaceId, parentPageId?) -> + // { data: filterPage(page, markdown), success }. + const result = (await client.createPage( + title as string, + (content as string | undefined) ?? '', + spaceId as string, + parentPageId as string | undefined, + )) as { data?: { id?: string; slugId?: string; title?: string } }; + const data = result?.data ?? {}; + return { id: data.id ?? data.slugId, title: data.title ?? (title as string) }; + }, }, movePage: { @@ -661,6 +915,62 @@ export const SHARED_TOOL_SPECS = { 'append at the end.', ), }), + // The MCP host keeps its robustness guards (coerce 'null'/'' -> null, a cheap + // self-cycle guard, and a POSITIVE { success: true } confirmation) and its + // human-readable success envelope — owns its full result, so it is NOT + // wrapped in jsonContent. + mcpExecute: async (client, { pageId, parentPageId, position }) => { + const finalParentId = + parentPageId === '' || parentPageId === 'null' + ? null + : (parentPageId as string | null | undefined); + + // Cheap cycle guard: a page cannot be moved directly under itself. + // (Deeper descendant-cycle detection is intentionally out of scope.) + if (finalParentId !== null && finalParentId === pageId) { + throw new Error('cannot move a page under itself'); + } + + const result = await client.movePage( + pageId as string, + finalParentId || null, + position as string | undefined, + ); + + // Require POSITIVE confirmation: the live /pages/move success shape is + // exactly { success: true, status: 200 }. An empty body, a 204, or any odd + // shape lacking success === true must NOT be reported as a successful move. + if ( + !( + result && + typeof result === 'object' && + (result as { success?: unknown }).success === true + ) + ) { + throw new Error( + `Failed to move page ${pageId}: ${JSON.stringify(result)}`, + ); + } + + return mcpJson({ + message: `Successfully moved page ${pageId} to parent ${finalParentId || 'root'}`, + result, + }); + }, + // The in-app host has no guards; it forwards `parentPageId ?? null` + the + // optional position and projects `{ pageId, parentPageId, moved }`. + inAppExecute: async (client, { pageId, parentPageId, position }) => { + await client.movePage( + pageId as string, + (parentPageId as string | null | undefined) ?? null, + position as string | undefined, + ); + return { + pageId, + parentPageId: (parentPageId as string | null | undefined) ?? null, + moved: true, + }; + }, }, renamePage: { @@ -675,6 +985,13 @@ export const SHARED_TOOL_SPECS = { pageId: z.string().min(1).describe('The id of the page to rename.'), title: z.string().min(1).describe('The new title.'), }), + // MCP wraps the raw rename response; in-app projects `{ pageId, title }`. + execute: (client, { pageId, title }) => + client.renamePage(pageId as string, title as string), + inAppExecute: async (client, { pageId, title }) => { + await client.renamePage(pageId as string, title as string); + return { pageId, title }; + }, }, deletePage: { @@ -691,6 +1008,23 @@ export const SHARED_TOOL_SPECS = { buildShape: (z) => ({ pageId: z.string().min(1).describe('The id of the page to move to trash.'), }), + // deletePage(pageId) hits POST /pages/delete with { pageId } only — the + // soft-delete (trash) path. GUARDRAIL: the schema exposes ONLY pageId, so no + // permanent/force-delete flag can reach the client on either host (asserted by + // ai-chat-tools.service.spec.ts). MCP emits a bare success line (owns its full + // envelope); in-app projects `{ pageId, trashed }`. + mcpExecute: async (client, { pageId }) => { + await client.deletePage(pageId as string); + return { + content: [ + { type: 'text' as const, text: `Successfully deleted page ${pageId}` }, + ], + }; + }, + inAppExecute: async (client, { pageId }) => { + await client.deletePage(pageId as string); + return { pageId, trashed: true }; + }, }, updatePageJson: { @@ -721,6 +1055,19 @@ export const SHARED_TOOL_SPECS = { ), title: z.string().optional().describe('Optional new title'), }), + // Content normalization is identical on both hosts: only parse/validate the + // document when actually supplied; undefined/null passes straight through so + // the client performs a title-only (or no-op) update. A string is JSON.parsed + // (an empty string "" therefore throws), an object passes through unchanged. + execute: (client, { pageId, content, title }) => { + let doc: unknown; + if (content === undefined || content === null) { + doc = undefined; + } else { + doc = parseNodeArg(content, 'content was a string but not valid JSON'); + } + return client.updatePageJson(pageId as string, doc, title as string | undefined); + }, }, exportPageMarkdown: { @@ -741,6 +1088,17 @@ export const SHARED_TOOL_SPECS = { buildShape: (z) => ({ pageId: z.string().min(1).describe('The id of the page to export.'), }), + // The markdown is a bare string. MCP returns it as a single text-content + // element (NOT jsonContent — that would JSON-quote the whole document); + // in-app projects `{ markdown }`. + mcpExecute: async (client, { pageId }) => { + const md = await client.exportPageMarkdown(pageId as string); + return { content: [{ type: 'text' as const, text: md }] }; + }, + inAppExecute: async (client, { pageId }) => { + const markdown = await client.exportPageMarkdown(pageId as string); + return { markdown }; + }, }, // --- comment tools (unified from the per-layer inline definitions, #294) --- @@ -823,6 +1181,69 @@ export const SHARED_TOOL_SPECS = { 'refused.', ), }), + // 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. + 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.", + ); + } + 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.", + ); + } + if (!selection || !(selection as string).trim()) { + throw new Error( + "create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.", + ); + } + } + const result = await client.createComment( + pageId as string, + content as string, + 'inline', + selection as string | undefined, + parentCommentId as string | undefined, + suggestedText as string | undefined, + ); + return mcpJson(result); + }, + inAppExecute: async (client, { pageId, content, selection, parentCommentId, suggestedText }) => { + if (!parentCommentId && (!selection || !(selection as string).trim())) { + throw new Error( + "createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.", + ); + } + if (suggestedText !== undefined) { + if (parentCommentId) { + throw new Error( + "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( + "createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.", + ); + } + } + const result = (await client.createComment( + pageId as string, + content as string, + 'inline', + selection as string | undefined, + parentCommentId as string | undefined, + suggestedText as string | undefined, + )) as { data?: { id?: string } }; + const data = result?.data ?? {}; + return { commentId: data.id, pageId }; + }, }, listComments: { @@ -848,6 +1269,8 @@ export const SHARED_TOOL_SPECS = { .optional() .describe('default only active threads; true — include resolved'), }), + execute: (client, { pageId, includeResolved }) => + client.listComments(pageId as string, includeResolved as boolean | undefined), }, resolveComment: { @@ -881,6 +1304,13 @@ export const SHARED_TOOL_SPECS = { 'true (default) marks the thread resolved/closed; false reopens it', ), }), + // MCP wraps the raw resolve response; in-app projects `{ commentId, resolved }`. + execute: (client, { commentId, resolved }) => + client.resolveComment(commentId as string, resolved as boolean), + inAppExecute: async (client, { commentId, resolved }) => { + await client.resolveComment(commentId as string, resolved as boolean); + return { commentId, resolved }; + }, }, checkNewComments: { @@ -916,6 +1346,30 @@ export const SHARED_TOOL_SPECS = { 'Only pages under this parent will be checked.', ), }), + // The in-app host has NO `since` guard (the canonical execute, raw). The MCP + // host additionally rejects an unparseable `since` up front — otherwise the + // NaN comparison silently treats every comment as "not new" and returns zero + // without signalling the bad input. This guard is a DELIBERATE per-layer + // difference (the in-app surface never had it), preserved via mcpExecute. + execute: (client, { spaceId, since, parentPageId }) => + client.checkNewComments( + spaceId as string, + since as string, + parentPageId as string | undefined, + ), + mcpExecute: async (client, { spaceId, since, parentPageId }) => { + if (Number.isNaN(Date.parse(since as string))) { + throw new Error( + `Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`, + ); + } + const result = await client.checkNewComments( + spaceId as string, + since as string, + parentPageId as string | undefined, + ); + return mcpJson(result); + }, }, // --- table tools (unified from the per-layer inline definitions, #294) --- @@ -962,6 +1416,13 @@ export const SHARED_TOOL_SPECS = { .optional() .describe('0-based insert position (0 inserts before the header); omit to append.'), }), + execute: (client, { pageId, table, cells, index }) => + client.tableInsertRow( + pageId as string, + table as string, + cells as string[], + index as number | undefined, + ), }, tableDeleteRow: { @@ -983,6 +1444,8 @@ export const SHARED_TOOL_SPECS = { .describe('"#" from the page outline, or a block id in the table.'), index: z.number().int().describe('0-based row index to delete.'), }), + execute: (client, { pageId, table, index }) => + client.tableDeleteRow(pageId as string, table as string, index as number), }, tableUpdateCell: { @@ -1006,6 +1469,14 @@ export const SHARED_TOOL_SPECS = { col: z.number().int().describe('0-based column index.'), text: z.string().describe('The new cell text.'), }), + execute: (client, { pageId, table, row, col, text }) => + client.tableUpdateCell( + pageId as string, + table as string, + row as number, + col as number, + text as string, + ), }, // --- footnote + image write tools (promoted from inline MCP-only, #410) --- @@ -1050,6 +1521,8 @@ export const SHARED_TOOL_SPECS = { .min(1) .describe('The footnote content as markdown (becomes the definition).'), }), + execute: (client, { pageId, anchorText, text }) => + client.insertFootnote(pageId as string, anchorText as string, text as string), }, insertImage: { @@ -1087,6 +1560,13 @@ export const SHARED_TOOL_SPECS = { 'Insert the image right after the first top-level block whose text contains this string', ), }), + execute: (client, { pageId, imageUrl, align, alt, replaceText, afterText }) => + client.insertImage(pageId as string, imageUrl as string, { + align: align as 'left' | 'center' | 'right' | undefined, + alt: alt as string | undefined, + replaceText: replaceText as string | undefined, + afterText: afterText as string | undefined, + }), }, replaceImage: { @@ -1118,6 +1598,11 @@ export const SHARED_TOOL_SPECS = { align: z.enum(['left', 'center', 'right']).optional(), alt: z.string().optional(), }), + execute: (client, { pageId, attachmentId, imageUrl, align, alt }) => + client.replaceImage(pageId as string, attachmentId as string, imageUrl as string, { + align: align as 'left' | 'center' | 'right' | undefined, + alt: alt as string | undefined, + }), }, // --- draw.io diagrams (issue #423, stage 1) --- @@ -1147,6 +1632,12 @@ export const SHARED_TOOL_SPECS = { .optional() .describe('"xml" (default) for mxGraph XML, or "svg" for the raw .drawio.svg.'), }), + execute: (client, { pageId, node, format }) => + client.drawioGet( + pageId as string, + node as string, + (format as 'xml' | 'svg' | undefined) ?? 'xml', + ), }, drawioCreate: { @@ -1193,6 +1684,18 @@ export const SHARED_TOOL_SPECS = { .describe('Anchor text fragment (for before/after).'), title: z.string().optional().describe('Optional diagram title.'), }), + // The flat schema fields are regrouped into the client's `where` object. + execute: (client, { pageId, xml, position, anchorNodeId, anchorText, title }) => + client.drawioCreate( + pageId as string, + { + position: position as 'before' | 'after' | 'append', + anchorNodeId: anchorNodeId as string | undefined, + anchorText: anchorText as string | undefined, + }, + xml as string, + title as string | undefined, + ), }, drawioUpdate: { @@ -1226,5 +1729,12 @@ export const SHARED_TOOL_SPECS = { .min(1) .describe('The meta.hash from the drawio_get this edit is based on.'), }), + execute: (client, { pageId, node, xml, baseHash }) => + client.drawioUpdate( + pageId as string, + node as string, + xml as string, + baseHash as string, + ), }, } satisfies Record;