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 81b1f450..d7548e15 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 @@ -316,50 +316,27 @@ export class AiChatToolsService { execute: async () => resolveCurrentPageResult(openedPage), }), - getPage: tool({ - description: - 'Fetch a single page as Markdown by its page id. Returns the page ' + - 'title and its Markdown content. Inline tags ' + - 'in the markdown are comment highlight anchors (also present for ' + - 'RESOLVED threads) — treat them as markup, not page text.', - inputSchema: modelFriendlyInput({ - pageId: z.string().describe('The id (or slugId) of the page.'), - }), - execute: 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 : '', - }; - }, + // 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) --- - createPage: tool({ - description: - 'Create a new page with a Markdown body in a space, optionally under ' + - 'a parent page. Returns the new page id and title. Reversible: a page ' + - 'can be moved to trash later.', - inputSchema: modelFriendlyInput({ - title: z.string().describe('The title of the new page.'), - content: z - .string() - .describe('The page body as Markdown (may be empty).'), - spaceId: z - .string() - .describe('The id of the space to create the page in.'), - parentPageId: z - .string() - .optional() - .describe('Optional parent page id to nest the new page under.'), - }), - execute: async ({ title, content, spaceId, parentPageId }) => { + // 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( @@ -375,7 +352,7 @@ export class AiChatToolsService { }; return { id: data.id ?? data.slugId, title: data.title ?? title }; }, - }), + ), updatePageContent: tool({ description: @@ -399,60 +376,37 @@ export class AiChatToolsService { }, }), - renamePage: tool({ - description: - "Rename a page (change its title only; the body is untouched). " + - 'Reversible: rename back at any time.', - inputSchema: modelFriendlyInput({ - pageId: z.string().describe('The id of the page to rename.'), - title: z.string().describe('The new title.'), - }), - execute: async ({ pageId, title }) => { + // 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 }; }, - }), + ), - movePage: tool({ - description: - 'Move a page under a new parent page, or to the space root when no ' + - 'parent is given. Reversible: move it back at any time.', - inputSchema: modelFriendlyInput({ - pageId: z.string().describe('The id of the page to move.'), - parentPageId: z - .string() - .nullable() - .optional() - .describe( - 'Target parent page id. Null/omitted moves the page to the ' + - 'space root.', - ), - }), - execute: async ({ pageId, parentPageId }) => { + // 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); + await client.movePage(pageId, parentPageId ?? null, position); return { pageId, parentPageId: parentPageId ?? null, moved: true }; }, - }), + ), - deletePage: tool({ - description: - 'Move a page to the trash (SOFT delete only — fully reversible; the ' + - 'page can be restored from trash). This NEVER permanently deletes.', - inputSchema: modelFriendlyInput({ - pageId: z.string().describe('The id of the page to move to trash.'), - }), - // GUARDRAIL (§14 H4): the only field ever passed to the client is - // pageId. permanentlyDelete/forceDelete are not part of the schema and - // are never forwarded, so the agent physically cannot permanently - // delete a page through this tool. - execute: 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). + // 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). @@ -530,33 +484,12 @@ export class AiChatToolsService { // 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. - listPages: tool({ - description: - 'List the most recent pages, optionally scoped to a single space. ' + - 'Returns a bounded list (default 50, max 100). Pass tree:true (with ' + - "spaceId) to instead get the space's full page hierarchy as a nested tree.", - inputSchema: modelFriendlyInput({ - spaceId: z - .string() - .optional() - .describe('Optional space id to scope the listing to.'), - limit: z - .number() - .int() - .min(1) - .max(100) - .optional() - .describe('Maximum number of pages (1-100).'), - tree: z - .boolean() - .optional() - .describe( - 'When true, return the full page hierarchy of the given space as a nested tree (children arrays) instead of the recent-pages flat list. Requires spaceId; ignores limit.', - ), - }), - execute: async ({ spaceId, limit, tree }) => + // 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: @@ -673,19 +606,14 @@ export class AiChatToolsService { await client.diffPageVersions(pageId, from, to), ), - exportPageMarkdown: tool({ - description: - 'Export a page to a single self-contained Docmost-flavoured ' + - 'Markdown file (meta + body + comment threads). Lossless round-trip ' + - 'with importPageMarkdown.', - inputSchema: modelFriendlyInput({ - pageId: z.string().describe('The id of the page to export.'), - }), - execute: async ({ pageId }) => { + // 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) --- @@ -735,28 +663,12 @@ export class AiChatToolsService { async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId), ), - updatePageJson: tool({ - description: - "Replace a page's body with a full ProseMirror document — a full " + - 'overwrite — and/or update its title. Minimal example content: ' + - '{"type":"doc","content":[{"type":"paragraph","content":' + - '[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' + - 'object or a JSON string (both accepted). Omit content for a ' + - 'title-only update. Reversible: the previous version is kept in page ' + - 'history.', - inputSchema: modelFriendlyInput({ - pageId: z.string().describe('The id of the page to update.'), - content: z - .any() - .optional() - .describe( - 'Full ProseMirror doc {"type":"doc","content":[...]} (JSON ' + - 'object or JSON string); omit for a title-only update.', - ), - title: z.string().optional().describe('Optional new title.'), - }), - execute: async ({ pageId, content, title }) => { - // Parity with the standalone MCP server (index.ts update_page_json): + // 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. @@ -769,7 +681,7 @@ export class AiChatToolsService { } return await client.updatePageJson(pageId, doc, title); }, - }), + ), // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). // The table reference parameter was unified to `table` (was `tableRef`). 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 e021f1af..b0c13b2b 100644 --- a/apps/server/src/core/ai-chat/tools/tool-tiers.ts +++ b/apps/server/src/core/ai-chat/tools/tool-tiers.ts @@ -100,14 +100,8 @@ export const INLINE_TOOL_TIERS: Record< tier: 'core', catalogLine: 'getCurrentPage — the page the user is currently viewing.', }, - getPage: { - tier: 'core', - catalogLine: 'getPage — fetch a page as Markdown by its id.', - }, - listPages: { - tier: 'core', - catalogLine: "listPages — list recent pages, or a space's full page tree.", - }, + // NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS + // (#294); they carry their own tier ('core') + catalogLine there. // NOTE: createComment, listComments and resolveComment moved to // @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier + // catalogLine there. getComment stays inline (MCP-only shape divergence is @@ -118,27 +112,14 @@ export const INLINE_TOOL_TIERS: Record< }, // --- deferred inline --- - createPage: { - tier: 'deferred', - catalogLine: 'createPage — create a new page with a Markdown body in a space.', - }, + // NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and + // exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they + // carry their own deferred tier + catalogLine there. updatePageContent: { tier: 'deferred', catalogLine: "updatePageContent — replace a page's body (and optionally title) with new Markdown.", }, - renamePage: { - tier: 'deferred', - catalogLine: "renamePage — change a page's title only (body untouched).", - }, - movePage: { - tier: 'deferred', - catalogLine: 'movePage — move a page under a new parent or to the space root.', - }, - deletePage: { - tier: 'deferred', - catalogLine: 'deletePage — move a page to trash (soft delete, reversible).', - }, listSidebarPages: { tier: 'deferred', catalogLine: @@ -159,16 +140,6 @@ export const INLINE_TOOL_TIERS: Record< catalogLine: 'getPageHistory — fetch one page-history version with its ProseMirror content.', }, - exportPageMarkdown: { - tier: 'deferred', - catalogLine: - 'exportPageMarkdown — export a page to self-contained Markdown (body + comments).', - }, - updatePageJson: { - tier: 'deferred', - catalogLine: - "updatePageJson — overwrite a page's body with a full ProseMirror document.", - }, sharePage: { tier: 'deferred', catalogLine: 'sharePage — make a page publicly accessible and return its URL.', diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 90137182..4c798cf3 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -118,56 +118,19 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer { // 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. -server.registerTool( - "list_pages", - { - description: - "List most recent pages in a space ordered by updatedAt (descending). " + - "Returns a bounded list (default 50, max 100) — use search for lookups " + - "in large spaces. Pass tree:true (with spaceId) to instead get the " + - "space's full page hierarchy as a nested tree.", - inputSchema: { - spaceId: z.string().optional(), - limit: z - .number() - .int() - .min(1) - .max(100) - .optional() - .describe("Max pages to return (default 50, max 100)"), - tree: z - .boolean() - .optional() - .describe( - "When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit.", - ), - }, - }, - async ({ spaceId, limit, tree }) => { - const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false); - return jsonContent(result); - }, -); +// 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 -server.registerTool( - "get_page", - { - description: - "Get page details with content converted to Markdown. The conversion is " + - "LOSSY (block ids, exact table/callout structure are approximated); for a " + - "lossless representation use get_page_json. Inline " + - "tags in the markdown are comment highlight anchors (also present for " + - "RESOLVED threads) — treat them as markup, not page text.", - inputSchema: { - pageId: z.string().min(1), - }, - }, - async ({ pageId }) => { - const page = await docmostClient.getPage(pageId); - return jsonContent(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 }) => { @@ -270,22 +233,9 @@ registerShared( ); // Tool: create_page -server.registerTool( - "create_page", - { - description: - "Create a new page from Markdown in a space. Pass parentPageId to nest " + - "it under a parent; omit it to create at the space root.", - inputSchema: { - title: z.string().min(1).describe("Title of the page"), - content: z.string().min(1).describe("Markdown content"), - spaceId: z.string().min(1), - parentPageId: z - .string() - .optional() - .describe("Optional parent page ID to nest under"), - }, - }, +// 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, @@ -298,32 +248,11 @@ server.registerTool( ); // Tool: update_page_json -server.registerTool( - "update_page_json", - { - description: - "Replace a page's content with a raw ProseMirror JSON document " + - "(lossless write: preserves the block ids, callouts, tables and " + - "attributes you pass in). Typical flow: get_page_json -> modify the " + - "JSON -> update_page_json. Keep existing node ids intact so heading " + - "anchors and history stay stable. Minimal full-doc example: " + - '{"type":"doc","content":[{"type":"paragraph","content":' + - '[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' + - "JSON string (both accepted), and is OPTIONAL: omit it to update only " + - "the title (though prefer rename_page for a title-only change). " + - "Supplying neither content nor title is an error.", - inputSchema: { - pageId: z.string().min(1).describe("ID of the page to update"), - content: z - .any() - .optional() - .describe( - 'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' + - "JSON string). Omit to rename only.", - ), - title: z.string().optional().describe("Optional new title"), - }, - }, +// 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 @@ -341,26 +270,11 @@ server.registerTool( ); // Tool: export_page_markdown -server.registerTool( - "export_page_markdown", - { - description: - "Export a page to a single self-contained, lossless Docmost-flavoured " + - "Markdown file (custom extensions): YAML-free meta header, body with " + - "inline comment anchors and diagrams, and a trailing comments-thread " + - "block. Designed for a download -> edit body -> import_page_markdown " + - "round-trip that preserves everything, including comment highlights. " + - "Comment THREADS are preserved in the file but are not re-pushed to the " + - "server on import.", - inputSchema: { - pageId: z.string().min(1), - }, - }, - async ({ pageId }) => { - const md = await docmostClient.exportPageMarkdown(pageId); - return { content: [{ type: "text" as const, text: md }] }; - }, -); +// 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( @@ -384,22 +298,11 @@ registerShared( ); // Tool: rename_page -server.registerTool( - "rename_page", - { - description: - "Rename a page (change its title only) without touching or resending " + - "its content.", - inputSchema: { - pageId: z.string().min(1).describe("ID of the page to rename"), - title: z.string().min(1).describe("New title"), - }, - }, - async ({ pageId, title }) => { - const result = await docmostClient.renamePage(pageId, title); - return jsonContent(result); - }, -); +// 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 }) => { @@ -603,29 +506,11 @@ registerShared(SHARED_TOOL_SPECS.listShares, async () => { }); // Tool: move_page -server.registerTool( - "move_page", - { - description: - "Move a page under a new parent (nesting) or to the space root.", - inputSchema: { - pageId: z.string().min(1), - parentPageId: z - .string() - .nullable() - .optional() - .describe( - "Target parent page ID. Pass 'null' or empty string to move to root.", - ), - position: z - .string() - .min(5) - .optional() - .describe( - "fractional-index position key; min 5 chars; omit to append at the end.", - ), - }, - }, +// 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; @@ -660,25 +545,16 @@ server.registerTool( ); // Tool: delete_page -server.registerTool( - "delete_page", - { - description: - "Delete a single page by ID. SOFT delete only: the page is moved to " + - "trash and can be restored; nothing is permanently deleted.", - inputSchema: { - pageId: z.string().min(1), - }, - }, - async ({ pageId }) => { - await docmostClient.deletePage(pageId); - return { - content: [ - { type: "text" as const, text: `Successfully deleted page ${pageId}` }, - ], - }; - }, -); +// 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) --- diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index 670e6f97..43eee4fd 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -510,6 +510,211 @@ export const SHARED_TOOL_SPECS = { }), }, + // --- page tools (unified from the per-layer inline definitions, #294) --- + // + // Descriptions merge both layers (the MCP copy's richer structural notes + the + // in-app copy's "Reversible via history/trash" framing where it added one). + // Field constraints keep the MCP copy's stricter .min(1) EXCEPT where the + // in-app layer deliberately allowed a looser value (documented per field). + + getPage: { + mcpName: 'get_page', + inAppKey: 'getPage', + description: + 'Fetch a single page as Markdown by its id. Returns the page title and ' + + 'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' + + 'table/callout structure are approximated); for a lossless representation ' + + 'use get_page_json. Inline tags in the markdown ' + + 'are comment highlight anchors (also present for RESOLVED threads) — ' + + 'treat them as markup, not page text.', + tier: 'core', + catalogLine: 'getPage — fetch a page as Markdown by its id.', + // Reconciled: MCP's stricter .min(1) kept; in-app's more-informative + // "(or slugId)" describe kept. + buildShape: (z) => ({ + pageId: z.string().min(1).describe('The id (or slugId) of the page.'), + }), + }, + + listPages: { + mcpName: 'list_pages', + inAppKey: 'listPages', + description: + 'List the most recent pages (ordered by updatedAt, descending), ' + + 'optionally scoped to a single space. Returns a bounded list (default ' + + '50, max 100) — use search for lookups in large spaces. Pass tree:true ' + + "(with spaceId) to instead get the space's full page hierarchy as a " + + 'nested tree.', + tier: 'core', + catalogLine: "listPages — list recent pages, or a space's full page tree.", + buildShape: (z) => ({ + spaceId: z + .string() + .optional() + .describe('Optional space id to scope the listing to.'), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Maximum number of pages (default 50, max 100).'), + tree: z + .boolean() + .optional() + .describe( + "When true, return the space's full page hierarchy as a nested tree " + + '(children arrays) instead of the recent-by-updatedAt flat list. ' + + 'Requires spaceId; ignores limit.', + ), + }), + }, + + createPage: { + mcpName: 'create_page', + inAppKey: 'createPage', + description: + 'Create a new page with a Markdown body in a space, optionally under a ' + + 'parent page (omit parentPageId to create at the space root). Returns ' + + 'the new page id and title. Reversible: a page can be moved to trash ' + + 'later.', + tier: 'deferred', + catalogLine: 'createPage — create a new page with a Markdown body in a space.', + // 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 + // 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). + buildShape: (z) => ({ + title: z.string().min(1).describe('The title of the new page.'), + content: z.string().describe('The page body as Markdown (may be empty).'), + spaceId: z.string().min(1).describe('The id of the space to create the page in.'), + parentPageId: z + .string() + .optional() + .describe('Optional parent page id to nest the new page under.'), + }), + }, + + movePage: { + mcpName: 'move_page', + inAppKey: 'movePage', + description: + 'Move a page under a new parent page, or to the space root when no ' + + 'parent is given. Reversible: move it back at any time.', + tier: 'deferred', + catalogLine: 'movePage — move a page under a new parent or to the space root.', + // Reconciled schema DRIFT: the MCP copy exposed a `position` field + // (fractional-index ordering) that the in-app copy lacked. Unified by + // KEEPING position (the in-app client already accepts an optional position + // arg, so the in-app execute now forwards it) — it is optional, so no + // previously-valid in-app call is rejected. `parentPageId` is `.nullable()` + // on both, so a real JSON null moves to root on either transport; the MCP + // execute additionally coerces the strings 'null'/'' to null as a robustness + // fallback (kept in its execute body, not in the shared schema). + buildShape: (z) => ({ + pageId: z.string().min(1).describe('The id of the page to move.'), + parentPageId: z + .string() + .nullable() + .optional() + .describe( + 'Target parent page id. Null or omitted moves the page to the space ' + + 'root.', + ), + position: z + .string() + .min(5) + .optional() + .describe( + 'Optional fractional-index position key (min 5 chars); omit to ' + + 'append at the end.', + ), + }), + }, + + renamePage: { + mcpName: 'rename_page', + inAppKey: 'renamePage', + description: + 'Rename a page (change its title only; the body is untouched, never ' + + 'resent). Reversible: rename back at any time.', + tier: 'deferred', + catalogLine: "renamePage — change a page's title only (body untouched).", + buildShape: (z) => ({ + pageId: z.string().min(1).describe('The id of the page to rename.'), + title: z.string().min(1).describe('The new title.'), + }), + }, + + deletePage: { + mcpName: 'delete_page', + inAppKey: 'deletePage', + description: + 'Move a page to the trash — SOFT delete only: the page can be restored ' + + 'from trash and nothing is ever permanently deleted.', + tier: 'deferred', + catalogLine: 'deletePage — move a page to trash (soft delete, reversible).', + // GUARDRAIL preserved (§14 H4): the schema exposes ONLY pageId, so a + // permanentlyDelete/forceDelete flag can never reach the client through this + // tool (asserted by ai-chat-tools.service.spec.ts). + buildShape: (z) => ({ + pageId: z.string().min(1).describe('The id of the page to move to trash.'), + }), + }, + + updatePageJson: { + mcpName: 'update_page_json', + inAppKey: 'updatePageJson', + description: + "Replace a page's content with a raw ProseMirror JSON document (lossless " + + 'write: preserves the block ids, callouts, tables and attributes you pass ' + + 'in). Typical flow: get_page_json -> modify the JSON -> update_page_json. ' + + 'Keep existing node ids intact so heading anchors and history stay ' + + 'stable. Minimal full-doc example: {"type":"doc","content":[{"type":' + + '"paragraph","content":[{"type":"text","text":"Hi"}]}]}. `content` may be ' + + 'a JSON object or a JSON string (both accepted), and is OPTIONAL: omit it ' + + 'to update only the title (though prefer rename_page for a title-only ' + + 'change). Supplying neither content nor title is an error. Reversible: ' + + 'the previous version is kept in page history.', + tier: 'deferred', + catalogLine: + "updatePageJson — overwrite a page's body with a full ProseMirror document.", + buildShape: (z) => ({ + pageId: z.string().min(1).describe('ID of the page to update'), + content: z + .any() + .optional() + .describe( + 'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' + + 'JSON string). Omit to update only the title.', + ), + title: z.string().optional().describe('Optional new title'), + }), + }, + + exportPageMarkdown: { + mcpName: 'export_page_markdown', + inAppKey: 'exportPageMarkdown', + // CANONICAL: the MCP copy (a strict superset of the terse in-app wording). + description: + 'Export a page to a single self-contained, lossless Docmost-flavoured ' + + 'Markdown file (custom extensions): YAML-free meta header, body with ' + + 'inline comment anchors and diagrams, and a trailing comments-thread ' + + 'block. Designed for a download -> edit body -> import_page_markdown ' + + 'round-trip that preserves everything, including comment highlights. ' + + 'Comment THREADS are preserved in the file but are not re-pushed to the ' + + 'server on import.', + tier: 'deferred', + catalogLine: + 'exportPageMarkdown — export a page to self-contained Markdown (body + comments).', + buildShape: (z) => ({ + pageId: z.string().min(1).describe('The id of the page to export.'), + }), + }, + // --- comment tools (unified from the per-layer inline definitions, #294) --- // // create_comment and resolve_comment previously carried a "per-transport