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),
}),
getOutline: sharedTool(
sharedToolSpecs.getOutline,
async ({ pageId }) => await client.getOutline(pageId),
),
getPageJson: tool({
description:
'Fetch a page as lossless ProseMirror JSON (preserves block ids and ' +
'marks). Use this when you need exact structure for node-level edits.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
}),
execute: async ({ pageId }) => await client.getPageJson(pageId),
}),
getPageJson: sharedTool(
sharedToolSpecs.getPageJson,
async ({ pageId }) => await client.getPageJson(pageId),
),
getNode: tool({
description:
"Fetch a single block's full ProseMirror subtree (lossless) by " +
'reference.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
nodeId: z
.string()
.describe(
'A block id from getOutline, or "#<index>" to select a ' +
'top-level block by its outline index (e.g. a table).',
),
}),
execute: async ({ pageId, nodeId }) =>
await client.getNode(pageId, nodeId),
}),
getNode: sharedTool(
sharedToolSpecs.getNode,
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
),
getTable: tool({
description:
@@ -570,27 +559,16 @@ export class AiChatToolsService {
await client.checkNewComments(spaceId, since, parentPageId),
}),
listShares: tool({
description:
'List all public shares in the workspace, each with its public URL.',
inputSchema: z.object({}),
execute: async () => await client.listShares(),
}),
listShares: sharedTool(
sharedToolSpecs.listShares,
async () => await client.listShares(),
),
listPageHistory: tool({
description:
'List the saved versions (history snapshots) of a page, newest ' +
'first. Returns one cursor-paginated page of results.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
cursor: z
.string()
.optional()
.describe('Optional pagination cursor from a previous call.'),
}),
execute: async ({ pageId, cursor }) =>
listPageHistory: sharedTool(
sharedToolSpecs.listPageHistory,
async ({ pageId, cursor }) =>
await client.listPageHistory(pageId, cursor),
}),
),
getPageHistory: tool({
description:
@@ -603,24 +581,11 @@ export class AiChatToolsService {
await client.getPageHistory(historyId),
}),
diffPageVersions: tool({
description:
'Diff two versions of a page and return the change set. from/to ' +
"each accept a historyId or 'current' (or omit for current).",
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
from: z
.string()
.optional()
.describe("A historyId, or 'current'/omit for current content."),
to: z
.string()
.optional()
.describe("A historyId, or 'current'/omit for current content."),
}),
execute: async ({ pageId, from, to }) =>
diffPageVersions: sharedTool(
sharedToolSpecs.diffPageVersions,
async ({ pageId, from, to }) =>
await client.diffPageVersions(pageId, from, to),
}),
),
exportPageMarkdown: tool({
description:
@@ -638,46 +603,10 @@ export class AiChatToolsService {
// --- WRITE tools (added; reversible via page history/trash) ---
editPageText: tool({
description:
'Surgical find/replace inside a page\'s text, preserving all block ' +
'ids and marks. A find MAY cross bold/italic/link boundaries; the ' +
'replacement inherits marks from the unchanged common prefix/suffix ' +
'(so editing plain text next to a bold word keeps it bold, and ' +
'editing inside a bold word keeps the new text bold). Each find must ' +
'match exactly once unless replaceAll is set. The batch applies what ' +
'it can and returns applied[] + failed[] plus a verify change-report ' +
'(the text/marks/structure that ACTUALLY changed — read it to confirm ' +
'your edit landed; do not assume success); a fully-unmatched batch ' +
'writes nothing and errors. find and replace are LITERAL text, not ' +
'markdown. This tool edits plain text ONLY and CANNOT add or remove ' +
'formatting marks: a formatting change — find/replace that differ only ' +
'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' +
'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' +
'failed[]. To change bold/italic/strike/code/link, read the block with ' +
'getPageJson and use patchNode (or updatePageJson) to set its marks. ' +
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
'world",replace:"Hello there"}] (crosses a bold boundary). Reversible: ' +
'the previous version is kept in page history.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to edit.'),
edits: z
.array(
z.object({
find: z.string().describe('Exact text to find.'),
replace: z.string().describe('Replacement text.'),
replaceAll: z
.boolean()
.optional()
.describe('Replace every occurrence (default: one match).'),
}),
)
.min(1)
.describe('One or more find/replace edits.'),
}),
execute: async ({ pageId, edits }) =>
await client.editPageText(pageId, edits),
}),
editPageText: sharedTool(
sharedToolSpecs.editPageText,
async ({ pageId, edits }) => await client.editPageText(pageId, edits),
),
patchNode: tool({
description:
@@ -767,17 +696,10 @@ export class AiChatToolsService {
},
}),
deleteNode: tool({
description:
'Remove a content BLOCK by its id (NOT a page). Reversible: the ' +
'previous version is kept in page history.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
nodeId: z.string().describe('The block id to remove.'),
}),
execute: async ({ pageId, nodeId }) =>
await client.deleteNode(pageId, nodeId),
}),
deleteNode: sharedTool(
sharedToolSpecs.deleteNode,
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
),
updatePageJson: tool({
description:
@@ -866,35 +788,17 @@ export class AiChatToolsService {
await client.tableUpdateCell(pageId, tableRef, row, col, text),
}),
copyPageContent: tool({
description:
"Replace the target page's BODY with the source page's body " +
'(title/slug are kept). Runs server-side — no document passes ' +
'through the model. Reversible: the target keeps page history.',
inputSchema: z.object({
sourcePageId: z.string().describe('The id of the source page.'),
targetPageId: z
.string()
.describe('The id of the target page to overwrite.'),
}),
execute: async ({ sourcePageId, targetPageId }) =>
copyPageContent: sharedTool(
sharedToolSpecs.copyPageContent,
async ({ sourcePageId, targetPageId }) =>
await client.copyPageContent(sourcePageId, targetPageId),
}),
),
importPageMarkdown: tool({
description:
"Replace a page's body from Docmost-flavoured Markdown (as produced " +
'by exportPageMarkdown). Reversible: the previous version is kept in ' +
'page history.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to overwrite.'),
markdown: z
.string()
.describe('Docmost-flavoured Markdown for the page body.'),
}),
execute: async ({ pageId, markdown }) =>
importPageMarkdown: sharedTool(
sharedToolSpecs.importPageMarkdown,
async ({ pageId, markdown }) =>
await client.importPageMarkdown(pageId, markdown),
}),
),
sharePage: tool({
description:
@@ -912,27 +816,15 @@ export class AiChatToolsService {
await client.sharePage(pageId, searchIndexing),
}),
unsharePage: tool({
description:
'Remove the public share of a page (reverses sharePage).',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to unshare.'),
}),
execute: async ({ pageId }) => await client.unsharePage(pageId),
}),
unsharePage: sharedTool(
sharedToolSpecs.unsharePage,
async ({ pageId }) => await client.unsharePage(pageId),
),
restorePageVersion: tool({
description:
'Restore a past version by writing its content back as the current ' +
'page content. Itself reversible: it creates a new history snapshot.',
inputSchema: z.object({
historyId: z
.string()
.describe('The id of the history version to restore.'),
}),
execute: async ({ historyId }) =>
await client.restorePageVersion(historyId),
}),
restorePageVersion: sharedTool(
sharedToolSpecs.restorePageVersion,
async ({ historyId }) => await client.restorePageVersion(historyId),
),
transformPage: tool({
description:

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,
};
}