refactor(ai-chat): shared tool-spec registry for identical tools; formalize integration db factory

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>
This commit is contained in:
claude_code
2026-06-21 18:57:00 +03:00
parent 4720705155
commit f3fa15e746
10 changed files with 908 additions and 593 deletions

View File

@@ -1,6 +1,19 @@
import { AiChatToolsService } from './ai-chat-tools.service';
import * as loader from './docmost-client.loader';
import type { DocmostClientLike } from './docmost-client.loader';
// The real zod-agnostic shared tool-spec registry. It has no runtime deps, so
// importing the TS source directly keeps these mocks honest: the service builds
// the shared tools from exactly the specs the package ships, not a hand-stub.
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
// loadDocmostMcp now resolves to { DocmostClient, sharedToolSpecs }. Every mock
// below must supply sharedToolSpecs or the service throws while building the
// shared tools. Factor the resolved-value shape so the three mock sites stay in
// sync.
const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({
DocmostClient,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
});
/**
* Guardrail test (§14 [H4]): the adapter's `deletePage` write tool must be a
@@ -37,11 +50,11 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
beforeEach(() => {
deletePageCalls.length = 0;
// Intercept the ESM loader so `new DocmostClient(config)` returns our fake.
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
DocmostClient: function () {
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor,
});
} as unknown as loader.DocmostClientCtor),
);
// The new semanticSearch deps (aiService + repos) are not exercised by the
// deletePage guardrail tests; pass stubs to satisfy the constructor arity.
service = new AiChatToolsService(
@@ -144,11 +157,11 @@ describe('AiChatToolsService expanded toolset guardrails', () => {
let service: AiChatToolsService;
beforeEach(() => {
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
DocmostClient: function () {
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor,
});
} as unknown as loader.DocmostClientCtor),
);
service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,
@@ -252,11 +265,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
patchNodeCalls.length = 0;
insertNodeCalls.length = 0;
updatePageJsonCalls.length = 0;
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
DocmostClient: function () {
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor,
});
} as unknown as loader.DocmostClientCtor),
);
service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,

View File

@@ -11,6 +11,7 @@ import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'
import {
loadDocmostMcp,
type DocmostClientLike,
type SharedToolSpec,
} from './docmost-client.loader';
import { resolveCurrentPageResult } from './current-page.util';
import { parseNodeArg } from './parse-node-arg';
@@ -84,13 +85,29 @@ export class AiChatToolsService {
aiChatId,
});
const { DocmostClient } = await loadDocmostMcp();
const { DocmostClient, sharedToolSpecs } = await loadDocmostMcp();
const client: DocmostClientLike = new DocmostClient({
apiUrl,
getToken,
getCollabToken,
});
// Build an ai-SDK tool from a shared, zod-agnostic spec. The spec owns the
// canonical description + (optional) schema builder, which is invoked with
// THIS layer's zod (v4); only the execute body is supplied per call. No-arg
// specs (no buildShape) get an empty object schema.
const sharedTool = (
spec: SharedToolSpec,
execute: Tool['execute'],
): Tool =>
tool({
description: spec.description,
inputSchema: spec.buildShape
? z.object(spec.buildShape(z) as z.ZodRawShape)
: z.object({}),
execute,
});
return {
searchPages: tool({
description:
@@ -416,20 +433,15 @@ export class AiChatToolsService {
// --- READ tools (added) ---
getWorkspace: tool({
description:
'Fetch metadata about the current workspace (name, settings).',
inputSchema: z.object({}),
execute: async () => await client.getWorkspace(),
}),
getWorkspace: sharedTool(
sharedToolSpecs.getWorkspace,
async () => await client.getWorkspace(),
),
listSpaces: tool({
description:
'List the spaces the current user can access. Returns the array ' +
'of spaces (id, name, slug, ...).',
inputSchema: z.object({}),
execute: async () => await client.getSpaces(),
}),
listSpaces: sharedTool(
sharedToolSpecs.listSpaces,
async () => await client.getSpaces(),
),
listPages: tool({
description:
@@ -477,43 +489,20 @@ export class AiChatToolsService {
await client.listSidebarPages(spaceId, pageId),
}),
getOutline: tool({
description:
"Compact outline of a page's top-level blocks, with block ids. Use " +
'it to locate sections/tables and grab block ids before drilling in ' +
'with getNode / patchNode / insertNode.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
}),
execute: async ({ pageId }) => await client.getOutline(pageId),
}),
getPageJson: tool({
description:
'Fetch a page as lossless ProseMirror JSON (preserves block ids and ' +
'marks). Use this when you need exact structure for node-level edits.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
}),
execute: async ({ pageId }) => await client.getPageJson(pageId),
}),
getNode: tool({
description:
"Fetch a single block's full ProseMirror subtree (lossless) by " +
'reference.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
nodeId: z
.string()
.describe(
'A block id from getOutline, or "#<index>" to select a ' +
'top-level block by its outline index (e.g. a table).',
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),
),
}),
execute: async ({ pageId, nodeId }) =>
await client.getNode(pageId, nodeId),
}),
getTable: tool({
description:
@@ -570,27 +559,16 @@ export class AiChatToolsService {
await client.checkNewComments(spaceId, since, parentPageId),
}),
listShares: tool({
description:
'List all public shares in the workspace, each with its public URL.',
inputSchema: z.object({}),
execute: async () => await client.listShares(),
}),
listShares: sharedTool(
sharedToolSpecs.listShares,
async () => await client.listShares(),
),
listPageHistory: tool({
description:
'List the saved versions (history snapshots) of a page, newest ' +
'first. Returns one cursor-paginated page of results.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
cursor: z
.string()
.optional()
.describe('Optional pagination cursor from a previous call.'),
}),
execute: async ({ pageId, cursor }) =>
listPageHistory: sharedTool(
sharedToolSpecs.listPageHistory,
async ({ pageId, cursor }) =>
await client.listPageHistory(pageId, cursor),
}),
),
getPageHistory: tool({
description:
@@ -603,24 +581,11 @@ export class AiChatToolsService {
await client.getPageHistory(historyId),
}),
diffPageVersions: tool({
description:
'Diff two versions of a page and return the change set. from/to ' +
"each accept a historyId or 'current' (or omit for current).",
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
from: z
.string()
.optional()
.describe("A historyId, or 'current'/omit for current content."),
to: z
.string()
.optional()
.describe("A historyId, or 'current'/omit for current content."),
}),
execute: async ({ pageId, from, to }) =>
diffPageVersions: sharedTool(
sharedToolSpecs.diffPageVersions,
async ({ pageId, from, to }) =>
await client.diffPageVersions(pageId, from, to),
}),
),
exportPageMarkdown: tool({
description:
@@ -638,46 +603,10 @@ export class AiChatToolsService {
// --- WRITE tools (added; reversible via page history/trash) ---
editPageText: tool({
description:
'Surgical find/replace inside a page\'s text, preserving all block ' +
'ids and marks. A find MAY cross bold/italic/link boundaries; the ' +
'replacement inherits marks from the unchanged common prefix/suffix ' +
'(so editing plain text next to a bold word keeps it bold, and ' +
'editing inside a bold word keeps the new text bold). Each find must ' +
'match exactly once unless replaceAll is set. The batch applies what ' +
'it can and returns applied[] + failed[] plus a verify change-report ' +
'(the text/marks/structure that ACTUALLY changed — read it to confirm ' +
'your edit landed; do not assume success); a fully-unmatched batch ' +
'writes nothing and errors. find and replace are LITERAL text, not ' +
'markdown. This tool edits plain text ONLY and CANNOT add or remove ' +
'formatting marks: a formatting change — find/replace that differ only ' +
'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' +
'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' +
'failed[]. To change bold/italic/strike/code/link, read the block with ' +
'getPageJson and use patchNode (or updatePageJson) to set its marks. ' +
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
'world",replace:"Hello there"}] (crosses a bold boundary). Reversible: ' +
'the previous version is kept in page history.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to edit.'),
edits: z
.array(
z.object({
find: z.string().describe('Exact text to find.'),
replace: z.string().describe('Replacement text.'),
replaceAll: z
.boolean()
.optional()
.describe('Replace every occurrence (default: one match).'),
}),
)
.min(1)
.describe('One or more find/replace edits.'),
}),
execute: async ({ pageId, edits }) =>
await client.editPageText(pageId, edits),
}),
editPageText: sharedTool(
sharedToolSpecs.editPageText,
async ({ pageId, edits }) => await client.editPageText(pageId, edits),
),
patchNode: tool({
description:
@@ -767,17 +696,10 @@ export class AiChatToolsService {
},
}),
deleteNode: tool({
description:
'Remove a content BLOCK by its id (NOT a page). Reversible: the ' +
'previous version is kept in page history.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
nodeId: z.string().describe('The block id to remove.'),
}),
execute: async ({ pageId, nodeId }) =>
await client.deleteNode(pageId, nodeId),
}),
deleteNode: sharedTool(
sharedToolSpecs.deleteNode,
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
),
updatePageJson: tool({
description:
@@ -866,35 +788,17 @@ export class AiChatToolsService {
await client.tableUpdateCell(pageId, tableRef, row, col, text),
}),
copyPageContent: tool({
description:
"Replace the target page's BODY with the source page's body " +
'(title/slug are kept). Runs server-side — no document passes ' +
'through the model. Reversible: the target keeps page history.',
inputSchema: z.object({
sourcePageId: z.string().describe('The id of the source page.'),
targetPageId: z
.string()
.describe('The id of the target page to overwrite.'),
}),
execute: async ({ sourcePageId, targetPageId }) =>
copyPageContent: sharedTool(
sharedToolSpecs.copyPageContent,
async ({ sourcePageId, targetPageId }) =>
await client.copyPageContent(sourcePageId, targetPageId),
}),
),
importPageMarkdown: tool({
description:
"Replace a page's body from Docmost-flavoured Markdown (as produced " +
'by exportPageMarkdown). Reversible: the previous version is kept in ' +
'page history.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to overwrite.'),
markdown: z
.string()
.describe('Docmost-flavoured Markdown for the page body.'),
}),
execute: async ({ pageId, markdown }) =>
importPageMarkdown: sharedTool(
sharedToolSpecs.importPageMarkdown,
async ({ pageId, markdown }) =>
await client.importPageMarkdown(pageId, markdown),
}),
),
sharePage: tool({
description:
@@ -912,27 +816,15 @@ export class AiChatToolsService {
await client.sharePage(pageId, searchIndexing),
}),
unsharePage: tool({
description:
'Remove the public share of a page (reverses sharePage).',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to unshare.'),
}),
execute: async ({ pageId }) => await client.unsharePage(pageId),
}),
unsharePage: sharedTool(
sharedToolSpecs.unsharePage,
async ({ pageId }) => await client.unsharePage(pageId),
),
restorePageVersion: tool({
description:
'Restore a past version by writing its content back as the current ' +
'page content. Itself reversible: it creates a new history snapshot.',
inputSchema: z.object({
historyId: z
.string()
.describe('The id of the history version to restore.'),
}),
execute: async ({ historyId }) =>
await client.restorePageVersion(historyId),
}),
restorePageVersion: sharedTool(
sharedToolSpecs.restorePageVersion,
async ({ historyId }) => await client.restorePageVersion(historyId),
),
transformPage: tool({
description:

View File

@@ -167,8 +167,29 @@ export interface DocmostClientCtor {
new (config: DocmostClientConfig): DocmostClientLike;
}
/**
* Local hand-mirror of the `SharedToolSpec` shape exported from
* `@docmost/mcp` (packages/mcp/src/tool-specs.ts). Same approach as
* `DocmostClientLike`: we do not import the ESM package's types directly across
* the CJS/ESM boundary. The registry itself has no runtime deps, but keeping the
* type local avoids coupling the server build to the package's type surface.
*
* `buildShape` is intentionally zod-agnostic: it returns a plain ZodRawShape
* built with whatever zod namespace the caller passes (the server passes its own
* zod v4; the MCP package passes its zod v3). See the registry module comment.
*/
export interface SharedToolSpec {
mcpName: string;
inAppKey: string;
description: string;
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
// its own zod (v4) and the MCP package its own (v3) into the same builder.
buildShape?: (z: any) => Record<string, unknown>;
}
interface DocmostMcpModule {
DocmostClient: DocmostClientCtor;
SHARED_TOOL_SPECS: Record<string, SharedToolSpec>;
}
// TS with module:commonjs downlevels a literal `import()` to `require()`, which
@@ -191,6 +212,7 @@ let modulePromise: Promise<DocmostMcpModule> | null = null;
*/
export async function loadDocmostMcp(): Promise<{
DocmostClient: DocmostClientCtor;
sharedToolSpecs: Record<string, SharedToolSpec>;
}> {
if (!modulePromise) {
modulePromise = (async () => {
@@ -206,5 +228,15 @@ export async function loadDocmostMcp(): Promise<{
});
}
const mod = await modulePromise;
return { DocmostClient: mod.DocmostClient };
if (!mod.SHARED_TOOL_SPECS) {
// A stale @docmost/mcp build (missing the shared registry export) would
// otherwise surface as a confusing TypeError deep in the tools service.
throw new Error(
'@docmost/mcp is stale: SHARED_TOOL_SPECS missing — rebuild the package (pnpm --filter @docmost/mcp build).',
);
}
return {
DocmostClient: mod.DocmostClient,
sharedToolSpecs: mod.SHARED_TOOL_SPECS,
};
}

View File

@@ -3,6 +3,31 @@ import { CamelCasePlugin, Kysely } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
/**
* db.ts — THE canonical place to seed prerequisite rows for integration tests.
*
* Seeders here use minimal, explicit `insertInto(...).values(...)` calls and are
* DELIBERATELY decoupled from the app's repo `insert*` methods. Those repo
* methods carry side effects integration specs do not want — password hashing,
* validation, default/derived columns, event emission — so reproducing only the
* columns a test needs keeps the fixtures small, fast and predictable.
*
* CONVENTIONS:
* - New entity seeders go HERE (a `createX(db, ...)` helper) rather than as raw
* `insertInto` calls scattered across spec files, so the schema knowledge
* lives in one place.
* - Each seeder inserts only the NOT NULL / uniquely-constrained columns plus
* whatever the consuming tests assert on; everything else is left to DB
* defaults.
* - Plain `randomUUID()` (v4) is fine for FK integrity; the app uses uuid v7,
* but tests never depend on id ordering.
*
* TRADE-OFF: because the column/constraint knowledge below is mirrored from the
* Kysely schema rather than derived from it, a migration that changes a NOT NULL
* column or a unique constraint can make an insert here fail. When that happens
* the fix is to update the relevant seeder, not the spec that calls it.
*/
/**
* Isolated test database connection string. The dev DB is `docmost`; tests run
* against a dedicated `docmost_test` that global-setup drops + recreates +
@@ -58,21 +83,27 @@ export async function destroyTestDb(): Promise<void> {
}
// --- Seeding helpers ---------------------------------------------------------
// Insert minimal valid rows (only the columns the tests need + NOT NULL ones).
// Plain randomUUID() is fine for FK integrity in tests (the app uses uuid v7).
// Each helper inserts a minimal valid row (only the columns the tests need plus
// the NOT NULL / uniquely-constrained ones) and returns the generated id. See
// the module doc comment above for why these bypass the app's repo layer.
// Short, human-readable suffix derived from a row's uuid. Used to build unique
// names/slugs/hostnames for seeded rows so unique constraints never collide.
const shortId = (id: string): string => id.slice(0, 8);
export async function createWorkspace(
db: Kysely<any>,
overrides: { settings?: unknown; name?: string } = {},
): Promise<{ id: string; settings: any }> {
const id = randomUUID();
const suffix = shortId(id);
const row = await db
.insertInto('workspaces')
.values({
id,
name: overrides.name ?? `ws-${id.slice(0, 8)}`,
name: overrides.name ?? `ws-${suffix}`,
// hostname is uniquely constrained; keep it unique per workspace.
hostname: `host-${id.slice(0, 8)}`,
hostname: `host-${suffix}`,
settings: overrides.settings === undefined ? null : (overrides.settings as any),
})
.returning(['id', 'settings'])
@@ -86,12 +117,13 @@ export async function createUser(
overrides: { email?: string; name?: string } = {},
): Promise<{ id: string }> {
const id = randomUUID();
const suffix = shortId(id);
const row = await db
.insertInto('users')
.values({
id,
email: overrides.email ?? `user-${id.slice(0, 8)}@example.test`,
name: overrides.name ?? `user-${id.slice(0, 8)}`,
email: overrides.email ?? `user-${suffix}@example.test`,
name: overrides.name ?? `user-${suffix}`,
workspaceId,
})
.returning(['id'])
@@ -105,13 +137,14 @@ export async function createSpace(
overrides: { slug?: string; name?: string } = {},
): Promise<{ id: string }> {
const id = randomUUID();
const suffix = shortId(id);
const row = await db
.insertInto('spaces')
.values({
id,
name: overrides.name ?? `space-${id.slice(0, 8)}`,
name: overrides.name ?? `space-${suffix}`,
// slug is unique per workspace + NOT NULL.
slug: overrides.slug ?? `space-${id.slice(0, 8)}`,
slug: overrides.slug ?? `space-${suffix}`,
workspaceId,
})
.returning(['id'])
@@ -124,13 +157,14 @@ export async function createPage(
args: { workspaceId: string; spaceId: string; title?: string },
): Promise<{ id: string }> {
const id = randomUUID();
const suffix = shortId(id);
const row = await db
.insertInto('pages')
.values({
id,
// slug_id is NOT NULL + globally unique.
slugId: `slug-${id.slice(0, 8)}`,
title: args.title ?? `page-${id.slice(0, 8)}`,
slugId: `slug-${suffix}`,
title: args.title ?? `page-${suffix}`,
spaceId: args.spaceId,
workspaceId: args.workspaceId,
})
@@ -186,7 +220,7 @@ export async function createChat(
workspaceId: args.workspaceId,
creatorId: args.creatorId,
roleId: args.roleId ?? null,
title: args.title ?? `chat-${id.slice(0, 8)}`,
title: args.title ?? `chat-${shortId(id)}`,
})
.returning(['id'])
.executeTakeFirstOrThrow();

View File

@@ -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);
});

View File

@@ -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,

View 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'),
}),
},
};

View File

@@ -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 }) => {
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);
},
);
});
// 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 }) => {
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 }) => {
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 }) => {
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);
},
);
});
// 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);

View 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>;

View 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`);
}
});