diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts index becf082f..5add9494 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts @@ -1,6 +1,19 @@ import { AiChatToolsService } from './ai-chat-tools.service'; import * as loader from './docmost-client.loader'; import type { DocmostClientLike } from './docmost-client.loader'; +// The real zod-agnostic shared tool-spec registry. It has no runtime deps, so +// importing the TS source directly keeps these mocks honest: the service builds +// the shared tools from exactly the specs the package ships, not a hand-stub. +import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs'; + +// loadDocmostMcp now resolves to { DocmostClient, sharedToolSpecs }. Every mock +// below must supply sharedToolSpecs or the service throws while building the +// shared tools. Factor the resolved-value shape so the three mock sites stay in +// sync. +const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({ + DocmostClient, + sharedToolSpecs: SHARED_TOOL_SPECS as Record, +}); /** * Guardrail test (§14 [H4]): the adapter's `deletePage` write tool must be a @@ -37,11 +50,11 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => { beforeEach(() => { deletePageCalls.length = 0; // Intercept the ESM loader so `new DocmostClient(config)` returns our fake. - jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({ - DocmostClient: function () { + jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue( + mockLoaded(function () { return fakeClient as DocmostClientLike; - } as unknown as loader.DocmostClientCtor, - }); + } as unknown as loader.DocmostClientCtor), + ); // The new semanticSearch deps (aiService + repos) are not exercised by the // deletePage guardrail tests; pass stubs to satisfy the constructor arity. service = new AiChatToolsService( @@ -144,11 +157,11 @@ describe('AiChatToolsService expanded toolset guardrails', () => { let service: AiChatToolsService; beforeEach(() => { - jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({ - DocmostClient: function () { + jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue( + mockLoaded(function () { return fakeClient as DocmostClientLike; - } as unknown as loader.DocmostClientCtor, - }); + } as unknown as loader.DocmostClientCtor), + ); service = new AiChatToolsService( tokenServiceStub as never, {} as never, @@ -252,11 +265,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => { patchNodeCalls.length = 0; insertNodeCalls.length = 0; updatePageJsonCalls.length = 0; - jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({ - DocmostClient: function () { + jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue( + mockLoaded(function () { return fakeClient as DocmostClientLike; - } as unknown as loader.DocmostClientCtor, - }); + } as unknown as loader.DocmostClientCtor), + ); service = new AiChatToolsService( tokenServiceStub as never, {} as never, 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 74ac66b8..4ad80ef7 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 @@ -11,6 +11,7 @@ import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo' import { loadDocmostMcp, type DocmostClientLike, + type SharedToolSpec, } from './docmost-client.loader'; import { resolveCurrentPageResult } from './current-page.util'; import { parseNodeArg } from './parse-node-arg'; @@ -84,13 +85,29 @@ export class AiChatToolsService { aiChatId, }); - const { DocmostClient } = await loadDocmostMcp(); + const { DocmostClient, sharedToolSpecs } = await loadDocmostMcp(); const client: DocmostClientLike = new DocmostClient({ apiUrl, getToken, getCollabToken, }); + // Build an ai-SDK tool from a shared, zod-agnostic spec. The spec owns the + // canonical description + (optional) schema builder, which is invoked with + // THIS layer's zod (v4); only the execute body is supplied per call. No-arg + // specs (no buildShape) get an empty object schema. + const sharedTool = ( + spec: SharedToolSpec, + execute: Tool['execute'], + ): Tool => + tool({ + description: spec.description, + inputSchema: spec.buildShape + ? z.object(spec.buildShape(z) as z.ZodRawShape) + : z.object({}), + execute, + }); + return { searchPages: tool({ description: @@ -416,20 +433,15 @@ export class AiChatToolsService { // --- READ tools (added) --- - getWorkspace: tool({ - description: - 'Fetch metadata about the current workspace (name, settings).', - inputSchema: z.object({}), - execute: async () => await client.getWorkspace(), - }), + getWorkspace: sharedTool( + sharedToolSpecs.getWorkspace, + async () => await client.getWorkspace(), + ), - listSpaces: tool({ - description: - 'List the spaces the current user can access. Returns the array ' + - 'of spaces (id, name, slug, ...).', - inputSchema: z.object({}), - execute: async () => await client.getSpaces(), - }), + listSpaces: sharedTool( + sharedToolSpecs.listSpaces, + async () => await client.getSpaces(), + ), listPages: tool({ description: @@ -477,43 +489,20 @@ export class AiChatToolsService { await client.listSidebarPages(spaceId, pageId), }), - getOutline: tool({ - description: - "Compact outline of a page's top-level blocks, with block ids. Use " + - 'it to locate sections/tables and grab block ids before drilling in ' + - 'with getNode / patchNode / insertNode.', - inputSchema: z.object({ - pageId: z.string().describe('The id of the page.'), - }), - execute: async ({ pageId }) => await client.getOutline(pageId), - }), + getOutline: sharedTool( + sharedToolSpecs.getOutline, + async ({ pageId }) => await client.getOutline(pageId), + ), - getPageJson: tool({ - description: - 'Fetch a page as lossless ProseMirror JSON (preserves block ids and ' + - 'marks). Use this when you need exact structure for node-level edits.', - inputSchema: z.object({ - pageId: z.string().describe('The id of the page.'), - }), - execute: async ({ pageId }) => await client.getPageJson(pageId), - }), + getPageJson: sharedTool( + sharedToolSpecs.getPageJson, + async ({ pageId }) => await client.getPageJson(pageId), + ), - getNode: tool({ - description: - "Fetch a single block's full ProseMirror subtree (lossless) by " + - 'reference.', - inputSchema: z.object({ - pageId: z.string().describe('The id of the page.'), - nodeId: z - .string() - .describe( - 'A block id from getOutline, or "#" to select a ' + - 'top-level block by its outline index (e.g. a table).', - ), - }), - execute: async ({ pageId, nodeId }) => - await client.getNode(pageId, nodeId), - }), + getNode: sharedTool( + sharedToolSpecs.getNode, + async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId), + ), getTable: tool({ description: @@ -570,27 +559,16 @@ export class AiChatToolsService { await client.checkNewComments(spaceId, since, parentPageId), }), - listShares: tool({ - description: - 'List all public shares in the workspace, each with its public URL.', - inputSchema: z.object({}), - execute: async () => await client.listShares(), - }), + listShares: sharedTool( + sharedToolSpecs.listShares, + async () => await client.listShares(), + ), - listPageHistory: tool({ - description: - 'List the saved versions (history snapshots) of a page, newest ' + - 'first. Returns one cursor-paginated page of results.', - inputSchema: z.object({ - pageId: z.string().describe('The id of the page.'), - cursor: z - .string() - .optional() - .describe('Optional pagination cursor from a previous call.'), - }), - execute: async ({ pageId, cursor }) => + listPageHistory: sharedTool( + sharedToolSpecs.listPageHistory, + async ({ pageId, cursor }) => await client.listPageHistory(pageId, cursor), - }), + ), getPageHistory: tool({ description: @@ -603,24 +581,11 @@ export class AiChatToolsService { await client.getPageHistory(historyId), }), - diffPageVersions: tool({ - description: - 'Diff two versions of a page and return the change set. from/to ' + - "each accept a historyId or 'current' (or omit for current).", - inputSchema: z.object({ - pageId: z.string().describe('The id of the page.'), - from: z - .string() - .optional() - .describe("A historyId, or 'current'/omit for current content."), - to: z - .string() - .optional() - .describe("A historyId, or 'current'/omit for current content."), - }), - execute: async ({ pageId, from, to }) => + diffPageVersions: sharedTool( + sharedToolSpecs.diffPageVersions, + async ({ pageId, from, to }) => await client.diffPageVersions(pageId, from, to), - }), + ), exportPageMarkdown: tool({ description: @@ -638,46 +603,10 @@ export class AiChatToolsService { // --- WRITE tools (added; reversible via page history/trash) --- - editPageText: tool({ - description: - 'Surgical find/replace inside a page\'s text, preserving all block ' + - 'ids and marks. A find MAY cross bold/italic/link boundaries; the ' + - 'replacement inherits marks from the unchanged common prefix/suffix ' + - '(so editing plain text next to a bold word keeps it bold, and ' + - 'editing inside a bold word keeps the new text bold). Each find must ' + - 'match exactly once unless replaceAll is set. The batch applies what ' + - 'it can and returns applied[] + failed[] plus a verify change-report ' + - '(the text/marks/structure that ACTUALLY changed — read it to confirm ' + - 'your edit landed; do not assume success); a fully-unmatched batch ' + - 'writes nothing and errors. find and replace are LITERAL text, not ' + - 'markdown. This tool edits plain text ONLY and CANNOT add or remove ' + - 'formatting marks: a formatting change — find/replace that differ only ' + - 'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' + - 'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' + - 'failed[]. To change bold/italic/strike/code/link, read the block with ' + - 'getPageJson and use patchNode (or updatePageJson) to set its marks. ' + - 'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' + - 'world",replace:"Hello there"}] (crosses a bold boundary). Reversible: ' + - 'the previous version is kept in page history.', - inputSchema: z.object({ - pageId: z.string().describe('The id of the page to edit.'), - edits: z - .array( - z.object({ - find: z.string().describe('Exact text to find.'), - replace: z.string().describe('Replacement text.'), - replaceAll: z - .boolean() - .optional() - .describe('Replace every occurrence (default: one match).'), - }), - ) - .min(1) - .describe('One or more find/replace edits.'), - }), - execute: async ({ pageId, edits }) => - await client.editPageText(pageId, edits), - }), + editPageText: sharedTool( + sharedToolSpecs.editPageText, + async ({ pageId, edits }) => await client.editPageText(pageId, edits), + ), patchNode: tool({ description: @@ -767,17 +696,10 @@ export class AiChatToolsService { }, }), - deleteNode: tool({ - description: - 'Remove a content BLOCK by its id (NOT a page). Reversible: the ' + - 'previous version is kept in page history.', - inputSchema: z.object({ - pageId: z.string().describe('The id of the page.'), - nodeId: z.string().describe('The block id to remove.'), - }), - execute: async ({ pageId, nodeId }) => - await client.deleteNode(pageId, nodeId), - }), + deleteNode: sharedTool( + sharedToolSpecs.deleteNode, + async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId), + ), updatePageJson: tool({ description: @@ -866,35 +788,17 @@ export class AiChatToolsService { await client.tableUpdateCell(pageId, tableRef, row, col, text), }), - copyPageContent: tool({ - description: - "Replace the target page's BODY with the source page's body " + - '(title/slug are kept). Runs server-side — no document passes ' + - 'through the model. Reversible: the target keeps page history.', - inputSchema: z.object({ - sourcePageId: z.string().describe('The id of the source page.'), - targetPageId: z - .string() - .describe('The id of the target page to overwrite.'), - }), - execute: async ({ sourcePageId, targetPageId }) => + copyPageContent: sharedTool( + sharedToolSpecs.copyPageContent, + async ({ sourcePageId, targetPageId }) => await client.copyPageContent(sourcePageId, targetPageId), - }), + ), - importPageMarkdown: tool({ - description: - "Replace a page's body from Docmost-flavoured Markdown (as produced " + - 'by exportPageMarkdown). Reversible: the previous version is kept in ' + - 'page history.', - inputSchema: z.object({ - pageId: z.string().describe('The id of the page to overwrite.'), - markdown: z - .string() - .describe('Docmost-flavoured Markdown for the page body.'), - }), - execute: async ({ pageId, markdown }) => + importPageMarkdown: sharedTool( + sharedToolSpecs.importPageMarkdown, + async ({ pageId, markdown }) => await client.importPageMarkdown(pageId, markdown), - }), + ), sharePage: tool({ description: @@ -912,27 +816,15 @@ export class AiChatToolsService { await client.sharePage(pageId, searchIndexing), }), - unsharePage: tool({ - description: - 'Remove the public share of a page (reverses sharePage).', - inputSchema: z.object({ - pageId: z.string().describe('The id of the page to unshare.'), - }), - execute: async ({ pageId }) => await client.unsharePage(pageId), - }), + unsharePage: sharedTool( + sharedToolSpecs.unsharePage, + async ({ pageId }) => await client.unsharePage(pageId), + ), - restorePageVersion: tool({ - description: - 'Restore a past version by writing its content back as the current ' + - 'page content. Itself reversible: it creates a new history snapshot.', - inputSchema: z.object({ - historyId: z - .string() - .describe('The id of the history version to restore.'), - }), - execute: async ({ historyId }) => - await client.restorePageVersion(historyId), - }), + restorePageVersion: sharedTool( + sharedToolSpecs.restorePageVersion, + async ({ historyId }) => await client.restorePageVersion(historyId), + ), transformPage: tool({ description: diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index 7773fb39..5b740cfe 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -167,8 +167,29 @@ export interface DocmostClientCtor { new (config: DocmostClientConfig): DocmostClientLike; } +/** + * Local hand-mirror of the `SharedToolSpec` shape exported from + * `@docmost/mcp` (packages/mcp/src/tool-specs.ts). Same approach as + * `DocmostClientLike`: we do not import the ESM package's types directly across + * the CJS/ESM boundary. The registry itself has no runtime deps, but keeping the + * type local avoids coupling the server build to the package's type surface. + * + * `buildShape` is intentionally zod-agnostic: it returns a plain ZodRawShape + * built with whatever zod namespace the caller passes (the server passes its own + * zod v4; the MCP package passes its zod v3). See the registry module comment. + */ +export interface SharedToolSpec { + mcpName: string; + inAppKey: string; + description: string; + // Loose `z` on purpose: the registry is zod-agnostic so the server can pass + // its own zod (v4) and the MCP package its own (v3) into the same builder. + buildShape?: (z: any) => Record; +} + interface DocmostMcpModule { DocmostClient: DocmostClientCtor; + SHARED_TOOL_SPECS: Record; } // TS with module:commonjs downlevels a literal `import()` to `require()`, which @@ -191,6 +212,7 @@ let modulePromise: Promise | null = null; */ export async function loadDocmostMcp(): Promise<{ DocmostClient: DocmostClientCtor; + sharedToolSpecs: Record; }> { if (!modulePromise) { modulePromise = (async () => { @@ -206,5 +228,15 @@ export async function loadDocmostMcp(): Promise<{ }); } const mod = await modulePromise; - return { DocmostClient: mod.DocmostClient }; + if (!mod.SHARED_TOOL_SPECS) { + // A stale @docmost/mcp build (missing the shared registry export) would + // otherwise surface as a confusing TypeError deep in the tools service. + throw new Error( + '@docmost/mcp is stale: SHARED_TOOL_SPECS missing — rebuild the package (pnpm --filter @docmost/mcp build).', + ); + } + return { + DocmostClient: mod.DocmostClient, + sharedToolSpecs: mod.SHARED_TOOL_SPECS, + }; } diff --git a/apps/server/test/integration/db.ts b/apps/server/test/integration/db.ts index bb4001c8..8cf11fdb 100644 --- a/apps/server/test/integration/db.ts +++ b/apps/server/test/integration/db.ts @@ -3,6 +3,31 @@ import { CamelCasePlugin, Kysely } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import * as postgres from 'postgres'; +/** + * db.ts — THE canonical place to seed prerequisite rows for integration tests. + * + * Seeders here use minimal, explicit `insertInto(...).values(...)` calls and are + * DELIBERATELY decoupled from the app's repo `insert*` methods. Those repo + * methods carry side effects integration specs do not want — password hashing, + * validation, default/derived columns, event emission — so reproducing only the + * columns a test needs keeps the fixtures small, fast and predictable. + * + * CONVENTIONS: + * - New entity seeders go HERE (a `createX(db, ...)` helper) rather than as raw + * `insertInto` calls scattered across spec files, so the schema knowledge + * lives in one place. + * - Each seeder inserts only the NOT NULL / uniquely-constrained columns plus + * whatever the consuming tests assert on; everything else is left to DB + * defaults. + * - Plain `randomUUID()` (v4) is fine for FK integrity; the app uses uuid v7, + * but tests never depend on id ordering. + * + * TRADE-OFF: because the column/constraint knowledge below is mirrored from the + * Kysely schema rather than derived from it, a migration that changes a NOT NULL + * column or a unique constraint can make an insert here fail. When that happens + * the fix is to update the relevant seeder, not the spec that calls it. + */ + /** * Isolated test database connection string. The dev DB is `docmost`; tests run * against a dedicated `docmost_test` that global-setup drops + recreates + @@ -58,21 +83,27 @@ export async function destroyTestDb(): Promise { } // --- Seeding helpers --------------------------------------------------------- -// Insert minimal valid rows (only the columns the tests need + NOT NULL ones). -// Plain randomUUID() is fine for FK integrity in tests (the app uses uuid v7). +// Each helper inserts a minimal valid row (only the columns the tests need plus +// the NOT NULL / uniquely-constrained ones) and returns the generated id. See +// the module doc comment above for why these bypass the app's repo layer. + +// Short, human-readable suffix derived from a row's uuid. Used to build unique +// names/slugs/hostnames for seeded rows so unique constraints never collide. +const shortId = (id: string): string => id.slice(0, 8); export async function createWorkspace( db: Kysely, overrides: { settings?: unknown; name?: string } = {}, ): Promise<{ id: string; settings: any }> { const id = randomUUID(); + const suffix = shortId(id); const row = await db .insertInto('workspaces') .values({ id, - name: overrides.name ?? `ws-${id.slice(0, 8)}`, + name: overrides.name ?? `ws-${suffix}`, // hostname is uniquely constrained; keep it unique per workspace. - hostname: `host-${id.slice(0, 8)}`, + hostname: `host-${suffix}`, settings: overrides.settings === undefined ? null : (overrides.settings as any), }) .returning(['id', 'settings']) @@ -86,12 +117,13 @@ export async function createUser( overrides: { email?: string; name?: string } = {}, ): Promise<{ id: string }> { const id = randomUUID(); + const suffix = shortId(id); const row = await db .insertInto('users') .values({ id, - email: overrides.email ?? `user-${id.slice(0, 8)}@example.test`, - name: overrides.name ?? `user-${id.slice(0, 8)}`, + email: overrides.email ?? `user-${suffix}@example.test`, + name: overrides.name ?? `user-${suffix}`, workspaceId, }) .returning(['id']) @@ -105,13 +137,14 @@ export async function createSpace( overrides: { slug?: string; name?: string } = {}, ): Promise<{ id: string }> { const id = randomUUID(); + const suffix = shortId(id); const row = await db .insertInto('spaces') .values({ id, - name: overrides.name ?? `space-${id.slice(0, 8)}`, + name: overrides.name ?? `space-${suffix}`, // slug is unique per workspace + NOT NULL. - slug: overrides.slug ?? `space-${id.slice(0, 8)}`, + slug: overrides.slug ?? `space-${suffix}`, workspaceId, }) .returning(['id']) @@ -124,13 +157,14 @@ export async function createPage( args: { workspaceId: string; spaceId: string; title?: string }, ): Promise<{ id: string }> { const id = randomUUID(); + const suffix = shortId(id); const row = await db .insertInto('pages') .values({ id, // slug_id is NOT NULL + globally unique. - slugId: `slug-${id.slice(0, 8)}`, - title: args.title ?? `page-${id.slice(0, 8)}`, + slugId: `slug-${suffix}`, + title: args.title ?? `page-${suffix}`, spaceId: args.spaceId, workspaceId: args.workspaceId, }) @@ -186,7 +220,7 @@ export async function createChat( workspaceId: args.workspaceId, creatorId: args.creatorId, roleId: args.roleId ?? null, - title: args.title ?? `chat-${id.slice(0, 8)}`, + title: args.title ?? `chat-${shortId(id)}`, }) .returning(['id']) .executeTakeFirstOrThrow(); diff --git a/packages/mcp/build/index.js b/packages/mcp/build/index.js index efc101fc..b0c20413 100644 --- a/packages/mcp/build/index.js +++ b/packages/mcp/build/index.js @@ -5,10 +5,15 @@ import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { DocmostClient } from "./client.js"; import { parseNodeArg } from "./lib/parse-node-arg.js"; +import { SHARED_TOOL_SPECS } from "./tool-specs.js"; // Re-export the client and its config type so embedding hosts (e.g. the gitmost // NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient // directly — for the credentials variant OR the per-user getToken variant. export { DocmostClient } from "./client.js"; +// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK +// service can read it off the loaded module (it cannot import the ESM package's +// internals directly; it goes through loadDocmostMcp()). +export { SHARED_TOOL_SPECS } from "./tool-specs.js"; // Read version from package.json const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -46,17 +51,27 @@ export function createDocmostMcpServer(config) { name: "docmost-mcp", version: VERSION, }, { instructions: SERVER_INSTRUCTIONS }); + // 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, execute) => server.registerTool(spec.mcpName, spec.buildShape + ? { description: spec.description, inputSchema: spec.buildShape(z) } + : { description: spec.description }, execute); // Tool: get_workspace - server.registerTool("get_workspace", { - description: "Get the current Docmost workspace", - }, async () => { + registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => { const workspace = await docmostClient.getWorkspace(); return jsonContent(workspace); }); // Tool: list_spaces - server.registerTool("list_spaces", { - description: "List all available spaces in Docmost", - }, async () => { + registerShared(SHARED_TOOL_SPECS.listSpaces, async () => { const spaces = await docmostClient.getSpaces(); return jsonContent(spaces); }); @@ -97,43 +112,17 @@ export function createDocmostMcpServer(config) { return jsonContent(page); }); // Tool: get_page_json - server.registerTool("get_page_json", { - description: "Get page details with the raw ProseMirror JSON content (lossless: " + - "includes block ids, callouts, tables, link/image attributes) plus the " + - "slugId used in URLs. Use together with update_page_json for precise " + - "structural edits, or edit_page_text for simple text fixes.", - inputSchema: { - pageId: z.string().min(1), - }, - }, async ({ pageId }) => { + registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => { const page = await docmostClient.getPageJson(pageId); return jsonContent(page); }); // Tool: get_outline - server.registerTool("get_outline", { - description: "Return a COMPACT outline of a page's top-level blocks ({index, type, " + - "id, level, firstText}; tables add rows/cols/header; lists add item " + - "count) WITHOUT the full document body. Use it to locate sections/tables " + - "and grab block ids cheaply before get_node / patch_node / insert_node.", - inputSchema: { - pageId: z.string().min(1), - }, - }, async ({ pageId }) => { + registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => { const result = await docmostClient.getOutline(pageId); return jsonContent(result); }); // Tool: get_node - server.registerTool("get_node", { - description: "Fetch a single node's full ProseMirror subtree (lossless) without " + - "pulling the whole document. `nodeId` is a block id from get_outline/" + - "get_page_json (works for headings/paragraphs/callouts/images), OR " + - "`#` to fetch a top-level block by its outline index — use the " + - "`#` form for tables/rows/cells, which carry no id.", - inputSchema: { - pageId: z.string().min(1), - nodeId: z.string().min(1), - }, - }, async ({ pageId, nodeId }) => { + registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => { const result = await docmostClient.getNode(pageId, nodeId); return jsonContent(result); }); @@ -270,35 +259,12 @@ export function createDocmostMcpServer(config) { return { content: [{ type: "text", text: md }] }; }); // Tool: import_page_markdown - server.registerTool("import_page_markdown", { - description: "Replace a page's content from a self-contained Docmost-flavoured " + - "Markdown file produced by export_page_markdown. Restores comment " + - "highlight anchors and diagrams from their inline HTML. NOTE: comment " + - "thread records are NOT created/updated/deleted on the server by this " + - "tool — only the page body + inline comment marks are written; manage " + - "comment threads via the comment tools/UI.", - inputSchema: { - pageId: z.string().min(1), - markdown: z.string().min(1), - }, - }, async ({ pageId, markdown }) => { + registerShared(SHARED_TOOL_SPECS.importPageMarkdown, async ({ pageId, markdown }) => { const res = await docmostClient.importPageMarkdown(pageId, markdown); return jsonContent(res); }); // Tool: copy_page_content - server.registerTool("copy_page_content", { - description: "Replace targetPageId's content with a copy of sourcePageId's content, " + - "entirely server-side — the document is NOT sent through the model. The " + - "target keeps its own title and slug; only its body is replaced. Ideal " + - "for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.", - inputSchema: { - sourcePageId: z.string().min(1).describe("Page to copy content FROM"), - targetPageId: z - .string() - .min(1) - .describe("Page whose content is REPLACED (title/slug kept)"), - }, - }, async ({ sourcePageId, targetPageId }) => { + registerShared(SHARED_TOOL_SPECS.copyPageContent, async ({ sourcePageId, targetPageId }) => { const result = await docmostClient.copyPageContent(sourcePageId, targetPageId); return jsonContent(result); }); @@ -315,40 +281,7 @@ export function createDocmostMcpServer(config) { return jsonContent(result); }); // Tool: edit_page_text - server.registerTool("edit_page_text", { - description: "Surgical find/replace inside a page's text. Preserves ALL structure: " + - "block ids, marks, links, callouts, tables. A `find` MAY cross " + - "bold/italic/link boundaries; the replacement inherits marks from the " + - "unchanged common prefix/suffix (editing plain text next to a bold word " + - "keeps it bold; editing inside a bold word keeps the new text bold). " + - "Each `find` must match exactly once (or set replaceAll). The batch " + - "applies what it can and returns applied[] + failed[]; a fully-unmatched " + - "batch writes nothing and errors. `find` should be the literal rendered " + - "text (no markdown). Markdown wrappers (**bold**, *italic*, `code`) and " + - "trailing emoji are tolerated via a strip-and-retry fallback, but plain " + - "text is preferred. Examples: edits:[{find:\"teh\"," + - "replace:\"the\"}]; edits:[{find:\"Hello world\",replace:\"Hello there\"}] " + - "(crosses a bold boundary). This is the preferred tool for fixing " + - "wording, typos, numbers, names. It edits plain text only and CANNOT " + - "change formatting marks: formatting changes (markdown markers in " + - "find/replace) are refused — use patch_node/update_page_json to change " + - "marks. The result includes a `verify` change-report of what actually " + - "changed (text/block/mark deltas).", - inputSchema: { - pageId: z.string().describe("ID of the page to edit"), - edits: z - .array(z.object({ - find: z.string().describe("Exact text to find"), - replace: z.string().describe("Replacement text (may be empty)"), - replaceAll: z - .boolean() - .optional() - .describe("Replace every occurrence (default: must match once)"), - })) - .min(1) - .describe("List of find/replace operations, applied in order"), - }, - }, async ({ pageId, edits }) => { + registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => { const result = await docmostClient.editPageText(pageId, edits); return jsonContent(result); }); @@ -417,14 +350,7 @@ export function createDocmostMcpServer(config) { return jsonContent(result); }); // Tool: delete_node - server.registerTool("delete_node", { - description: "Remove a single block by its attrs.id (from get_page_json) WITHOUT " + - "resending the whole document.", - inputSchema: { - pageId: z.string().min(1), - nodeId: z.string().min(1), - }, - }, async ({ pageId, nodeId }) => { + registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => { const result = await docmostClient.deleteNode(pageId, nodeId); return jsonContent(result); }); @@ -510,19 +436,12 @@ export function createDocmostMcpServer(config) { return jsonContent(result); }); // Tool: unshare_page - server.registerTool("unshare_page", { - description: "Remove the public share of a page (revokes the public URL).", - inputSchema: { - pageId: z.string().min(1).describe("ID of the page to unshare"), - }, - }, async ({ pageId }) => { + registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => { const result = await docmostClient.unsharePage(pageId); return jsonContent(result); }); // Tool: list_shares - server.registerTool("list_shares", { - description: "List all public shares in the workspace with page titles and public URLs.", - }, async () => { + registerShared(SHARED_TOOL_SPECS.listShares, async () => { const result = await docmostClient.listShares(); return jsonContent(result); }); @@ -747,55 +666,17 @@ export function createDocmostMcpServer(config) { return jsonContent(result); }); // Tool: diff_page_versions - server.registerTool("diff_page_versions", { - description: "Diff two versions of a page and return a Docmost-equivalent change set " + - "(inserted/deleted text, integrity counts for images/links/tables/" + - "callouts/footnote markers, and a human-readable markdown summary). " + - "`from`/`to` each accept a historyId, or null/'current' for the page's " + - "current content (defaults: from=current, to=current — pass a historyId " + - "from list_page_history to compare against the live page).", - inputSchema: { - pageId: z.string().min(1), - from: z - .string() - .optional() - .describe("historyId, or 'current'/omit for current content"), - to: z - .string() - .optional() - .describe("historyId, or 'current'/omit for current content"), - }, - }, async ({ pageId, from, to }) => { + registerShared(SHARED_TOOL_SPECS.diffPageVersions, async ({ pageId, from, to }) => { const result = await docmostClient.diffPageVersions(pageId, from, to); return jsonContent(result); }); // Tool: list_page_history - server.registerTool("list_page_history", { - description: "List a page's saved versions (Docmost auto-snapshots on every save), " + - "newest first, cursor-paginated. Returns { items, nextCursor }; each " + - "item's id is the historyId to pass to diff_page_versions or " + - "restore_page_version.", - inputSchema: { - pageId: z.string().min(1), - cursor: z - .string() - .optional() - .describe("Pagination cursor from a previous nextCursor"), - }, - }, async ({ pageId, cursor }) => { + registerShared(SHARED_TOOL_SPECS.listPageHistory, async ({ pageId, cursor }) => { const result = await docmostClient.listPageHistory(pageId, cursor); return jsonContent(result); }); // Tool: restore_page_version - server.registerTool("restore_page_version", { - description: "Restore a page to a saved version: writes that version's content back " + - "as the page's current content (Docmost has no restore endpoint, so " + - "this creates a NEW history snapshot — the restore is itself revertible). " + - "Get the historyId from list_page_history.", - inputSchema: { - historyId: z.string().min(1), - }, - }, async ({ historyId }) => { + registerShared(SHARED_TOOL_SPECS.restorePageVersion, async ({ historyId }) => { const result = await docmostClient.restorePageVersion(historyId); return jsonContent(result); }); diff --git a/packages/mcp/build/lib/docmost-schema.js b/packages/mcp/build/lib/docmost-schema.js index e89ed5a0..976e2d7f 100644 --- a/packages/mcp/build/lib/docmost-schema.js +++ b/packages/mcp/build/lib/docmost-schema.js @@ -732,6 +732,59 @@ const Embed = Node.create({ return ["div", { "data-type": "embed", ...HTMLAttributes }, 0]; }, }); +/** + * Docmost raw HTML embed. Block atom; the client renders `source` inside a + * sandboxed iframe. The MCP server never renders it — it only needs the + * schema to accept and carry the node so a fromYdoc -> transform -> toYdoc + * round-trip does not throw "Unknown node type: htmlEmbed". Mirrors the + * @docmost/editor-ext node name, attribute keys and flags; keep in sync when + * the editor-ext htmlEmbed schema changes. + * + * NOTE: unlike the canonical editor-ext node, `data-source` here is mapped as + * plain text rather than base64-encoded. That is intentional: the MCP write + * path carries the node through Yjs (fromYdoc -> toYdoc) on its JSON `source` + * attribute and never invokes parseHTML/renderHTML, and htmlEmbed is not + * produced from the markdown/HTML (generateJSON) path. If a future HTML path + * for htmlEmbed is added here, this mapping must adopt editor-ext's base64 + * encode/decode to avoid double-encoding `source`. + */ +const HtmlEmbed = Node.create({ + name: "htmlEmbed", + group: "block", + inline: false, + isolating: true, + atom: true, + defining: true, + draggable: true, + addAttributes() { + return { + source: { + default: "", + parseHTML: (el) => el.getAttribute("data-source") ?? "", + renderHTML: (attrs) => ({ + "data-source": attrs.source ?? "", + }), + }, + height: { + default: null, + parseHTML: (el) => { + const v = el.getAttribute("data-height"); + if (!v) + return null; + const n = parseInt(v, 10); + return Number.isFinite(n) ? n : null; + }, + renderHTML: (attrs) => attrs.height != null ? { "data-height": String(attrs.height) } : {}, + }, + }; + }, + parseHTML() { + return [{ tag: 'div[data-type="htmlEmbed"]' }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { "data-type": "htmlEmbed", ...HTMLAttributes }, 0]; + }, +}); /** Shared attribute set for drawio/excalidraw diagram nodes. */ const diagramAttributes = () => ({ src: { @@ -1062,6 +1115,7 @@ export const docmostExtensions = [ Video, Youtube, Embed, + HtmlEmbed, Drawio, Excalidraw, Columns, diff --git a/packages/mcp/build/tool-specs.js b/packages/mcp/build/tool-specs.js new file mode 100644 index 00000000..d834e657 --- /dev/null +++ b/packages/mcp/build/tool-specs.js @@ -0,0 +1,212 @@ +// Zod-agnostic shared tool-spec registry consumed by BOTH the zod-v3 MCP server +// (packages/mcp/src/index.ts) and the zod-v4 in-app AI-SDK service +// (apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts). Intentionally +// imports NO zod: each consumer passes its OWN zod namespace into buildShape, +// because the two packages are on different zod majors (v3 here, v4 in the +// server) and a zod schema object built with one major cannot be reused by the +// other. The builders below only touch z.string()/.min()/.optional()/.describe(), +// z.array() and z.object() — API identical across v3 and v4 — so a single +// builder works with either namespace. +// +// Only tools whose snake_case/camelCase name, input schema AND model-facing +// description are genuinely identical across both layers live here. Tools that +// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on +// some write tools, different limits, hybrid-RRF search, etc.) stay defined +// per-layer and are NOT represented here. +export const SHARED_TOOL_SPECS = { + // --- no-argument read tools --- + getWorkspace: { + mcpName: 'get_workspace', + inAppKey: 'getWorkspace', + description: 'Fetch metadata about the current workspace (name, settings).', + }, + listSpaces: { + mcpName: 'list_spaces', + inAppKey: 'listSpaces', + description: 'List the spaces the current user can access. Returns the array of ' + + 'spaces (id, name, slug, ...).', + }, + listShares: { + mcpName: 'list_shares', + inAppKey: 'listShares', + description: 'List all public shares in the workspace with page titles and public URLs.', + }, + // --- single-pageId read tools --- + getPageJson: { + mcpName: 'get_page_json', + inAppKey: 'getPageJson', + description: 'Get page details with the raw ProseMirror JSON content (lossless: ' + + 'includes block ids, callouts, tables, link/image attributes) plus the ' + + 'slugId used in URLs. Use the block ids it returns to make precise ' + + 'structural edits or surgical text edits without resending the page.', + buildShape: (z) => ({ + pageId: z.string().min(1), + }), + }, + getOutline: { + mcpName: 'get_outline', + inAppKey: 'getOutline', + description: "Return a COMPACT outline of a page's top-level blocks ({index, type, " + + 'id, level, firstText}; tables add rows/cols/header; lists add item ' + + 'count) WITHOUT the full document body. Use it to locate sections/tables ' + + 'and grab block ids cheaply before fetching, patching or inserting ' + + 'individual blocks.', + buildShape: (z) => ({ + pageId: z.string().min(1), + }), + }, + // --- two-id read tool --- + getNode: { + mcpName: 'get_node', + inAppKey: 'getNode', + description: "Fetch a single node's full ProseMirror subtree (lossless) without " + + 'pulling the whole document. `nodeId` is a block id from the page ' + + 'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' + + '`#` to fetch a top-level block by its outline index — use the ' + + '`#` form for tables/rows/cells, which carry no id.', + buildShape: (z) => ({ + pageId: z.string().min(1), + nodeId: z.string().min(1), + }), + }, + // --- node delete --- + deleteNode: { + mcpName: 'delete_node', + inAppKey: 'deleteNode', + description: 'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' + + 'resending the whole document.', + buildShape: (z) => ({ + pageId: z.string().min(1), + nodeId: z.string().min(1), + }), + }, + // --- share management --- + unsharePage: { + mcpName: 'unshare_page', + inAppKey: 'unsharePage', + description: 'Remove the public share of a page (revokes the public URL).', + buildShape: (z) => ({ + pageId: z.string().min(1).describe('ID of the page to unshare'), + }), + }, + // --- version history --- + diffPageVersions: { + mcpName: 'diff_page_versions', + inAppKey: 'diffPageVersions', + description: 'Diff two versions of a page and return a Docmost-equivalent change set ' + + '(inserted/deleted text, integrity counts for images/links/tables/' + + 'callouts/footnote markers, and a human-readable markdown summary). ' + + "`from`/`to` each accept a historyId, or null/'current' for the page's " + + 'current content (defaults: from=current, to=current — pass a historyId ' + + 'from the page-history list to compare against the live page).', + buildShape: (z) => ({ + pageId: z.string().min(1), + from: z + .string() + .optional() + .describe("historyId, or 'current'/omit for current content"), + to: z + .string() + .optional() + .describe("historyId, or 'current'/omit for current content"), + }), + }, + listPageHistory: { + mcpName: 'list_page_history', + inAppKey: 'listPageHistory', + description: "List a page's saved versions (Docmost auto-snapshots on every save), " + + 'newest first, cursor-paginated. Returns { items, nextCursor }; each ' + + "item's id is the historyId to pass to the page diff or restore tools.", + buildShape: (z) => ({ + pageId: z.string().min(1), + cursor: z + .string() + .optional() + .describe('Pagination cursor from a previous nextCursor'), + }), + }, + restorePageVersion: { + mcpName: 'restore_page_version', + inAppKey: 'restorePageVersion', + description: 'Restore a page to a saved version: writes that version\'s content back ' + + 'as the page\'s current content (Docmost has no restore endpoint, so ' + + 'this creates a NEW history snapshot — the restore is itself revertible). ' + + 'Get the historyId from the page-history list.', + buildShape: (z) => ({ + historyId: z.string().min(1), + }), + }, + // --- markdown round-trip --- + importPageMarkdown: { + mcpName: 'import_page_markdown', + inAppKey: 'importPageMarkdown', + description: "Replace a page's content from a self-contained Docmost-flavoured " + + 'Markdown file produced by the page-Markdown export tool. Restores comment ' + + 'highlight anchors and diagrams from their inline HTML. NOTE: comment ' + + 'thread records are NOT created/updated/deleted on the server by this ' + + 'tool — only the page body + inline comment marks are written; manage ' + + 'comment threads via the comment tools/UI.', + buildShape: (z) => ({ + pageId: z.string().min(1), + markdown: z.string().min(1), + }), + }, + // --- server-side content copy --- + copyPageContent: { + mcpName: 'copy_page_content', + inAppKey: 'copyPageContent', + description: "Replace targetPageId's content with a copy of sourcePageId's content, " + + 'entirely server-side — the document is NOT sent through the model. The ' + + 'target keeps its own title and slug; only its body is replaced. Ideal ' + + "for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.", + buildShape: (z) => ({ + sourcePageId: z.string().min(1).describe('Page to copy content FROM'), + targetPageId: z + .string() + .min(1) + .describe('Page whose content is REPLACED (title/slug kept)'), + }), + }, + // --- surgical text edit (folds in the documented drift-bug fix) --- + // + // CANONICAL description is the CORRECTED in-app wording: a formatting-only + // change is REFUSED into failed[] (not silently stripped-and-retried). The + // stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry + // fallback" is intentionally absent here. + editPageText: { + mcpName: 'edit_page_text', + inAppKey: 'editPageText', + description: "Surgical find/replace inside a page's text, preserving all block " + + 'ids and marks. A find MAY cross bold/italic/link boundaries; the ' + + 'replacement inherits marks from the unchanged common prefix/suffix ' + + '(so editing plain text next to a bold word keeps it bold, and ' + + 'editing inside a bold word keeps the new text bold). Each find must ' + + 'match exactly once unless replaceAll is set. The batch applies what ' + + 'it can and returns applied[] + failed[] plus a verify change-report ' + + '(the text/marks/structure that ACTUALLY changed — read it to confirm ' + + 'your edit landed; do not assume success); a fully-unmatched batch ' + + 'writes nothing and errors. find and replace are LITERAL text, not ' + + 'markdown. This tool edits plain text ONLY and CANNOT add or remove ' + + 'formatting marks: a formatting change — find/replace that differ only ' + + 'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' + + 'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' + + 'failed[]. To change bold/italic/strike/code/link, read the block as ' + + 'page JSON and use a structural node patch/update to set its marks. ' + + 'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' + + 'world",replace:"Hello there"}] (crosses a bold boundary).', + buildShape: (z) => ({ + pageId: z.string().describe('ID of the page to edit'), + edits: z + .array(z.object({ + find: z.string().describe('Exact text to find'), + replace: z.string().describe('Replacement text (may be empty)'), + replaceAll: z + .boolean() + .optional() + .describe('Replace every occurrence (default: must match once)'), + })) + .min(1) + .describe('List of find/replace operations, applied in order'), + }), + }, +}; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 09bd2142..54e40bca 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { DocmostClient, DocmostMcpConfig } from "./client.js"; import { parseNodeArg } from "./lib/parse-node-arg.js"; +import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; // Re-export the client and its config type so embedding hosts (e.g. the gitmost // NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient @@ -12,6 +13,12 @@ import { parseNodeArg } from "./lib/parse-node-arg.js"; export { DocmostClient } from "./client.js"; export type { DocmostMcpConfig } from "./client.js"; +// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK +// service can read it off the loaded module (it cannot import the ESM package's +// internals directly; it goes through loadDocmostMcp()). +export { SHARED_TOOL_SPECS } from "./tool-specs.js"; +export type { SharedToolSpec } from "./tool-specs.js"; + // Read version from package.json const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -62,29 +69,40 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer { { instructions: SERVER_INSTRUCTIONS }, ); + // 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)( + spec.mcpName, + spec.buildShape + ? { description: spec.description, inputSchema: spec.buildShape(z) } + : { description: spec.description }, + execute, + ); + // Tool: get_workspace - server.registerTool( - "get_workspace", - { - description: "Get the current Docmost workspace", - }, - async () => { + registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => { const workspace = await docmostClient.getWorkspace(); return jsonContent(workspace); - }, -); + }); -// Tool: list_spaces -server.registerTool( - "list_spaces", - { - description: "List all available spaces in Docmost", - }, - async () => { + // Tool: list_spaces + registerShared(SHARED_TOOL_SPECS.listSpaces, async () => { const spaces = await docmostClient.getSpaces(); return jsonContent(spaces); - }, -); + }); // Tool: list_pages server.registerTool( @@ -137,63 +155,22 @@ server.registerTool( ); // Tool: get_page_json -server.registerTool( - "get_page_json", - { - description: - "Get page details with the raw ProseMirror JSON content (lossless: " + - "includes block ids, callouts, tables, link/image attributes) plus the " + - "slugId used in URLs. Use together with update_page_json for precise " + - "structural edits, or edit_page_text for simple text fixes.", - inputSchema: { - pageId: z.string().min(1), - }, - }, - async ({ pageId }) => { - const page = await docmostClient.getPageJson(pageId); - return jsonContent(page); - }, -); +registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => { + const page = await docmostClient.getPageJson(pageId); + return jsonContent(page); +}); // Tool: get_outline -server.registerTool( - "get_outline", - { - description: - "Return a COMPACT outline of a page's top-level blocks ({index, type, " + - "id, level, firstText}; tables add rows/cols/header; lists add item " + - "count) WITHOUT the full document body. Use it to locate sections/tables " + - "and grab block ids cheaply before get_node / patch_node / insert_node.", - inputSchema: { - pageId: z.string().min(1), - }, - }, - async ({ pageId }) => { - const result = await docmostClient.getOutline(pageId); - return jsonContent(result); - }, -); +registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => { + const result = await docmostClient.getOutline(pageId); + return jsonContent(result); +}); // Tool: get_node -server.registerTool( - "get_node", - { - description: - "Fetch a single node's full ProseMirror subtree (lossless) without " + - "pulling the whole document. `nodeId` is a block id from get_outline/" + - "get_page_json (works for headings/paragraphs/callouts/images), OR " + - "`#` to fetch a top-level block by its outline index — use the " + - "`#` form for tables/rows/cells, which carry no id.", - inputSchema: { - pageId: z.string().min(1), - nodeId: z.string().min(1), - }, - }, - async ({ pageId, nodeId }) => { - const result = await docmostClient.getNode(pageId, nodeId); - return jsonContent(result); - }, -); +registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => { + const result = await docmostClient.getNode(pageId, nodeId); + return jsonContent(result); +}); // Tool: table_get server.registerTool( @@ -387,21 +364,8 @@ server.registerTool( ); // Tool: import_page_markdown -server.registerTool( - "import_page_markdown", - { - description: - "Replace a page's content from a self-contained Docmost-flavoured " + - "Markdown file produced by export_page_markdown. Restores comment " + - "highlight anchors and diagrams from their inline HTML. NOTE: comment " + - "thread records are NOT created/updated/deleted on the server by this " + - "tool — only the page body + inline comment marks are written; manage " + - "comment threads via the comment tools/UI.", - inputSchema: { - pageId: z.string().min(1), - markdown: z.string().min(1), - }, - }, +registerShared( + SHARED_TOOL_SPECS.importPageMarkdown, async ({ pageId, markdown }) => { const res = await docmostClient.importPageMarkdown(pageId, markdown); return jsonContent(res); @@ -409,22 +373,8 @@ server.registerTool( ); // Tool: copy_page_content -server.registerTool( - "copy_page_content", - { - description: - "Replace targetPageId's content with a copy of sourcePageId's content, " + - "entirely server-side — the document is NOT sent through the model. The " + - "target keeps its own title and slug; only its body is replaced. Ideal " + - "for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.", - inputSchema: { - sourcePageId: z.string().min(1).describe("Page to copy content FROM"), - targetPageId: z - .string() - .min(1) - .describe("Page whose content is REPLACED (title/slug kept)"), - }, - }, +registerShared( + SHARED_TOOL_SPECS.copyPageContent, async ({ sourcePageId, targetPageId }) => { const result = await docmostClient.copyPageContent( sourcePageId, @@ -453,50 +403,10 @@ server.registerTool( ); // Tool: edit_page_text -server.registerTool( - "edit_page_text", - { - description: - "Surgical find/replace inside a page's text. Preserves ALL structure: " + - "block ids, marks, links, callouts, tables. A `find` MAY cross " + - "bold/italic/link boundaries; the replacement inherits marks from the " + - "unchanged common prefix/suffix (editing plain text next to a bold word " + - "keeps it bold; editing inside a bold word keeps the new text bold). " + - "Each `find` must match exactly once (or set replaceAll). The batch " + - "applies what it can and returns applied[] + failed[]; a fully-unmatched " + - "batch writes nothing and errors. `find` should be the literal rendered " + - "text (no markdown). Markdown wrappers (**bold**, *italic*, `code`) and " + - "trailing emoji are tolerated via a strip-and-retry fallback, but plain " + - "text is preferred. Examples: edits:[{find:\"teh\"," + - "replace:\"the\"}]; edits:[{find:\"Hello world\",replace:\"Hello there\"}] " + - "(crosses a bold boundary). This is the preferred tool for fixing " + - "wording, typos, numbers, names. It edits plain text only and CANNOT " + - "change formatting marks: formatting changes (markdown markers in " + - "find/replace) are refused — use patch_node/update_page_json to change " + - "marks. The result includes a `verify` change-report of what actually " + - "changed (text/block/mark deltas).", - inputSchema: { - pageId: z.string().describe("ID of the page to edit"), - edits: z - .array( - z.object({ - find: z.string().describe("Exact text to find"), - replace: z.string().describe("Replacement text (may be empty)"), - replaceAll: z - .boolean() - .optional() - .describe("Replace every occurrence (default: must match once)"), - }), - ) - .min(1) - .describe("List of find/replace operations, applied in order"), - }, - }, - async ({ pageId, edits }) => { - const result = await docmostClient.editPageText(pageId, edits); - return jsonContent(result); - }, -); +registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => { + const result = await docmostClient.editPageText(pageId, edits); + return jsonContent(result); +}); // Tool: patch_node server.registerTool( @@ -579,22 +489,10 @@ server.registerTool( ); // Tool: delete_node -server.registerTool( - "delete_node", - { - description: - "Remove a single block by its attrs.id (from get_page_json) WITHOUT " + - "resending the whole document.", - inputSchema: { - pageId: z.string().min(1), - nodeId: z.string().min(1), - }, - }, - async ({ pageId, nodeId }) => { - const result = await docmostClient.deleteNode(pageId, nodeId); - return jsonContent(result); - }, -); +registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => { + const result = await docmostClient.deleteNode(pageId, nodeId); + return jsonContent(result); +}); // Tool: insert_image server.registerTool( @@ -705,32 +603,16 @@ server.registerTool( ); // Tool: unshare_page -server.registerTool( - "unshare_page", - { - description: "Remove the public share of a page (revokes the public URL).", - inputSchema: { - pageId: z.string().min(1).describe("ID of the page to unshare"), - }, - }, - async ({ pageId }) => { - const result = await docmostClient.unsharePage(pageId); - return jsonContent(result); - }, -); +registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => { + const result = await docmostClient.unsharePage(pageId); + return jsonContent(result); +}); // Tool: list_shares -server.registerTool( - "list_shares", - { - description: - "List all public shares in the workspace with page titles and public URLs.", - }, - async () => { - const result = await docmostClient.listShares(); - return jsonContent(result); - }, -); +registerShared(SHARED_TOOL_SPECS.listShares, async () => { + const result = await docmostClient.listShares(); + return jsonContent(result); +}); // Tool: move_page server.registerTool( @@ -1046,28 +928,8 @@ server.registerTool( ); // Tool: diff_page_versions -server.registerTool( - "diff_page_versions", - { - description: - "Diff two versions of a page and return a Docmost-equivalent change set " + - "(inserted/deleted text, integrity counts for images/links/tables/" + - "callouts/footnote markers, and a human-readable markdown summary). " + - "`from`/`to` each accept a historyId, or null/'current' for the page's " + - "current content (defaults: from=current, to=current — pass a historyId " + - "from list_page_history to compare against the live page).", - inputSchema: { - pageId: z.string().min(1), - from: z - .string() - .optional() - .describe("historyId, or 'current'/omit for current content"), - to: z - .string() - .optional() - .describe("historyId, or 'current'/omit for current content"), - }, - }, +registerShared( + SHARED_TOOL_SPECS.diffPageVersions, async ({ pageId, from, to }) => { const result = await docmostClient.diffPageVersions(pageId, from, to); return jsonContent(result); @@ -1075,22 +937,8 @@ server.registerTool( ); // Tool: list_page_history -server.registerTool( - "list_page_history", - { - description: - "List a page's saved versions (Docmost auto-snapshots on every save), " + - "newest first, cursor-paginated. Returns { items, nextCursor }; each " + - "item's id is the historyId to pass to diff_page_versions or " + - "restore_page_version.", - inputSchema: { - pageId: z.string().min(1), - cursor: z - .string() - .optional() - .describe("Pagination cursor from a previous nextCursor"), - }, - }, +registerShared( + SHARED_TOOL_SPECS.listPageHistory, async ({ pageId, cursor }) => { const result = await docmostClient.listPageHistory(pageId, cursor); return jsonContent(result); @@ -1098,18 +946,8 @@ server.registerTool( ); // Tool: restore_page_version -server.registerTool( - "restore_page_version", - { - description: - "Restore a page to a saved version: writes that version's content back " + - "as the page's current content (Docmost has no restore endpoint, so " + - "this creates a NEW history snapshot — the restore is itself revertible). " + - "Get the historyId from list_page_history.", - inputSchema: { - historyId: z.string().min(1), - }, - }, +registerShared( + SHARED_TOOL_SPECS.restorePageVersion, async ({ historyId }) => { const result = await docmostClient.restorePageVersion(historyId); return jsonContent(result); diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts new file mode 100644 index 00000000..8f689c64 --- /dev/null +++ b/packages/mcp/src/tool-specs.ts @@ -0,0 +1,269 @@ +// Zod-agnostic shared tool-spec registry consumed by BOTH the zod-v3 MCP server +// (packages/mcp/src/index.ts) and the zod-v4 in-app AI-SDK service +// (apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts). Intentionally +// imports NO zod: each consumer passes its OWN zod namespace into buildShape, +// because the two packages are on different zod majors (v3 here, v4 in the +// server) and a zod schema object built with one major cannot be reused by the +// other. The builders below only touch z.string()/.min()/.optional()/.describe(), +// z.array() and z.object() — API identical across v3 and v4 — so a single +// builder works with either namespace. +// +// Only tools whose snake_case/camelCase name, input schema AND model-facing +// description are genuinely identical across both layers live here. Tools that +// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on +// some write tools, different limits, hybrid-RRF search, etc.) stay defined +// per-layer and are NOT represented here. + +// Loose on purpose — see the comment above. The two zod majors expose different +// static type surfaces, so typing this precisely would couple the registry to +// one of them. Each builder uses only the common, stable subset of the API. +type ZodLike = any; + +export interface SharedToolSpec { + /** snake_case tool name passed to McpServer.registerTool. */ + mcpName: string; + /** camelCase key in the ai-SDK tools object (the in-app layer). */ + inAppKey: string; + /** Single canonical model-facing description used by both layers. */ + description: string; + /** + * Builds the tool's input schema as a plain object of zod fields (a + * ZodRawShape). Called with the consumer's own zod namespace. Omitted for + * no-argument tools (the MCP side then registers with no inputSchema and the + * in-app side uses z.object({})). + */ + buildShape?: (z: ZodLike) => Record; +} + +export const SHARED_TOOL_SPECS = { + // --- no-argument read tools --- + + getWorkspace: { + mcpName: 'get_workspace', + inAppKey: 'getWorkspace', + description: 'Fetch metadata about the current workspace (name, settings).', + }, + + listSpaces: { + mcpName: 'list_spaces', + inAppKey: 'listSpaces', + description: + 'List the spaces the current user can access. Returns the array of ' + + 'spaces (id, name, slug, ...).', + }, + + listShares: { + mcpName: 'list_shares', + inAppKey: 'listShares', + description: + 'List all public shares in the workspace with page titles and public URLs.', + }, + + // --- single-pageId read tools --- + + getPageJson: { + mcpName: 'get_page_json', + inAppKey: 'getPageJson', + description: + 'Get page details with the raw ProseMirror JSON content (lossless: ' + + 'includes block ids, callouts, tables, link/image attributes) plus the ' + + 'slugId used in URLs. Use the block ids it returns to make precise ' + + 'structural edits or surgical text edits without resending the page.', + buildShape: (z) => ({ + pageId: z.string().min(1), + }), + }, + + getOutline: { + mcpName: 'get_outline', + inAppKey: 'getOutline', + description: + "Return a COMPACT outline of a page's top-level blocks ({index, type, " + + 'id, level, firstText}; tables add rows/cols/header; lists add item ' + + 'count) WITHOUT the full document body. Use it to locate sections/tables ' + + 'and grab block ids cheaply before fetching, patching or inserting ' + + 'individual blocks.', + buildShape: (z) => ({ + pageId: z.string().min(1), + }), + }, + + // --- two-id read tool --- + + getNode: { + mcpName: 'get_node', + inAppKey: 'getNode', + description: + "Fetch a single node's full ProseMirror subtree (lossless) without " + + 'pulling the whole document. `nodeId` is a block id from the page ' + + 'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' + + '`#` to fetch a top-level block by its outline index — use the ' + + '`#` form for tables/rows/cells, which carry no id.', + buildShape: (z) => ({ + pageId: z.string().min(1), + nodeId: z.string().min(1), + }), + }, + + // --- node delete --- + + deleteNode: { + mcpName: 'delete_node', + inAppKey: 'deleteNode', + description: + 'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' + + 'resending the whole document.', + buildShape: (z) => ({ + pageId: z.string().min(1), + nodeId: z.string().min(1), + }), + }, + + // --- share management --- + + unsharePage: { + mcpName: 'unshare_page', + inAppKey: 'unsharePage', + description: 'Remove the public share of a page (revokes the public URL).', + buildShape: (z) => ({ + pageId: z.string().min(1).describe('ID of the page to unshare'), + }), + }, + + // --- version history --- + + diffPageVersions: { + mcpName: 'diff_page_versions', + inAppKey: 'diffPageVersions', + description: + 'Diff two versions of a page and return a Docmost-equivalent change set ' + + '(inserted/deleted text, integrity counts for images/links/tables/' + + 'callouts/footnote markers, and a human-readable markdown summary). ' + + "`from`/`to` each accept a historyId, or null/'current' for the page's " + + 'current content (defaults: from=current, to=current — pass a historyId ' + + 'from the page-history list to compare against the live page).', + buildShape: (z) => ({ + pageId: z.string().min(1), + from: z + .string() + .optional() + .describe("historyId, or 'current'/omit for current content"), + to: z + .string() + .optional() + .describe("historyId, or 'current'/omit for current content"), + }), + }, + + listPageHistory: { + mcpName: 'list_page_history', + inAppKey: 'listPageHistory', + description: + "List a page's saved versions (Docmost auto-snapshots on every save), " + + 'newest first, cursor-paginated. Returns { items, nextCursor }; each ' + + "item's id is the historyId to pass to the page diff or restore tools.", + buildShape: (z) => ({ + pageId: z.string().min(1), + cursor: z + .string() + .optional() + .describe('Pagination cursor from a previous nextCursor'), + }), + }, + + restorePageVersion: { + mcpName: 'restore_page_version', + inAppKey: 'restorePageVersion', + description: + 'Restore a page to a saved version: writes that version\'s content back ' + + 'as the page\'s current content (Docmost has no restore endpoint, so ' + + 'this creates a NEW history snapshot — the restore is itself revertible). ' + + 'Get the historyId from the page-history list.', + buildShape: (z) => ({ + historyId: z.string().min(1), + }), + }, + + // --- markdown round-trip --- + + importPageMarkdown: { + mcpName: 'import_page_markdown', + inAppKey: 'importPageMarkdown', + description: + "Replace a page's content from a self-contained Docmost-flavoured " + + 'Markdown file produced by the page-Markdown export tool. Restores comment ' + + 'highlight anchors and diagrams from their inline HTML. NOTE: comment ' + + 'thread records are NOT created/updated/deleted on the server by this ' + + 'tool — only the page body + inline comment marks are written; manage ' + + 'comment threads via the comment tools/UI.', + buildShape: (z) => ({ + pageId: z.string().min(1), + markdown: z.string().min(1), + }), + }, + + // --- server-side content copy --- + + copyPageContent: { + mcpName: 'copy_page_content', + inAppKey: 'copyPageContent', + description: + "Replace targetPageId's content with a copy of sourcePageId's content, " + + 'entirely server-side — the document is NOT sent through the model. The ' + + 'target keeps its own title and slug; only its body is replaced. Ideal ' + + "for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.", + buildShape: (z) => ({ + sourcePageId: z.string().min(1).describe('Page to copy content FROM'), + targetPageId: z + .string() + .min(1) + .describe('Page whose content is REPLACED (title/slug kept)'), + }), + }, + + // --- surgical text edit (folds in the documented drift-bug fix) --- + // + // CANONICAL description is the CORRECTED in-app wording: a formatting-only + // change is REFUSED into failed[] (not silently stripped-and-retried). The + // stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry + // fallback" is intentionally absent here. + editPageText: { + mcpName: 'edit_page_text', + inAppKey: 'editPageText', + description: + "Surgical find/replace inside a page's text, preserving all block " + + 'ids and marks. A find MAY cross bold/italic/link boundaries; the ' + + 'replacement inherits marks from the unchanged common prefix/suffix ' + + '(so editing plain text next to a bold word keeps it bold, and ' + + 'editing inside a bold word keeps the new text bold). Each find must ' + + 'match exactly once unless replaceAll is set. The batch applies what ' + + 'it can and returns applied[] + failed[] plus a verify change-report ' + + '(the text/marks/structure that ACTUALLY changed — read it to confirm ' + + 'your edit landed; do not assume success); a fully-unmatched batch ' + + 'writes nothing and errors. find and replace are LITERAL text, not ' + + 'markdown. This tool edits plain text ONLY and CANNOT add or remove ' + + 'formatting marks: a formatting change — find/replace that differ only ' + + 'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' + + 'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' + + 'failed[]. To change bold/italic/strike/code/link, read the block as ' + + 'page JSON and use a structural node patch/update to set its marks. ' + + 'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' + + 'world",replace:"Hello there"}] (crosses a bold boundary).', + buildShape: (z) => ({ + pageId: z.string().describe('ID of the page to edit'), + edits: z + .array( + z.object({ + find: z.string().describe('Exact text to find'), + replace: z.string().describe('Replacement text (may be empty)'), + replaceAll: z + .boolean() + .optional() + .describe('Replace every occurrence (default: must match once)'), + }), + ) + .min(1) + .describe('List of find/replace operations, applied in order'), + }), + }, +} satisfies Record; diff --git a/packages/mcp/test/unit/tool-specs.test.mjs b/packages/mcp/test/unit/tool-specs.test.mjs new file mode 100644 index 00000000..e98f18b6 --- /dev/null +++ b/packages/mcp/test/unit/tool-specs.test.mjs @@ -0,0 +1,90 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; + +import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js"; + +// The shared registry is consumed by BOTH the zod-v3 MCP server and the zod-v4 +// in-app AI-SDK service, so every spec must carry the cross-layer wiring +// (mcpName + inAppKey) and its builders must produce the right field set when +// called with a real zod namespace. + +test("every spec exposes mcpName + inAppKey, and the key matches inAppKey", () => { + for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) { + assert.equal(typeof spec.mcpName, "string"); + assert.ok(spec.mcpName.length > 0, `${key}: empty mcpName`); + assert.equal(typeof spec.inAppKey, "string"); + assert.ok(spec.inAppKey.length > 0, `${key}: empty inAppKey`); + assert.equal(typeof spec.description, "string"); + assert.ok(spec.description.length > 0, `${key}: empty description`); + // The registry is keyed by inAppKey — keep the two in sync. + assert.equal(spec.inAppKey, key, `${key}: registry key must equal inAppKey`); + } +}); + +test("mcpName uses snake_case and inAppKey uses camelCase", () => { + for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) { + assert.match(spec.mcpName, /^[a-z0-9]+(_[a-z0-9]+)*$/, `${key}: mcpName not snake_case`); + assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`); + } +}); + +test("mcpName and inAppKey are each unique across the registry", () => { + const mcpNames = new Set(); + const inAppKeys = new Set(); + for (const spec of Object.values(SHARED_TOOL_SPECS)) { + assert.ok(!mcpNames.has(spec.mcpName), `duplicate mcpName: ${spec.mcpName}`); + assert.ok(!inAppKeys.has(spec.inAppKey), `duplicate inAppKey: ${spec.inAppKey}`); + mcpNames.add(spec.mcpName); + inAppKeys.add(spec.inAppKey); + } +}); + +test("buildShape (when present) returns a usable ZodRawShape with a real zod", () => { + for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) { + if (!spec.buildShape) continue; + const shape = spec.buildShape(z); + assert.equal(typeof shape, "object"); + // Each field must be a real zod type so z.object(shape) compiles a schema. + for (const [field, zt] of Object.entries(shape)) { + assert.ok( + zt && typeof zt.parse === "function", + `${key}.${field}: not a zod type`, + ); + } + // The compiled object schema must parse a minimal valid input. + assert.doesNotThrow(() => z.object(shape)); + } +}); + +test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => { + const spec = SHARED_TOOL_SPECS.editPageText; + assert.equal(spec.mcpName, "edit_page_text"); + const shape = spec.buildShape(z); + assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]); + // A valid edits batch parses. + const schema = z.object(shape); + const parsed = schema.parse({ + pageId: "p1", + edits: [{ find: "teh", replace: "the" }], + }); + assert.equal(parsed.pageId, "p1"); + assert.equal(parsed.edits.length, 1); + // The canonical description must NOT carry the stale MCP strip-and-retry claim. + assert.ok( + !/strip-and-retry/i.test(spec.description), + "editPageText description still claims strip-and-retry", + ); + assert.match(spec.description, /REFUSED into\s+failed\[\]/); +}); + +test("getNode builder produces exactly { pageId, nodeId }", () => { + const shape = SHARED_TOOL_SPECS.getNode.buildShape(z); + assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]); +}); + +test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => { + for (const key of ["getWorkspace", "listSpaces", "listShares"]) { + assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`); + } +});