Merge branch 'refactor/ai-tools-spec-registry' into develop
Shared zod-agnostic tool-spec registry for the 14 identical AI tools across the standalone MCP server and the in-app AI-SDK chat (keeps execute/auth and the ~17 intentionally-divergent guardrail tools per-layer), folds in the edit_page_text drift-bug fix, and formalizes the integration-test db factory.
This commit is contained in:
@@ -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<string,
|
||||
// unknown> 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 " +
|
||||
"`#<index>` to fetch a top-level block by its outline index — use the " +
|
||||
"`#<index>` 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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
212
packages/mcp/build/tool-specs.js
Normal file
212
packages/mcp/build/tool-specs.js
Normal file
@@ -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 ' +
|
||||
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
||||
'`#<index>` 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'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -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<string,
|
||||
// unknown> 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 " +
|
||||
"`#<index>` to fetch a top-level block by its outline index — use the " +
|
||||
"`#<index>` 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);
|
||||
|
||||
269
packages/mcp/src/tool-specs.ts
Normal file
269
packages/mcp/src/tool-specs.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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 ' +
|
||||
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
||||
'`#<index>` 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<string, SharedToolSpec>;
|
||||
90
packages/mcp/test/unit/tool-specs.test.mjs
Normal file
90
packages/mcp/test/unit/tool-specs.test.mjs
Normal file
@@ -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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user