Implements two architecture follow-ups from the multi-aspect review.
1. Shared, zod-agnostic tool-spec registry (packages/mcp/src/tool-specs.ts)
for the 14 AI tools whose name + schema + model-facing description are
genuinely identical across the standalone MCP server and the in-app
AI-SDK chat. Both layers consume it (registerShared in index.ts;
sharedTool in ai-chat-tools.service.ts) and keep their own execute/auth.
- Zod-agnostic builders (z) => ZodRawShape bridge the zod v3 (mcp) vs
zod v4 (server) split; the registry imports no zod.
- Folds in the documented edit_page_text drift-bug fix: the stale
"strip-and-retry tolerated" claim is gone; canonical wording states a
formatting-only change is refused into failed[].
- Sibling-tool references in shared descriptions are transport-neutral so
one description is correct for both snake_case (MCP) and camelCase
(in-app) tool names.
- Loader fail-fast guard for a stale @docmost/mcp build.
- The ~17 intentionally-divergent tools (security guardrails, tuned UX)
stay per-layer, untouched.
- Rebuilt committed mcp artifacts (also regenerates a previously stale
build/lib/docmost-schema.js to match its already-committed source).
2. Formalize apps/server/test/integration/db.ts as the canonical
integration-test seed factory (module doc + a shortId helper); the
hand-written minimal seeders are kept on purpose, decoupled from the
app service-layer side effects.
Verified: server tsc + lint clean, mcp build clean; mcp unit tests 261 pass,
ai-chat-tools.service 16 pass, public-share-chat-tools 8 pass, ai-chat suite
224 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
213 lines
10 KiB
JavaScript
213 lines
10 KiB
JavaScript
// 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'),
|
|
}),
|
|
},
|
|
};
|