Merge pull request 'refactor(tools): execute-маппинг в SHARED_TOOL_SPECS + автопроводка обоих хостов (#445)' (#459) from refactor/445-execute-mapping into develop
Reviewed-on: #459
This commit was merged in pull request #459.
This commit is contained in:
@@ -292,6 +292,17 @@ export class AiChatToolsService {
|
||||
execute,
|
||||
});
|
||||
|
||||
// The in-app toolset. It starts with the tools kept INLINE here for a
|
||||
// documented per-layer reason: an intentional behaviour/schema divergence from
|
||||
// the standalone MCP surface (searchPages' hybrid RRF, updatePageContent's
|
||||
// Markdown write, transformPage's guardrailed shorter schema), a
|
||||
// snake_case/camelCase naming clash the shared registry forbids (getTable vs
|
||||
// the MCP `table_get`), per-request state the registry loop cannot provide
|
||||
// (getCurrentPage reads the resolved openedPage; searchPages closes over the
|
||||
// per-request user/embedding deps), or a tool with no MCP twin
|
||||
// (listSidebarPages/getComment/getPageHistory). Every SHARED tool is then added
|
||||
// by the registry loop below (see it), so there is exactly one arg-mapping per
|
||||
// shared tool and it can never drift from the MCP host again (#445).
|
||||
const tools: Record<string, Tool> = {
|
||||
// INTENTIONAL per-transport divergence (not in the shared registry): this
|
||||
// in-app search runs a semantic + keyword hybrid (RRF) with in-process
|
||||
@@ -426,44 +437,8 @@ export class AiChatToolsService {
|
||||
execute: async () => resolveCurrentPageResult(openedPage),
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The execute body keeps this layer's { title, markdown } projection.
|
||||
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
|
||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||
const result = await client.getPage(pageId);
|
||||
const data = (result?.data ?? {}) as {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
return {
|
||||
title: data.title ?? '',
|
||||
markdown: typeof data.content === 'string' ? data.content : '',
|
||||
};
|
||||
}),
|
||||
|
||||
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
createPage: sharedTool(
|
||||
sharedToolSpecs.createPage,
|
||||
async ({ title, content, spaceId, parentPageId }) => {
|
||||
// createPage(title, content, spaceId, parentPageId?) ->
|
||||
// { data: filterPage(page, markdown), success }.
|
||||
const result = await client.createPage(
|
||||
title,
|
||||
content ?? '',
|
||||
spaceId,
|
||||
parentPageId,
|
||||
);
|
||||
const data = (result?.data ?? {}) as {
|
||||
id?: string;
|
||||
slugId?: string;
|
||||
title?: string;
|
||||
};
|
||||
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
||||
},
|
||||
),
|
||||
|
||||
updatePageContent: tool({
|
||||
description:
|
||||
"Replace a page's body with new Markdown content (and optionally its " +
|
||||
@@ -486,121 +461,6 @@ export class AiChatToolsService {
|
||||
},
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
renamePage: sharedTool(
|
||||
sharedToolSpecs.renamePage,
|
||||
async ({ pageId, title }) => {
|
||||
// renamePage(pageId, title) -> { success, pageId, title }.
|
||||
await client.renamePage(pageId, title);
|
||||
return { pageId, title };
|
||||
},
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The shared schema adds the optional `position` field this layer lacked
|
||||
// before; the execute now forwards it (the client already accepted it).
|
||||
movePage: sharedTool(
|
||||
sharedToolSpecs.movePage,
|
||||
async ({ pageId, parentPageId, position }) => {
|
||||
// movePage(pageId, parentPageId, position?) -> raw move response.
|
||||
await client.movePage(pageId, parentPageId ?? null, position);
|
||||
return { pageId, parentPageId: parentPageId ?? null, moved: true };
|
||||
},
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
|
||||
// permanentlyDelete/forceDelete are never part of the input and can never
|
||||
// be forwarded — the agent physically cannot permanently delete a page.
|
||||
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
|
||||
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
||||
// which is the soft-delete (trash) path on the server.
|
||||
await client.deletePage(pageId);
|
||||
return { pageId, trashed: true };
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// This layer keeps only its own execute-side guards (require a selection
|
||||
// for a top-level comment; reject suggestedText on a reply / without a
|
||||
// selection) — the schema+description are shared.
|
||||
createComment: sharedTool(
|
||||
sharedToolSpecs.createComment,
|
||||
async ({
|
||||
pageId,
|
||||
content,
|
||||
selection,
|
||||
parentCommentId,
|
||||
suggestedText,
|
||||
}) => {
|
||||
// createComment(pageId, content, type, selection?, parentCommentId?,
|
||||
// suggestedText?). Top-level comments are inline and must carry a
|
||||
// selection to anchor on; replies inherit the parent's anchor (no
|
||||
// selection). Throwing here surfaces a tool error to the model (Vercel
|
||||
// `ai` SDK) so the agent retries with a better selection — do not
|
||||
// catch/suppress it.
|
||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||
throw new Error(
|
||||
"createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.",
|
||||
);
|
||||
}
|
||||
if (suggestedText !== undefined) {
|
||||
if (parentCommentId) {
|
||||
throw new Error(
|
||||
"createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||
);
|
||||
}
|
||||
if (!selection || !selection.trim()) {
|
||||
throw new Error(
|
||||
"createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = await client.createComment(
|
||||
pageId,
|
||||
content,
|
||||
'inline',
|
||||
selection,
|
||||
parentCommentId,
|
||||
suggestedText,
|
||||
);
|
||||
const data = (result?.data ?? {}) as { id?: string };
|
||||
return { commentId: data.id, pageId };
|
||||
},
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
resolveComment: sharedTool(
|
||||
sharedToolSpecs.resolveComment,
|
||||
async ({ commentId, resolved }) => {
|
||||
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
||||
await client.resolveComment(commentId, resolved);
|
||||
return { commentId, resolved };
|
||||
},
|
||||
),
|
||||
|
||||
// --- READ tools (added) ---
|
||||
|
||||
getWorkspace: sharedTool(
|
||||
sharedToolSpecs.getWorkspace,
|
||||
async () => await client.getWorkspace(),
|
||||
),
|
||||
|
||||
listSpaces: sharedTool(
|
||||
sharedToolSpecs.listSpaces,
|
||||
async () => await client.getSpaces(),
|
||||
),
|
||||
|
||||
// INTENTIONAL per-transport divergence (not shared): keeps the `tree:true`
|
||||
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
||||
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
||||
// its own guidance.
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
listPages: sharedTool(
|
||||
sharedToolSpecs.listPages,
|
||||
async ({ spaceId, limit, tree }) =>
|
||||
await client.listPages(spaceId, limit, tree),
|
||||
),
|
||||
|
||||
listSidebarPages: tool({
|
||||
description:
|
||||
'List sidebar pages for a space. With no pageId, returns the ' +
|
||||
@@ -619,31 +479,6 @@ export class AiChatToolsService {
|
||||
await client.listSidebarPages(spaceId, pageId),
|
||||
}),
|
||||
|
||||
getOutline: sharedTool(
|
||||
sharedToolSpecs.getOutline,
|
||||
async ({ pageId }) => await client.getOutline(pageId),
|
||||
),
|
||||
|
||||
getPageJson: sharedTool(
|
||||
sharedToolSpecs.getPageJson,
|
||||
async ({ pageId }) => await client.getPageJson(pageId),
|
||||
),
|
||||
|
||||
getNode: sharedTool(
|
||||
sharedToolSpecs.getNode,
|
||||
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
||||
),
|
||||
|
||||
searchInPage: sharedTool(
|
||||
sharedToolSpecs.searchInPage,
|
||||
async ({ pageId, query, regex, caseSensitive, limit }) =>
|
||||
await client.searchInPage(pageId, query, {
|
||||
regex,
|
||||
caseSensitive,
|
||||
limit,
|
||||
}),
|
||||
),
|
||||
|
||||
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
|
||||
// while this key is `getTable` (verb-first), breaking the
|
||||
// snake_case(inAppKey) convention the shared registry enforces. Its
|
||||
@@ -666,13 +501,6 @@ export class AiChatToolsService {
|
||||
await client.getTable(pageId, table),
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
listComments: sharedTool(
|
||||
sharedToolSpecs.listComments,
|
||||
async ({ pageId, includeResolved }) =>
|
||||
await client.listComments(pageId, includeResolved),
|
||||
),
|
||||
|
||||
getComment: tool({
|
||||
description: 'Fetch a single comment by id (content as Markdown).',
|
||||
inputSchema: modelFriendlyInput({
|
||||
@@ -681,24 +509,6 @@ export class AiChatToolsService {
|
||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
checkNewComments: sharedTool(
|
||||
sharedToolSpecs.checkNewComments,
|
||||
async ({ spaceId, since, parentPageId }) =>
|
||||
await client.checkNewComments(spaceId, since, parentPageId),
|
||||
),
|
||||
|
||||
listShares: sharedTool(
|
||||
sharedToolSpecs.listShares,
|
||||
async () => await client.listShares(),
|
||||
),
|
||||
|
||||
listPageHistory: sharedTool(
|
||||
sharedToolSpecs.listPageHistory,
|
||||
async ({ pageId, cursor }) =>
|
||||
await client.listPageHistory(pageId, cursor),
|
||||
),
|
||||
|
||||
getPageHistory: tool({
|
||||
description:
|
||||
'Fetch a single page-history version including its lossless ' +
|
||||
@@ -710,203 +520,8 @@ export class AiChatToolsService {
|
||||
await client.getPageHistory(historyId),
|
||||
}),
|
||||
|
||||
diffPageVersions: sharedTool(
|
||||
sharedToolSpecs.diffPageVersions,
|
||||
async ({ pageId, from, to }) =>
|
||||
await client.diffPageVersions(pageId, from, to),
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
exportPageMarkdown: sharedTool(
|
||||
sharedToolSpecs.exportPageMarkdown,
|
||||
async ({ pageId }) => {
|
||||
const markdown = await client.exportPageMarkdown(pageId);
|
||||
return { markdown };
|
||||
},
|
||||
),
|
||||
|
||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||
|
||||
editPageText: sharedTool(
|
||||
sharedToolSpecs.editPageText,
|
||||
async ({ pageId, edits }) => await client.editPageText(pageId, edits),
|
||||
),
|
||||
|
||||
// Returns ONLY the short link object — never the document body — so a
|
||||
// large page can be handed to an external consumer without bloating
|
||||
// context.
|
||||
stashPage: sharedTool(
|
||||
sharedToolSpecs.stashPage,
|
||||
async ({ pageId }) => await client.stashPage(pageId),
|
||||
),
|
||||
|
||||
// Schema + description from the shared registry (identical across both
|
||||
// transports). The execute body keeps its OWN parseNodeArg normalization:
|
||||
// the model sometimes serializes the node as a JSON string, and we parse it
|
||||
// before the client's typeof-object guard rejects it (parity with the
|
||||
// standalone MCP server, index.ts patch_node).
|
||||
patchNode: sharedTool(
|
||||
sharedToolSpecs.patchNode,
|
||||
async ({ pageId, nodeId, node }) => {
|
||||
const parsedNode = parseNodeArg(node);
|
||||
return await client.patchNode(pageId, nodeId, parsedNode);
|
||||
},
|
||||
),
|
||||
|
||||
// Shared registry schema + description; execute retains parseNodeArg on the
|
||||
// incoming node (parity with the standalone MCP server, index.ts
|
||||
// insert_node).
|
||||
insertNode: sharedTool(
|
||||
sharedToolSpecs.insertNode,
|
||||
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
const parsedNode = parseNodeArg(node);
|
||||
return await client.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
anchorNodeId,
|
||||
anchorText,
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
deleteNode: sharedTool(
|
||||
sharedToolSpecs.deleteNode,
|
||||
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The execute body keeps this layer's content normalization (parity with
|
||||
// the standalone MCP server, index.ts update_page_json).
|
||||
updatePageJson: sharedTool(
|
||||
sharedToolSpecs.updatePageJson,
|
||||
async ({ pageId, content, title }) => {
|
||||
// undefined/null pass through as undefined (title-only / no-op); any
|
||||
// string is JSON.parsed (so an empty string "" throws, matching the
|
||||
// MCP server); an object is passed through unchanged.
|
||||
let doc;
|
||||
if (content === undefined || content === null) {
|
||||
doc = undefined;
|
||||
} else {
|
||||
// String -> JSON.parse (throwing on invalid); object passes through.
|
||||
doc = parseNodeArg(content, 'content was a string but not valid JSON');
|
||||
}
|
||||
return await client.updatePageJson(pageId, doc, title);
|
||||
},
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
|
||||
// Promoted from MCP-only so the in-app agent can attach a REAL footnote to
|
||||
// already-written text instead of leaving a literal `^[...]` string.
|
||||
insertFootnote: sharedTool(
|
||||
sharedToolSpecs.insertFootnote,
|
||||
async ({ pageId, anchorText, text }) =>
|
||||
await client.insertFootnote(pageId, anchorText, text),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
|
||||
// The schema field is `imageUrl`; the client method takes it positionally.
|
||||
insertImage: sharedTool(
|
||||
sharedToolSpecs.insertImage,
|
||||
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) =>
|
||||
await client.insertImage(pageId, imageUrl, {
|
||||
align,
|
||||
alt,
|
||||
replaceText,
|
||||
afterText,
|
||||
}),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
|
||||
replaceImage: sharedTool(
|
||||
sharedToolSpecs.replaceImage,
|
||||
async ({ pageId, attachmentId, imageUrl, align, alt }) =>
|
||||
await client.replaceImage(pageId, attachmentId, imageUrl, {
|
||||
align,
|
||||
alt,
|
||||
}),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
|
||||
// meta.hash in the result is the baseHash drawioUpdate requires.
|
||||
drawioGet: sharedTool(
|
||||
sharedToolSpecs.drawioGet,
|
||||
async ({ pageId, node, format }) =>
|
||||
await client.drawioGet(pageId, node, format ?? 'xml'),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
|
||||
// The flat schema fields are regrouped into the client's `where` object.
|
||||
drawioCreate: sharedTool(
|
||||
sharedToolSpecs.drawioCreate,
|
||||
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) =>
|
||||
await client.drawioCreate(
|
||||
pageId,
|
||||
{ position, anchorNodeId, anchorText },
|
||||
xml,
|
||||
title,
|
||||
),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
|
||||
// baseHash is the optimistic lock: mismatch => structured conflict error.
|
||||
drawioUpdate: sharedTool(
|
||||
sharedToolSpecs.drawioUpdate,
|
||||
async ({ pageId, node, xml, baseHash }) =>
|
||||
await client.drawioUpdate(pageId, node, xml, baseHash),
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||
tableInsertRow: sharedTool(
|
||||
sharedToolSpecs.tableInsertRow,
|
||||
async ({ pageId, table, cells, index }) =>
|
||||
await client.tableInsertRow(pageId, table, cells, index),
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
tableDeleteRow: sharedTool(
|
||||
sharedToolSpecs.tableDeleteRow,
|
||||
async ({ pageId, table, index }) =>
|
||||
await client.tableDeleteRow(pageId, table, index),
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
tableUpdateCell: sharedTool(
|
||||
sharedToolSpecs.tableUpdateCell,
|
||||
async ({ pageId, table, row, col, text }) =>
|
||||
await client.tableUpdateCell(pageId, table, row, col, text),
|
||||
),
|
||||
|
||||
copyPageContent: sharedTool(
|
||||
sharedToolSpecs.copyPageContent,
|
||||
async ({ sourcePageId, targetPageId }) =>
|
||||
await client.copyPageContent(sourcePageId, targetPageId),
|
||||
),
|
||||
|
||||
importPageMarkdown: sharedTool(
|
||||
sharedToolSpecs.importPageMarkdown,
|
||||
async ({ pageId, markdown }) =>
|
||||
await client.importPageMarkdown(pageId, markdown),
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// Both layers already carried the security-confirmation framing, so there
|
||||
// was no real divergence to preserve — only wording drift.
|
||||
sharePage: sharedTool(
|
||||
sharedToolSpecs.sharePage,
|
||||
async ({ pageId, searchIndexing }) =>
|
||||
await client.sharePage(pageId, searchIndexing),
|
||||
),
|
||||
|
||||
unsharePage: sharedTool(
|
||||
sharedToolSpecs.unsharePage,
|
||||
async ({ pageId }) => await client.unsharePage(pageId),
|
||||
),
|
||||
|
||||
restorePageVersion: sharedTool(
|
||||
sharedToolSpecs.restorePageVersion,
|
||||
async ({ historyId }) => await client.restorePageVersion(historyId),
|
||||
),
|
||||
|
||||
// INTENTIONAL per-transport divergence (not shared): deliberately omits the
|
||||
// `deleteComments` schema field (comment-deletion guardrail) and carries a
|
||||
// much shorter description; the standalone MCP `docmost_transform` exposes
|
||||
@@ -935,6 +550,29 @@ export class AiChatToolsService {
|
||||
}),
|
||||
};
|
||||
|
||||
// Add EVERY shared tool from the zod-agnostic registry in one loop (#445).
|
||||
// The spec owns the canonical arg->client mapping; this host only decides
|
||||
// WHICH mapping to run and returns its value directly (no envelope). For each
|
||||
// spec:
|
||||
// - skip `mcpOnly` specs (they belong to the standalone MCP host only);
|
||||
// - use `inAppExecute` when the spec declares a DELIBERATE per-layer
|
||||
// difference (a projected result shape, a different guardrail message);
|
||||
// - otherwise use the canonical `execute` (raw client result, identical to
|
||||
// the MCP host's before it wraps it as JSON).
|
||||
// The execute receives the AI-SDK-validated, type-erased input; the spec reads
|
||||
// the same fields its buildShape declares. This is the SINGLE place the in-app
|
||||
// arg mapping lives — it can no longer silently drift from the MCP host.
|
||||
for (const spec of Object.values(sharedToolSpecs)) {
|
||||
if (spec.mcpOnly) continue;
|
||||
const run = spec.inAppExecute ?? spec.execute;
|
||||
if (!run) continue; // defensive: a shared spec always carries one of them.
|
||||
tools[spec.inAppKey] = sharedTool(
|
||||
spec,
|
||||
(async (args) =>
|
||||
run(client, args as Record<string, unknown>)) as Tool['execute'],
|
||||
);
|
||||
}
|
||||
|
||||
// Passive "new comments: N" signal (#417). PER-TURN state (forUser runs once
|
||||
// per turn), so the watermark starts now and only comments a human leaves
|
||||
// WHILE this turn runs are signalled — exactly the mid-turn loop; between-turn
|
||||
|
||||
+46
-530
@@ -202,10 +202,10 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
||||
{ instructions: SERVER_INSTRUCTIONS },
|
||||
);
|
||||
|
||||
// Single choke point for MCP tool timing. Both `registerShared` (below) and
|
||||
// the inline `server.registerTool(...)` calls funnel through this one method,
|
||||
// so monkeypatching it HERE — before any tool is registered and before
|
||||
// `registerShared` captures a reference to it — times every tool with no
|
||||
// Single choke point for MCP tool timing. Both `registerSharedFromSpec` (below)
|
||||
// and the inline `server.registerTool(...)` calls funnel through this one
|
||||
// method, so monkeypatching it HERE — before any tool is registered and before
|
||||
// the registry loop captures a reference to it — times every tool with no
|
||||
// per-tool boilerplate. The wrapped handler records wall-clock duration and,
|
||||
// in a `finally`, feeds the host's dependency-neutral sink
|
||||
// `config.onMetric("mcp_tool_duration_seconds", seconds, { tool })`. The tool
|
||||
@@ -261,92 +261,56 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
||||
return originalRegisterTool(...args.slice(0, -1), signalledHandler);
|
||||
};
|
||||
|
||||
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
|
||||
// the canonical name + model-facing description + (optional) schema builder;
|
||||
// only the execute body is supplied per call. buildShape is invoked with THIS
|
||||
// package's zod (v3); the in-app layer passes its own zod (v4).
|
||||
//
|
||||
// The spec's schema builder returns a plain ZodRawShape (Record<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)(
|
||||
// Register EVERY shared tool from the zod-agnostic registry in one loop (#445).
|
||||
// The spec owns the canonical name + description + (optional) schema builder AND
|
||||
// the canonical execute mapping; the host only supplies the RESULT ENVELOPE. For
|
||||
// each spec:
|
||||
// - skip `inAppOnly` specs (they belong to the in-app host only);
|
||||
// - if the spec has an `mcpExecute` override (a deliberate per-layer
|
||||
// difference — a guardrail, an omitted param, or a non-JSON envelope like a
|
||||
// resource_link/bare success line), the override OWNS the full MCP content
|
||||
// result and is used VERBATIM;
|
||||
// - otherwise the canonical `execute` returns RAW data and this host wraps it
|
||||
// in the standard JSON text envelope (jsonContent), exactly as the old inline
|
||||
// bodies did.
|
||||
// buildShape is invoked with THIS package's zod (v3); the in-app layer passes its
|
||||
// own zod (v4). The registry's execute returns `unknown` (it is zod-agnostic), so
|
||||
// the wrapping is typed loosely and cast — runtime behaviour is unchanged.
|
||||
const registerSharedFromSpec = (spec: SharedToolSpec) => {
|
||||
if (spec.inAppOnly) return;
|
||||
const handler = async (args: any) => {
|
||||
if (spec.mcpExecute) {
|
||||
// The override owns the full MCP result envelope (not re-wrapped).
|
||||
return (await spec.mcpExecute(docmostClient, args)) as {
|
||||
content: { type: "text"; text: string }[];
|
||||
};
|
||||
}
|
||||
// Canonical execute returns raw data; wrap it as JSON text content.
|
||||
const raw = await spec.execute!(docmostClient, args);
|
||||
return jsonContent(raw);
|
||||
};
|
||||
return (server.registerTool as any)(
|
||||
spec.mcpName,
|
||||
spec.buildShape
|
||||
? { description: spec.description, inputSchema: spec.buildShape(z) }
|
||||
: { description: spec.description },
|
||||
execute,
|
||||
handler,
|
||||
);
|
||||
};
|
||||
|
||||
// Tool: get_workspace
|
||||
registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
|
||||
const workspace = await docmostClient.getWorkspace();
|
||||
return jsonContent(workspace);
|
||||
});
|
||||
for (const spec of Object.values(SHARED_TOOL_SPECS)) {
|
||||
registerSharedFromSpec(spec as SharedToolSpec);
|
||||
}
|
||||
|
||||
// Tool: list_spaces
|
||||
registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
|
||||
const spaces = await docmostClient.getSpaces();
|
||||
return jsonContent(spaces);
|
||||
});
|
||||
// --- INLINE tools kept per-transport (NOT in the shared registry) ---
|
||||
// Each stays inline for a documented reason: a snake_case/camelCase naming
|
||||
// clash the registry convention forbids (table_get), an intentional
|
||||
// per-transport behaviour/schema divergence (search, docmost_transform), or a
|
||||
// tool that exists ONLY on this standalone MCP surface (update_comment,
|
||||
// delete_comment — the in-app agent deliberately exposes no hard comment
|
||||
// edit/delete tool).
|
||||
|
||||
// Tool: list_pages
|
||||
// INTENTIONAL per-transport divergence (not in the shared registry): this
|
||||
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
|
||||
// the in-app copy keeps the same tree option but is worded for the in-app agent.
|
||||
// Kept per-layer so each side can tune its own guidance.
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This
|
||||
// transport keeps applying its own defaults (limit=50, tree=false) in execute.
|
||||
registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => {
|
||||
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: get_page
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => {
|
||||
const page = await docmostClient.getPage(pageId);
|
||||
return jsonContent(page);
|
||||
});
|
||||
|
||||
// Tool: get_page_json
|
||||
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
||||
const page = await docmostClient.getPageJson(pageId);
|
||||
return jsonContent(page);
|
||||
});
|
||||
|
||||
// Tool: get_outline
|
||||
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
|
||||
const result = await docmostClient.getOutline(pageId);
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: get_node
|
||||
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
|
||||
const result = await docmostClient.getNode(pageId, nodeId);
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: search_in_page
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.searchInPage,
|
||||
async ({ pageId, query, regex, caseSensitive, limit }) => {
|
||||
const result = await docmostClient.searchInPage(pageId, query, {
|
||||
regex,
|
||||
caseSensitive,
|
||||
limit,
|
||||
});
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: table_get
|
||||
// Tool: table_get
|
||||
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while
|
||||
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
|
||||
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
|
||||
@@ -372,383 +336,6 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: table_insert_row
|
||||
// Schema + description now live in the shared registry (#294); the `table`
|
||||
// parameter name is the canonical one (the in-app layer was unified to it).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.tableInsertRow,
|
||||
async ({ pageId, table, cells, index }) => {
|
||||
const result = await docmostClient.tableInsertRow(
|
||||
pageId,
|
||||
table,
|
||||
cells,
|
||||
index,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: table_delete_row
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.tableDeleteRow,
|
||||
async ({ pageId, table, index }) => {
|
||||
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: table_update_cell
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.tableUpdateCell,
|
||||
async ({ pageId, table, row, col, text }) => {
|
||||
const result = await docmostClient.tableUpdateCell(
|
||||
pageId,
|
||||
table,
|
||||
row,
|
||||
col,
|
||||
text,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: create_page
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.createPage,
|
||||
async ({ title, content, spaceId, parentPageId }) => {
|
||||
const result = await docmostClient.createPage(
|
||||
title,
|
||||
content,
|
||||
spaceId,
|
||||
parentPageId,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: update_page_json
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's content normalization (parse a JSON-string content,
|
||||
// pass undefined/null through for a title-only/no-op update).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.updatePageJson,
|
||||
async ({ pageId, content, title }) => {
|
||||
// Only parse/validate the document when it was actually supplied; when it
|
||||
// is omitted, pass it straight through so the client performs a title-only
|
||||
// (or no-op) update.
|
||||
let doc;
|
||||
if (content === undefined || content === null) {
|
||||
doc = undefined;
|
||||
} else {
|
||||
// String -> JSON.parse (throwing on invalid); object passes through.
|
||||
doc = parseNodeArg(content, "content was a string but not valid JSON");
|
||||
}
|
||||
const result = await docmostClient.updatePageJson(pageId, doc, title);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: export_page_markdown
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => {
|
||||
const md = await docmostClient.exportPageMarkdown(pageId);
|
||||
return { content: [{ type: "text" as const, text: md }] };
|
||||
});
|
||||
|
||||
// Tool: import_page_markdown
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.importPageMarkdown,
|
||||
async ({ pageId, markdown }) => {
|
||||
const res = await docmostClient.importPageMarkdown(pageId, markdown);
|
||||
return jsonContent(res);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: copy_page_content
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.copyPageContent,
|
||||
async ({ sourcePageId, targetPageId }) => {
|
||||
const result = await docmostClient.copyPageContent(
|
||||
sourcePageId,
|
||||
targetPageId,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: rename_page
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => {
|
||||
const result = await docmostClient.renamePage(pageId, title);
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: edit_page_text
|
||||
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
||||
const result = await docmostClient.editPageText(pageId, edits);
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: stash_page — returns a resource_link (NOT embedded text) so the doc
|
||||
// body never enters the model context. Registered directly (not via
|
||||
// registerShared) because that helper only emits text content. Also returns
|
||||
// `structuredContent` carrying the full documented `{uri, sha256, size, images}`
|
||||
// shape alongside the resource_link, so MCP clients receive the blob's sha256
|
||||
// (its ETag, for integrity) and mirror counts, not just the link.
|
||||
server.registerTool(
|
||||
SHARED_TOOL_SPECS.stashPage.mcpName,
|
||||
{
|
||||
description: SHARED_TOOL_SPECS.stashPage.description,
|
||||
inputSchema: SHARED_TOOL_SPECS.stashPage.buildShape!(z),
|
||||
},
|
||||
async ({ pageId }: { pageId: string }) => {
|
||||
const result = await docmostClient.stashPage(pageId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "resource_link" as const,
|
||||
uri: result.uri,
|
||||
name: "page.json",
|
||||
mimeType: "application/json",
|
||||
size: result.size,
|
||||
},
|
||||
],
|
||||
// Mirror the full documented result shape ({ uri, size, sha256, images })
|
||||
// as structuredContent so MCP clients get the blob's sha256 (its ETag, for
|
||||
// integrity) and the mirror counts, not just the resource_link.
|
||||
structuredContent: {
|
||||
uri: result.uri,
|
||||
sha256: result.sha256,
|
||||
size: result.size,
|
||||
images: result.images,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: patch_node — schema + description from the shared registry (identical
|
||||
// across both transports). The execute body keeps its own parseNodeArg
|
||||
// normalization (the model sometimes serializes `node` as a JSON string).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.patchNode,
|
||||
async ({ pageId, nodeId, node }) => {
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: insert_node — schema + description from the shared registry. As with
|
||||
// patch_node, the execute body retains parseNodeArg on the incoming node.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.insertNode,
|
||||
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
anchorNodeId,
|
||||
anchorText,
|
||||
});
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: delete_node
|
||||
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
||||
const result = await docmostClient.deleteNode(pageId, nodeId);
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: insert_image
|
||||
// Schema + description now live in the shared registry (#410) so BOTH this MCP
|
||||
// server and the in-app AI-chat agent expose it. The execute body is unchanged.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.insertImage,
|
||||
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
|
||||
const result = await docmostClient.insertImage(pageId, imageUrl, {
|
||||
align,
|
||||
alt,
|
||||
replaceText,
|
||||
afterText,
|
||||
});
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: replace_image
|
||||
// Schema + description now live in the shared registry (#410).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.replaceImage,
|
||||
async ({ pageId, attachmentId, imageUrl, align, alt }) => {
|
||||
const result = await docmostClient.replaceImage(
|
||||
pageId,
|
||||
attachmentId,
|
||||
imageUrl,
|
||||
{
|
||||
align,
|
||||
alt,
|
||||
},
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: drawio_get — read a draw.io diagram as mxGraph XML (or the raw SVG).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.drawioGet,
|
||||
async ({ pageId, node, format }) => {
|
||||
const result = await docmostClient.drawioGet(pageId, node, format ?? "xml");
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: drawio_create — lint mxGraph XML, build the .drawio.svg, insert a node.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.drawioCreate,
|
||||
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => {
|
||||
const result = await docmostClient.drawioCreate(
|
||||
pageId,
|
||||
{ position, anchorNodeId, anchorText },
|
||||
xml,
|
||||
title,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: drawio_update — optimistic-locked full replacement of a diagram.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.drawioUpdate,
|
||||
async ({ pageId, node, xml, baseHash }) => {
|
||||
const result = await docmostClient.drawioUpdate(pageId, node, xml, baseHash);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: share_page
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's own `searchIndexing ?? true` default.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.sharePage,
|
||||
async ({ pageId, searchIndexing }) => {
|
||||
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: unshare_page
|
||||
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
|
||||
const result = await docmostClient.unsharePage(pageId);
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: list_shares
|
||||
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
||||
const result = await docmostClient.listShares();
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: move_page
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and
|
||||
// its positive-confirmation check on the move response.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.movePage,
|
||||
async ({ pageId, parentPageId, position }) => {
|
||||
const finalParentId =
|
||||
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
||||
|
||||
// Cheap cycle guard: a page cannot be moved directly under itself.
|
||||
// (Deeper descendant-cycle detection is intentionally out of scope.)
|
||||
if (finalParentId !== null && finalParentId === pageId) {
|
||||
throw new Error("cannot move a page under itself");
|
||||
}
|
||||
|
||||
const result = await docmostClient.movePage(
|
||||
pageId,
|
||||
finalParentId || null,
|
||||
position,
|
||||
);
|
||||
|
||||
// Require POSITIVE confirmation: the live /pages/move success shape is
|
||||
// exactly { success: true, status: 200 }. An empty body, a 204, or any odd
|
||||
// shape lacking success === true must NOT be reported as a successful move,
|
||||
// so we surface the raw API result instead of declaring success.
|
||||
if (!(result && typeof result === "object" && result.success === true)) {
|
||||
throw new Error(
|
||||
`Failed to move page ${pageId}: ${JSON.stringify(result)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return jsonContent({
|
||||
message: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
|
||||
result,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: delete_page
|
||||
// Schema + description now live in the shared registry (#294). The shared schema
|
||||
// exposes ONLY pageId, so no permanent/force-delete flag can reach the client.
|
||||
registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => {
|
||||
await docmostClient.deletePage(pageId);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
||||
|
||||
// Tool: list_comments
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.listComments,
|
||||
async ({ pageId, includeResolved }) => {
|
||||
const comments = await docmostClient.listComments(pageId, includeResolved);
|
||||
return jsonContent(comments);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: create_comment
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's own guards (require a selection for a top-level
|
||||
// comment; reject suggestedText on a reply / without a selection).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.createComment,
|
||||
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||
throw new Error(
|
||||
"create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
|
||||
);
|
||||
}
|
||||
if (suggestedText !== undefined) {
|
||||
if (parentCommentId) {
|
||||
throw new Error(
|
||||
"create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||
);
|
||||
}
|
||||
if (!selection || !selection.trim()) {
|
||||
throw new Error(
|
||||
"create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = await docmostClient.createComment(
|
||||
pageId,
|
||||
content,
|
||||
"inline",
|
||||
selection,
|
||||
parentCommentId,
|
||||
suggestedText,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: update_comment
|
||||
server.registerTool(
|
||||
"update_comment",
|
||||
@@ -793,39 +380,6 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: resolve_comment
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.resolveComment,
|
||||
async ({ commentId, resolved }) => {
|
||||
const result = await docmostClient.resolveComment(commentId, resolved);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: check_new_comments
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's own guard rejecting an unparseable `since` timestamp.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.checkNewComments,
|
||||
async ({ spaceId, since, parentPageId }) => {
|
||||
// Reject an unparseable timestamp up front: otherwise the comparison
|
||||
// against NaN silently treats every comment as "not new" and the tool
|
||||
// returns zero results without signalling the bad input.
|
||||
if (Number.isNaN(Date.parse(since))) {
|
||||
throw new Error(
|
||||
`Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`,
|
||||
);
|
||||
}
|
||||
const result = await docmostClient.checkNewComments(
|
||||
spaceId,
|
||||
since,
|
||||
parentPageId,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: search
|
||||
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
||||
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
||||
@@ -935,43 +489,5 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: insert_footnote
|
||||
// Schema + description now live in the shared registry (#410) so the in-app
|
||||
// AI-chat agent exposes it too. The execute body is unchanged.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.insertFootnote,
|
||||
async ({ pageId, anchorText, text }) => {
|
||||
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: diff_page_versions
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.diffPageVersions,
|
||||
async ({ pageId, from, to }) => {
|
||||
const result = await docmostClient.diffPageVersions(pageId, from, to);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: list_page_history
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.listPageHistory,
|
||||
async ({ pageId, cursor }) => {
|
||||
const result = await docmostClient.listPageHistory(pageId, cursor);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: restore_page_version
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.restorePageVersion,
|
||||
async ({ historyId }) => {
|
||||
const result = await docmostClient.restorePageVersion(historyId);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,92 @@
|
||||
// one of them. Each builder uses only the common, stable subset of the API.
|
||||
type ZodLike = any;
|
||||
|
||||
// The `node` normalizer shared by BOTH hosts (patch_node / insert_node /
|
||||
// update_page_json): the model sometimes serializes a ProseMirror node arg as a
|
||||
// JSON string, so we parse a string to an object (throwing a documented message
|
||||
// on invalid JSON) and pass an object through. It lives in the converter package
|
||||
// (#414) so it is the ONE copy both the MCP server and the in-app server import;
|
||||
// putting it in a shared execute here keeps that single normalization in one
|
||||
// place instead of hand-mirrored per host. Pure — safe across the zod boundary.
|
||||
import { parseNodeArg } from '@docmost/prosemirror-markdown';
|
||||
// Type-only import (erased at compile) of the real client so `DocmostClientLike`
|
||||
// is DERIVED from it (issue #446), not hand-mirrored. The loosest correct client
|
||||
// surface both hosts satisfy: the in-app host passes its own DERIVED
|
||||
// `DocmostClientLike` (a Pick of the same class) and the MCP host passes the real
|
||||
// `DocmostClient`, so both are structurally assignable to this shared alias.
|
||||
import type { DocmostClient } from './client.js';
|
||||
|
||||
/**
|
||||
* The client surface a shared `execute` may call — the LOOSEST correct type both
|
||||
* hosts satisfy: a `Pick` of the real `DocmostClient` methods the executes below
|
||||
* use. DERIVED from the real class (issue #446) so a signature change to any
|
||||
* consumed method surfaces as a compile error in the execute bodies here, not a
|
||||
* silent runtime "wrong argument". Kept as a Pick (not the whole class) so the
|
||||
* standalone MCP host's full `DocmostClient` AND the in-app host's OWN narrower
|
||||
* `DocmostClientLike` (also a Pick of the same class, a superset of these methods)
|
||||
* are both structurally assignable to it. `import type` is fully erased, so
|
||||
* tool-specs.ts pulls in no runtime dependency on the client and still crosses the
|
||||
* zod-major boundary freely. When you add a client call to an execute below, add
|
||||
* its method name here too (a compile error will point you at it).
|
||||
*/
|
||||
export type DocmostClientLike = Pick<
|
||||
DocmostClient,
|
||||
| 'getWorkspace'
|
||||
| 'getSpaces'
|
||||
| 'listShares'
|
||||
| 'listPages'
|
||||
| 'getPage'
|
||||
| 'getPageJson'
|
||||
| 'getOutline'
|
||||
| 'getNode'
|
||||
| 'searchInPage'
|
||||
| 'listComments'
|
||||
| 'checkNewComments'
|
||||
| 'listPageHistory'
|
||||
| 'diffPageVersions'
|
||||
| 'exportPageMarkdown'
|
||||
| 'createPage'
|
||||
| 'renamePage'
|
||||
| 'movePage'
|
||||
| 'deletePage'
|
||||
| 'editPageText'
|
||||
| 'patchNode'
|
||||
| 'insertNode'
|
||||
| 'deleteNode'
|
||||
| 'updatePageJson'
|
||||
| 'tableInsertRow'
|
||||
| 'tableDeleteRow'
|
||||
| 'tableUpdateCell'
|
||||
| 'copyPageContent'
|
||||
| 'importPageMarkdown'
|
||||
| 'sharePage'
|
||||
| 'unsharePage'
|
||||
| 'restorePageVersion'
|
||||
| 'stashPage'
|
||||
| 'insertFootnote'
|
||||
| 'insertImage'
|
||||
| 'replaceImage'
|
||||
| 'drawioGet'
|
||||
| 'drawioCreate'
|
||||
| 'drawioUpdate'
|
||||
| 'createComment'
|
||||
| 'resolveComment'
|
||||
>;
|
||||
|
||||
/**
|
||||
* A shared tool `execute`: the single canonical mapping from validated schema
|
||||
* args to the client call. Plain JS — it crosses the zod-major boundary (v3 in
|
||||
* the MCP package, v4 on the server) freely, receiving the already-validated,
|
||||
* type-erased args from whichever host invoked it. It returns RAW data; the host
|
||||
* applies its own result envelope (the MCP transport wraps it as JSON text
|
||||
* content, the in-app AI-SDK host returns it as-is). Host-specific overrides
|
||||
* (`mcpExecute`/`inAppExecute`) return a value the host uses instead of wrapping.
|
||||
*/
|
||||
export type SharedToolExecute = (
|
||||
client: DocmostClientLike,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
export interface SharedToolSpec {
|
||||
/** snake_case tool name passed to McpServer.registerTool. */
|
||||
mcpName: string;
|
||||
@@ -54,8 +140,53 @@ export interface SharedToolSpec {
|
||||
* in-app side uses z.object({})).
|
||||
*/
|
||||
buildShape?: (z: ZodLike) => Record<string, unknown>;
|
||||
/**
|
||||
* Single canonical mapping from validated schema args to the client call,
|
||||
* shared by BOTH hosts. Returns RAW data — the MCP host wraps it as JSON text
|
||||
* content (jsonContent), the in-app host returns it as-is. Present on tools
|
||||
* whose mapping AND raw result are identical across the two layers. When a host
|
||||
* needs a genuinely different mapping or result shape, it supplies an override
|
||||
* (below) and the host uses that INSTEAD of `execute`.
|
||||
*/
|
||||
execute?: SharedToolExecute;
|
||||
/**
|
||||
* MCP-host override for a DELIBERATE per-layer difference (a guardrail, an
|
||||
* omitted param, or a non-JSON result envelope like a resource_link / a bare
|
||||
* success line). When present, the MCP host calls this and uses its return
|
||||
* value VERBATIM (it is NOT re-wrapped in jsonContent), so this override owns
|
||||
* the full MCP content envelope.
|
||||
*/
|
||||
mcpExecute?: SharedToolExecute;
|
||||
/**
|
||||
* In-app-host override for a DELIBERATE per-layer difference (a projected
|
||||
* result shape, a different guardrail message). When present, the in-app host
|
||||
* calls this and returns its value as the tool result (no wrapping).
|
||||
*/
|
||||
inAppExecute?: SharedToolExecute;
|
||||
/** Registered only on the MCP host (skipped by the in-app registry loop). */
|
||||
mcpOnly?: boolean;
|
||||
/** Registered only on the in-app host (skipped by the MCP registry loop). */
|
||||
inAppOnly?: boolean;
|
||||
}
|
||||
|
||||
// --- Shared execute helpers -------------------------------------------------
|
||||
//
|
||||
// Each helper is the ONE canonical arg->client mapping for a tool (or a host
|
||||
// override where the two layers deliberately differ). They are attached to their
|
||||
// spec below. Kept as named functions (not inline) so the spec table stays
|
||||
// readable and each mapping is individually greppable/testable.
|
||||
//
|
||||
// The `args` are the host's already-validated, zod-erased input; we read the
|
||||
// same fields the tool's buildShape declares. Return RAW data unless the name is
|
||||
// an mcp*/inApp* override that owns the host's full result shape.
|
||||
|
||||
/** Format a JSON payload as the MCP transport's text-content envelope. Mirrors
|
||||
* the private `jsonContent` in index.ts so an mcpExecute override that must NOT
|
||||
* be re-wrapped can still emit the standard envelope for the data part. */
|
||||
const mcpJson = (data: unknown) => ({
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }],
|
||||
});
|
||||
|
||||
export const SHARED_TOOL_SPECS = {
|
||||
// --- no-argument read tools ---
|
||||
|
||||
@@ -65,6 +196,7 @@ export const SHARED_TOOL_SPECS = {
|
||||
description: 'Fetch metadata about the current workspace (name, settings).',
|
||||
tier: 'core',
|
||||
catalogLine: 'getWorkspace — fetch current workspace metadata (name, settings).',
|
||||
execute: (client) => client.getWorkspace(),
|
||||
},
|
||||
|
||||
listSpaces: {
|
||||
@@ -75,6 +207,7 @@ export const SHARED_TOOL_SPECS = {
|
||||
'spaces (id, name, slug, ...).',
|
||||
tier: 'core',
|
||||
catalogLine: 'listSpaces — list the spaces the user can access (id, name, slug).',
|
||||
execute: (client) => client.getSpaces(),
|
||||
},
|
||||
|
||||
listShares: {
|
||||
@@ -84,6 +217,7 @@ export const SHARED_TOOL_SPECS = {
|
||||
'List all public shares in the workspace with page titles and public URLs.',
|
||||
tier: 'deferred',
|
||||
catalogLine: 'listShares — list all public shares in the workspace with their URLs.',
|
||||
execute: (client) => client.listShares(),
|
||||
},
|
||||
|
||||
// --- single-pageId read tools ---
|
||||
@@ -102,6 +236,7 @@ export const SHARED_TOOL_SPECS = {
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
}),
|
||||
execute: (client, { pageId }) => client.getPageJson(pageId as string),
|
||||
},
|
||||
|
||||
getOutline: {
|
||||
@@ -119,6 +254,7 @@ export const SHARED_TOOL_SPECS = {
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
}),
|
||||
execute: (client, { pageId }) => client.getOutline(pageId as string),
|
||||
},
|
||||
|
||||
// --- two-id read tool ---
|
||||
@@ -139,6 +275,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
pageId: z.string().min(1),
|
||||
nodeId: z.string().min(1),
|
||||
}),
|
||||
execute: (client, { pageId, nodeId }) =>
|
||||
client.getNode(pageId as string, nodeId as string),
|
||||
},
|
||||
|
||||
// --- in-page occurrence search (client-side, over ProseMirror plain text) ---
|
||||
@@ -196,6 +334,12 @@ export const SHARED_TOOL_SPECS = {
|
||||
.optional()
|
||||
.describe('Max matches to RETURN (default 50, max 200); total is always reported.'),
|
||||
}),
|
||||
execute: (client, { pageId, query, regex, caseSensitive, limit }) =>
|
||||
client.searchInPage(pageId as string, query as string, {
|
||||
regex: regex as boolean | undefined,
|
||||
caseSensitive: caseSensitive as boolean | undefined,
|
||||
limit: limit as number | undefined,
|
||||
}),
|
||||
},
|
||||
|
||||
// --- node delete ---
|
||||
@@ -212,6 +356,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
pageId: z.string().min(1),
|
||||
nodeId: z.string().min(1),
|
||||
}),
|
||||
execute: (client, { pageId, nodeId }) =>
|
||||
client.deleteNode(pageId as string, nodeId as string),
|
||||
},
|
||||
|
||||
// --- single-block structural write (patch / insert) ---
|
||||
@@ -262,6 +408,11 @@ export const SHARED_TOOL_SPECS = {
|
||||
'JSON object or JSON string both accepted.',
|
||||
),
|
||||
}),
|
||||
// parseNodeArg normalizes a JSON-string node into an object (the model
|
||||
// sometimes serializes it as a string) before the client's typeof-object
|
||||
// guard rejects it — identical on both hosts.
|
||||
execute: (client, { pageId, nodeId, node }) =>
|
||||
client.patchNode(pageId as string, nodeId as string, parseNodeArg(node)),
|
||||
},
|
||||
|
||||
insertNode: {
|
||||
@@ -318,6 +469,12 @@ export const SHARED_TOOL_SPECS = {
|
||||
'are tolerated as a fallback; prefer plain text or anchorNodeId.',
|
||||
),
|
||||
}),
|
||||
execute: (client, { pageId, node, position, anchorNodeId, anchorText }) =>
|
||||
client.insertNode(pageId as string, parseNodeArg(node), {
|
||||
position: position as 'before' | 'after' | 'append',
|
||||
anchorNodeId: anchorNodeId as string | undefined,
|
||||
anchorText: anchorText as string | undefined,
|
||||
}),
|
||||
},
|
||||
|
||||
// --- share management ---
|
||||
@@ -348,6 +505,11 @@ export const SHARED_TOOL_SPECS = {
|
||||
.optional()
|
||||
.describe('Allow public search engines to index it (default true).'),
|
||||
}),
|
||||
// `searchIndexing ?? true` is a no-op default: the client method already
|
||||
// defaults searchIndexing to true, so passing `undefined` (the in-app form)
|
||||
// and `?? true` (the old MCP form) are byte-identical — one canonical mapping.
|
||||
execute: (client, { pageId, searchIndexing }) =>
|
||||
client.sharePage(pageId as string, (searchIndexing as boolean | undefined) ?? true),
|
||||
},
|
||||
|
||||
unsharePage: {
|
||||
@@ -359,6 +521,7 @@ export const SHARED_TOOL_SPECS = {
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('ID of the page to unshare'),
|
||||
}),
|
||||
execute: (client, { pageId }) => client.unsharePage(pageId as string),
|
||||
},
|
||||
|
||||
// --- version history ---
|
||||
@@ -387,6 +550,12 @@ export const SHARED_TOOL_SPECS = {
|
||||
.optional()
|
||||
.describe("historyId, or 'current'/omit for current content"),
|
||||
}),
|
||||
execute: (client, { pageId, from, to }) =>
|
||||
client.diffPageVersions(
|
||||
pageId as string,
|
||||
from as string | undefined,
|
||||
to as string | undefined,
|
||||
),
|
||||
},
|
||||
|
||||
listPageHistory: {
|
||||
@@ -406,6 +575,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
.optional()
|
||||
.describe('Pagination cursor from a previous nextCursor'),
|
||||
}),
|
||||
execute: (client, { pageId, cursor }) =>
|
||||
client.listPageHistory(pageId as string, cursor as string | undefined),
|
||||
},
|
||||
|
||||
restorePageVersion: {
|
||||
@@ -422,6 +593,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
buildShape: (z) => ({
|
||||
historyId: z.string().min(1),
|
||||
}),
|
||||
execute: (client, { historyId }) =>
|
||||
client.restorePageVersion(historyId as string),
|
||||
},
|
||||
|
||||
// --- markdown round-trip ---
|
||||
@@ -443,6 +616,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
pageId: z.string().min(1),
|
||||
markdown: z.string().min(1),
|
||||
}),
|
||||
execute: (client, { pageId, markdown }) =>
|
||||
client.importPageMarkdown(pageId as string, markdown as string),
|
||||
},
|
||||
|
||||
// --- server-side content copy ---
|
||||
@@ -465,6 +640,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
.min(1)
|
||||
.describe('Page whose content is REPLACED (title/slug kept)'),
|
||||
}),
|
||||
execute: (client, { sourcePageId, targetPageId }) =>
|
||||
client.copyPageContent(sourcePageId as string, targetPageId as string),
|
||||
},
|
||||
|
||||
// --- surgical text edit (folds in the documented drift-bug fix) ---
|
||||
@@ -514,6 +691,11 @@ export const SHARED_TOOL_SPECS = {
|
||||
.min(1)
|
||||
.describe('List of find/replace operations, applied in order'),
|
||||
}),
|
||||
execute: (client, { pageId, edits }) =>
|
||||
client.editPageText(
|
||||
pageId as string,
|
||||
edits as Parameters<DocmostClientLike['editPageText']>[1],
|
||||
),
|
||||
},
|
||||
|
||||
// --- hand a large page to an external consumer without bloating context ---
|
||||
@@ -542,6 +724,33 @@ export const SHARED_TOOL_SPECS = {
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
}),
|
||||
// In-app returns the full documented `{ uri, size, sha256, images }` object
|
||||
// as-is (the canonical execute).
|
||||
execute: (client, { pageId }) => client.stashPage(pageId as string),
|
||||
// The MCP transport must deliver the body as a resource_link (so it never
|
||||
// enters the model context) PLUS a structuredContent mirror of the documented
|
||||
// shape (sha256 = the blob's ETag, mirror counts). Owns its full envelope, so
|
||||
// it is NOT wrapped in jsonContent.
|
||||
mcpExecute: async (client, { pageId }) => {
|
||||
const result = await client.stashPage(pageId as string);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'resource_link' as const,
|
||||
uri: result.uri,
|
||||
name: 'page.json',
|
||||
mimeType: 'application/json',
|
||||
size: result.size,
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
uri: result.uri,
|
||||
sha256: result.sha256,
|
||||
size: result.size,
|
||||
images: result.images,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// --- page tools (unified from the per-layer inline definitions, #294) ---
|
||||
@@ -568,6 +777,20 @@ export const SHARED_TOOL_SPECS = {
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id (or slugId) of the page.'),
|
||||
}),
|
||||
// MCP wraps the raw `{ data, success }` as JSON. The in-app host instead
|
||||
// projects a token-efficient `{ title, markdown }` (its long-standing shape).
|
||||
execute: (client, { pageId }) => client.getPage(pageId as string),
|
||||
inAppExecute: async (client, { pageId }) => {
|
||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||
const result = (await client.getPage(pageId as string)) as {
|
||||
data?: { title?: string; content?: string };
|
||||
};
|
||||
const data = result?.data ?? {};
|
||||
return {
|
||||
title: data.title ?? '',
|
||||
markdown: typeof data.content === 'string' ? data.content : '',
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
listPages: {
|
||||
@@ -602,6 +825,15 @@ export const SHARED_TOOL_SPECS = {
|
||||
'Requires spaceId; ignores limit.',
|
||||
),
|
||||
}),
|
||||
// `limit ?? 50` / `tree ?? false` are no-op defaults: the client method
|
||||
// already defaults limit=50, tree=false, so the old MCP explicit-default form
|
||||
// and the in-app pass-through form are byte-identical — one canonical mapping.
|
||||
execute: (client, { spaceId, limit, tree }) =>
|
||||
client.listPages(
|
||||
spaceId as string | undefined,
|
||||
(limit as number | undefined) ?? 50,
|
||||
(tree as boolean | undefined) ?? false,
|
||||
),
|
||||
},
|
||||
|
||||
createPage: {
|
||||
@@ -630,6 +862,28 @@ export const SHARED_TOOL_SPECS = {
|
||||
.optional()
|
||||
.describe('Optional parent page id to nest the new page under.'),
|
||||
}),
|
||||
// MCP wraps the raw create response as JSON. In-app projects `{ id, title }`
|
||||
// and defensively coerces a missing body to '' (the schema makes content a
|
||||
// required string, so `?? ''` only guards an absent field — preserved).
|
||||
execute: (client, { title, content, spaceId, parentPageId }) =>
|
||||
client.createPage(
|
||||
title as string,
|
||||
content as string,
|
||||
spaceId as string,
|
||||
parentPageId as string | undefined,
|
||||
),
|
||||
inAppExecute: async (client, { title, content, spaceId, parentPageId }) => {
|
||||
// createPage(title, content, spaceId, parentPageId?) ->
|
||||
// { data: filterPage(page, markdown), success }.
|
||||
const result = (await client.createPage(
|
||||
title as string,
|
||||
(content as string | undefined) ?? '',
|
||||
spaceId as string,
|
||||
parentPageId as string | undefined,
|
||||
)) as { data?: { id?: string; slugId?: string; title?: string } };
|
||||
const data = result?.data ?? {};
|
||||
return { id: data.id ?? data.slugId, title: data.title ?? (title as string) };
|
||||
},
|
||||
},
|
||||
|
||||
movePage: {
|
||||
@@ -667,6 +921,62 @@ export const SHARED_TOOL_SPECS = {
|
||||
'append at the end.',
|
||||
),
|
||||
}),
|
||||
// The MCP host keeps its robustness guards (coerce 'null'/'' -> null, a cheap
|
||||
// self-cycle guard, and a POSITIVE { success: true } confirmation) and its
|
||||
// human-readable success envelope — owns its full result, so it is NOT
|
||||
// wrapped in jsonContent.
|
||||
mcpExecute: async (client, { pageId, parentPageId, position }) => {
|
||||
const finalParentId =
|
||||
parentPageId === '' || parentPageId === 'null'
|
||||
? null
|
||||
: (parentPageId as string | null | undefined);
|
||||
|
||||
// Cheap cycle guard: a page cannot be moved directly under itself.
|
||||
// (Deeper descendant-cycle detection is intentionally out of scope.)
|
||||
if (finalParentId !== null && finalParentId === pageId) {
|
||||
throw new Error('cannot move a page under itself');
|
||||
}
|
||||
|
||||
const result = await client.movePage(
|
||||
pageId as string,
|
||||
finalParentId || null,
|
||||
position as string | undefined,
|
||||
);
|
||||
|
||||
// Require POSITIVE confirmation: the live /pages/move success shape is
|
||||
// exactly { success: true, status: 200 }. An empty body, a 204, or any odd
|
||||
// shape lacking success === true must NOT be reported as a successful move.
|
||||
if (
|
||||
!(
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
(result as { success?: unknown }).success === true
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Failed to move page ${pageId}: ${JSON.stringify(result)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return mcpJson({
|
||||
message: `Successfully moved page ${pageId} to parent ${finalParentId || 'root'}`,
|
||||
result,
|
||||
});
|
||||
},
|
||||
// The in-app host has no guards; it forwards `parentPageId ?? null` + the
|
||||
// optional position and projects `{ pageId, parentPageId, moved }`.
|
||||
inAppExecute: async (client, { pageId, parentPageId, position }) => {
|
||||
await client.movePage(
|
||||
pageId as string,
|
||||
(parentPageId as string | null | undefined) ?? null,
|
||||
position as string | undefined,
|
||||
);
|
||||
return {
|
||||
pageId,
|
||||
parentPageId: (parentPageId as string | null | undefined) ?? null,
|
||||
moved: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
renamePage: {
|
||||
@@ -681,6 +991,13 @@ export const SHARED_TOOL_SPECS = {
|
||||
pageId: z.string().min(1).describe('The id of the page to rename.'),
|
||||
title: z.string().min(1).describe('The new title.'),
|
||||
}),
|
||||
// MCP wraps the raw rename response; in-app projects `{ pageId, title }`.
|
||||
execute: (client, { pageId, title }) =>
|
||||
client.renamePage(pageId as string, title as string),
|
||||
inAppExecute: async (client, { pageId, title }) => {
|
||||
await client.renamePage(pageId as string, title as string);
|
||||
return { pageId, title };
|
||||
},
|
||||
},
|
||||
|
||||
deletePage: {
|
||||
@@ -697,6 +1014,23 @@ export const SHARED_TOOL_SPECS = {
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page to move to trash.'),
|
||||
}),
|
||||
// deletePage(pageId) hits POST /pages/delete with { pageId } only — the
|
||||
// soft-delete (trash) path. GUARDRAIL: the schema exposes ONLY pageId, so no
|
||||
// permanent/force-delete flag can reach the client on either host (asserted by
|
||||
// ai-chat-tools.service.spec.ts). MCP emits a bare success line (owns its full
|
||||
// envelope); in-app projects `{ pageId, trashed }`.
|
||||
mcpExecute: async (client, { pageId }) => {
|
||||
await client.deletePage(pageId as string);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Successfully deleted page ${pageId}` },
|
||||
],
|
||||
};
|
||||
},
|
||||
inAppExecute: async (client, { pageId }) => {
|
||||
await client.deletePage(pageId as string);
|
||||
return { pageId, trashed: true };
|
||||
},
|
||||
},
|
||||
|
||||
updatePageJson: {
|
||||
@@ -730,6 +1064,19 @@ export const SHARED_TOOL_SPECS = {
|
||||
),
|
||||
title: z.string().optional().describe('Optional new title'),
|
||||
}),
|
||||
// Content normalization is identical on both hosts: only parse/validate the
|
||||
// document when actually supplied; undefined/null passes straight through so
|
||||
// the client performs a title-only (or no-op) update. A string is JSON.parsed
|
||||
// (an empty string "" therefore throws), an object passes through unchanged.
|
||||
execute: (client, { pageId, content, title }) => {
|
||||
let doc: unknown;
|
||||
if (content === undefined || content === null) {
|
||||
doc = undefined;
|
||||
} else {
|
||||
doc = parseNodeArg(content, 'content was a string but not valid JSON');
|
||||
}
|
||||
return client.updatePageJson(pageId as string, doc, title as string | undefined);
|
||||
},
|
||||
},
|
||||
|
||||
exportPageMarkdown: {
|
||||
@@ -750,6 +1097,17 @@ export const SHARED_TOOL_SPECS = {
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page to export.'),
|
||||
}),
|
||||
// The markdown is a bare string. MCP returns it as a single text-content
|
||||
// element (NOT jsonContent — that would JSON-quote the whole document);
|
||||
// in-app projects `{ markdown }`.
|
||||
mcpExecute: async (client, { pageId }) => {
|
||||
const md = await client.exportPageMarkdown(pageId as string);
|
||||
return { content: [{ type: 'text' as const, text: md }] };
|
||||
},
|
||||
inAppExecute: async (client, { pageId }) => {
|
||||
const markdown = await client.exportPageMarkdown(pageId as string);
|
||||
return { markdown };
|
||||
},
|
||||
},
|
||||
|
||||
// --- comment tools (unified from the per-layer inline definitions, #294) ---
|
||||
@@ -832,6 +1190,69 @@ export const SHARED_TOOL_SPECS = {
|
||||
'refused.',
|
||||
),
|
||||
}),
|
||||
// Both hosts enforce the SAME guardrails (a top-level comment requires a
|
||||
// selection; suggestedText is forbidden on a reply / without a selection) but
|
||||
// with per-layer error wording (snake_case 'create_comment:' on the MCP
|
||||
// surface, camelCase 'createComment' in-app) and different result shapes (MCP
|
||||
// jsonContent, in-app projects `{ commentId, pageId }`). Preserved byte-for-
|
||||
// byte via the two overrides.
|
||||
mcpExecute: async (client, { pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||
if (!parentCommentId && (!selection || !(selection as string).trim())) {
|
||||
throw new Error(
|
||||
"create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
|
||||
);
|
||||
}
|
||||
if (suggestedText !== undefined) {
|
||||
if (parentCommentId) {
|
||||
throw new Error(
|
||||
"create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||
);
|
||||
}
|
||||
if (!selection || !(selection as string).trim()) {
|
||||
throw new Error(
|
||||
"create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = await client.createComment(
|
||||
pageId as string,
|
||||
content as string,
|
||||
'inline',
|
||||
selection as string | undefined,
|
||||
parentCommentId as string | undefined,
|
||||
suggestedText as string | undefined,
|
||||
);
|
||||
return mcpJson(result);
|
||||
},
|
||||
inAppExecute: async (client, { pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||
if (!parentCommentId && (!selection || !(selection as string).trim())) {
|
||||
throw new Error(
|
||||
"createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.",
|
||||
);
|
||||
}
|
||||
if (suggestedText !== undefined) {
|
||||
if (parentCommentId) {
|
||||
throw new Error(
|
||||
"createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||
);
|
||||
}
|
||||
if (!selection || !(selection as string).trim()) {
|
||||
throw new Error(
|
||||
"createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = (await client.createComment(
|
||||
pageId as string,
|
||||
content as string,
|
||||
'inline',
|
||||
selection as string | undefined,
|
||||
parentCommentId as string | undefined,
|
||||
suggestedText as string | undefined,
|
||||
)) as { data?: { id?: string } };
|
||||
const data = result?.data ?? {};
|
||||
return { commentId: data.id, pageId };
|
||||
},
|
||||
},
|
||||
|
||||
listComments: {
|
||||
@@ -857,6 +1278,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
.optional()
|
||||
.describe('default only active threads; true — include resolved'),
|
||||
}),
|
||||
execute: (client, { pageId, includeResolved }) =>
|
||||
client.listComments(pageId as string, includeResolved as boolean | undefined),
|
||||
},
|
||||
|
||||
resolveComment: {
|
||||
@@ -890,6 +1313,13 @@ export const SHARED_TOOL_SPECS = {
|
||||
'true (default) marks the thread resolved/closed; false reopens it',
|
||||
),
|
||||
}),
|
||||
// MCP wraps the raw resolve response; in-app projects `{ commentId, resolved }`.
|
||||
execute: (client, { commentId, resolved }) =>
|
||||
client.resolveComment(commentId as string, resolved as boolean),
|
||||
inAppExecute: async (client, { commentId, resolved }) => {
|
||||
await client.resolveComment(commentId as string, resolved as boolean);
|
||||
return { commentId, resolved };
|
||||
},
|
||||
},
|
||||
|
||||
checkNewComments: {
|
||||
@@ -925,6 +1355,30 @@ export const SHARED_TOOL_SPECS = {
|
||||
'Only pages under this parent will be checked.',
|
||||
),
|
||||
}),
|
||||
// The in-app host has NO `since` guard (the canonical execute, raw). The MCP
|
||||
// host additionally rejects an unparseable `since` up front — otherwise the
|
||||
// NaN comparison silently treats every comment as "not new" and returns zero
|
||||
// without signalling the bad input. This guard is a DELIBERATE per-layer
|
||||
// difference (the in-app surface never had it), preserved via mcpExecute.
|
||||
execute: (client, { spaceId, since, parentPageId }) =>
|
||||
client.checkNewComments(
|
||||
spaceId as string,
|
||||
since as string,
|
||||
parentPageId as string | undefined,
|
||||
),
|
||||
mcpExecute: async (client, { spaceId, since, parentPageId }) => {
|
||||
if (Number.isNaN(Date.parse(since as string))) {
|
||||
throw new Error(
|
||||
`Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`,
|
||||
);
|
||||
}
|
||||
const result = await client.checkNewComments(
|
||||
spaceId as string,
|
||||
since as string,
|
||||
parentPageId as string | undefined,
|
||||
);
|
||||
return mcpJson(result);
|
||||
},
|
||||
},
|
||||
|
||||
// --- table tools (unified from the per-layer inline definitions, #294) ---
|
||||
@@ -971,6 +1425,13 @@ export const SHARED_TOOL_SPECS = {
|
||||
.optional()
|
||||
.describe('0-based insert position (0 inserts before the header); omit to append.'),
|
||||
}),
|
||||
execute: (client, { pageId, table, cells, index }) =>
|
||||
client.tableInsertRow(
|
||||
pageId as string,
|
||||
table as string,
|
||||
cells as string[],
|
||||
index as number | undefined,
|
||||
),
|
||||
},
|
||||
|
||||
tableDeleteRow: {
|
||||
@@ -992,6 +1453,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||
index: z.number().int().describe('0-based row index to delete.'),
|
||||
}),
|
||||
execute: (client, { pageId, table, index }) =>
|
||||
client.tableDeleteRow(pageId as string, table as string, index as number),
|
||||
},
|
||||
|
||||
tableUpdateCell: {
|
||||
@@ -1015,6 +1478,14 @@ export const SHARED_TOOL_SPECS = {
|
||||
col: z.number().int().describe('0-based column index.'),
|
||||
text: z.string().describe('The new cell text.'),
|
||||
}),
|
||||
execute: (client, { pageId, table, row, col, text }) =>
|
||||
client.tableUpdateCell(
|
||||
pageId as string,
|
||||
table as string,
|
||||
row as number,
|
||||
col as number,
|
||||
text as string,
|
||||
),
|
||||
},
|
||||
|
||||
// --- footnote + image write tools (promoted from inline MCP-only, #410) ---
|
||||
@@ -1059,6 +1530,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
.min(1)
|
||||
.describe('The footnote content as markdown (becomes the definition).'),
|
||||
}),
|
||||
execute: (client, { pageId, anchorText, text }) =>
|
||||
client.insertFootnote(pageId as string, anchorText as string, text as string),
|
||||
},
|
||||
|
||||
insertImage: {
|
||||
@@ -1096,6 +1569,13 @@ export const SHARED_TOOL_SPECS = {
|
||||
'Insert the image right after the first top-level block whose text contains this string',
|
||||
),
|
||||
}),
|
||||
execute: (client, { pageId, imageUrl, align, alt, replaceText, afterText }) =>
|
||||
client.insertImage(pageId as string, imageUrl as string, {
|
||||
align: align as 'left' | 'center' | 'right' | undefined,
|
||||
alt: alt as string | undefined,
|
||||
replaceText: replaceText as string | undefined,
|
||||
afterText: afterText as string | undefined,
|
||||
}),
|
||||
},
|
||||
|
||||
replaceImage: {
|
||||
@@ -1127,6 +1607,11 @@ export const SHARED_TOOL_SPECS = {
|
||||
align: z.enum(['left', 'center', 'right']).optional(),
|
||||
alt: z.string().optional(),
|
||||
}),
|
||||
execute: (client, { pageId, attachmentId, imageUrl, align, alt }) =>
|
||||
client.replaceImage(pageId as string, attachmentId as string, imageUrl as string, {
|
||||
align: align as 'left' | 'center' | 'right' | undefined,
|
||||
alt: alt as string | undefined,
|
||||
}),
|
||||
},
|
||||
|
||||
// --- draw.io diagrams (issue #423, stage 1) ---
|
||||
@@ -1156,6 +1641,12 @@ export const SHARED_TOOL_SPECS = {
|
||||
.optional()
|
||||
.describe('"xml" (default) for mxGraph XML, or "svg" for the raw .drawio.svg.'),
|
||||
}),
|
||||
execute: (client, { pageId, node, format }) =>
|
||||
client.drawioGet(
|
||||
pageId as string,
|
||||
node as string,
|
||||
(format as 'xml' | 'svg' | undefined) ?? 'xml',
|
||||
),
|
||||
},
|
||||
|
||||
drawioCreate: {
|
||||
@@ -1202,6 +1693,18 @@ export const SHARED_TOOL_SPECS = {
|
||||
.describe('Anchor text fragment (for before/after).'),
|
||||
title: z.string().optional().describe('Optional diagram title.'),
|
||||
}),
|
||||
// The flat schema fields are regrouped into the client's `where` object.
|
||||
execute: (client, { pageId, xml, position, anchorNodeId, anchorText, title }) =>
|
||||
client.drawioCreate(
|
||||
pageId as string,
|
||||
{
|
||||
position: position as 'before' | 'after' | 'append',
|
||||
anchorNodeId: anchorNodeId as string | undefined,
|
||||
anchorText: anchorText as string | undefined,
|
||||
},
|
||||
xml as string,
|
||||
title as string | undefined,
|
||||
),
|
||||
},
|
||||
|
||||
drawioUpdate: {
|
||||
@@ -1235,5 +1738,12 @@ export const SHARED_TOOL_SPECS = {
|
||||
.min(1)
|
||||
.describe('The meta.hash from the drawio_get this edit is based on.'),
|
||||
}),
|
||||
execute: (client, { pageId, node, xml, baseHash }) =>
|
||||
client.drawioUpdate(
|
||||
pageId as string,
|
||||
node as string,
|
||||
xml as string,
|
||||
baseHash as string,
|
||||
),
|
||||
},
|
||||
} satisfies Record<string, SharedToolSpec>;
|
||||
|
||||
Reference in New Issue
Block a user