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:
@@ -1,6 +1,19 @@
|
|||||||
import { AiChatToolsService } from './ai-chat-tools.service';
|
import { AiChatToolsService } from './ai-chat-tools.service';
|
||||||
import * as loader from './docmost-client.loader';
|
import * as loader from './docmost-client.loader';
|
||||||
import type { DocmostClientLike } 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
|
* Guardrail test (§14 [H4]): the adapter's `deletePage` write tool must be a
|
||||||
@@ -37,11 +50,11 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
deletePageCalls.length = 0;
|
deletePageCalls.length = 0;
|
||||||
// Intercept the ESM loader so `new DocmostClient(config)` returns our fake.
|
// Intercept the ESM loader so `new DocmostClient(config)` returns our fake.
|
||||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||||
DocmostClient: function () {
|
mockLoaded(function () {
|
||||||
return fakeClient as DocmostClientLike;
|
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
|
// The new semanticSearch deps (aiService + repos) are not exercised by the
|
||||||
// deletePage guardrail tests; pass stubs to satisfy the constructor arity.
|
// deletePage guardrail tests; pass stubs to satisfy the constructor arity.
|
||||||
service = new AiChatToolsService(
|
service = new AiChatToolsService(
|
||||||
@@ -144,11 +157,11 @@ describe('AiChatToolsService expanded toolset guardrails', () => {
|
|||||||
let service: AiChatToolsService;
|
let service: AiChatToolsService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||||
DocmostClient: function () {
|
mockLoaded(function () {
|
||||||
return fakeClient as DocmostClientLike;
|
return fakeClient as DocmostClientLike;
|
||||||
} as unknown as loader.DocmostClientCtor,
|
} as unknown as loader.DocmostClientCtor),
|
||||||
});
|
);
|
||||||
service = new AiChatToolsService(
|
service = new AiChatToolsService(
|
||||||
tokenServiceStub as never,
|
tokenServiceStub as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -252,11 +265,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
|
|||||||
patchNodeCalls.length = 0;
|
patchNodeCalls.length = 0;
|
||||||
insertNodeCalls.length = 0;
|
insertNodeCalls.length = 0;
|
||||||
updatePageJsonCalls.length = 0;
|
updatePageJsonCalls.length = 0;
|
||||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||||
DocmostClient: function () {
|
mockLoaded(function () {
|
||||||
return fakeClient as DocmostClientLike;
|
return fakeClient as DocmostClientLike;
|
||||||
} as unknown as loader.DocmostClientCtor,
|
} as unknown as loader.DocmostClientCtor),
|
||||||
});
|
);
|
||||||
service = new AiChatToolsService(
|
service = new AiChatToolsService(
|
||||||
tokenServiceStub as never,
|
tokenServiceStub as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'
|
|||||||
import {
|
import {
|
||||||
loadDocmostMcp,
|
loadDocmostMcp,
|
||||||
type DocmostClientLike,
|
type DocmostClientLike,
|
||||||
|
type SharedToolSpec,
|
||||||
} from './docmost-client.loader';
|
} from './docmost-client.loader';
|
||||||
import { resolveCurrentPageResult } from './current-page.util';
|
import { resolveCurrentPageResult } from './current-page.util';
|
||||||
import { parseNodeArg } from './parse-node-arg';
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
@@ -84,13 +85,29 @@ export class AiChatToolsService {
|
|||||||
aiChatId,
|
aiChatId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { DocmostClient } = await loadDocmostMcp();
|
const { DocmostClient, sharedToolSpecs } = await loadDocmostMcp();
|
||||||
const client: DocmostClientLike = new DocmostClient({
|
const client: DocmostClientLike = new DocmostClient({
|
||||||
apiUrl,
|
apiUrl,
|
||||||
getToken,
|
getToken,
|
||||||
getCollabToken,
|
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 {
|
return {
|
||||||
searchPages: tool({
|
searchPages: tool({
|
||||||
description:
|
description:
|
||||||
@@ -416,20 +433,15 @@ export class AiChatToolsService {
|
|||||||
|
|
||||||
// --- READ tools (added) ---
|
// --- READ tools (added) ---
|
||||||
|
|
||||||
getWorkspace: tool({
|
getWorkspace: sharedTool(
|
||||||
description:
|
sharedToolSpecs.getWorkspace,
|
||||||
'Fetch metadata about the current workspace (name, settings).',
|
async () => await client.getWorkspace(),
|
||||||
inputSchema: z.object({}),
|
),
|
||||||
execute: async () => await client.getWorkspace(),
|
|
||||||
}),
|
|
||||||
|
|
||||||
listSpaces: tool({
|
listSpaces: sharedTool(
|
||||||
description:
|
sharedToolSpecs.listSpaces,
|
||||||
'List the spaces the current user can access. Returns the array ' +
|
async () => await client.getSpaces(),
|
||||||
'of spaces (id, name, slug, ...).',
|
),
|
||||||
inputSchema: z.object({}),
|
|
||||||
execute: async () => await client.getSpaces(),
|
|
||||||
}),
|
|
||||||
|
|
||||||
listPages: tool({
|
listPages: tool({
|
||||||
description:
|
description:
|
||||||
@@ -477,43 +489,20 @@ export class AiChatToolsService {
|
|||||||
await client.listSidebarPages(spaceId, pageId),
|
await client.listSidebarPages(spaceId, pageId),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getOutline: tool({
|
getOutline: sharedTool(
|
||||||
description:
|
sharedToolSpecs.getOutline,
|
||||||
"Compact outline of a page's top-level blocks, with block ids. Use " +
|
async ({ pageId }) => await client.getOutline(pageId),
|
||||||
'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({
|
getPageJson: sharedTool(
|
||||||
description:
|
sharedToolSpecs.getPageJson,
|
||||||
'Fetch a page as lossless ProseMirror JSON (preserves block ids and ' +
|
async ({ pageId }) => await client.getPageJson(pageId),
|
||||||
'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({
|
getNode: sharedTool(
|
||||||
description:
|
sharedToolSpecs.getNode,
|
||||||
"Fetch a single block's full ProseMirror subtree (lossless) by " +
|
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
||||||
'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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
getTable: tool({
|
getTable: tool({
|
||||||
description:
|
description:
|
||||||
@@ -570,27 +559,16 @@ export class AiChatToolsService {
|
|||||||
await client.checkNewComments(spaceId, since, parentPageId),
|
await client.checkNewComments(spaceId, since, parentPageId),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listShares: tool({
|
listShares: sharedTool(
|
||||||
description:
|
sharedToolSpecs.listShares,
|
||||||
'List all public shares in the workspace, each with its public URL.',
|
async () => await client.listShares(),
|
||||||
inputSchema: z.object({}),
|
),
|
||||||
execute: async () => await client.listShares(),
|
|
||||||
}),
|
|
||||||
|
|
||||||
listPageHistory: tool({
|
listPageHistory: sharedTool(
|
||||||
description:
|
sharedToolSpecs.listPageHistory,
|
||||||
'List the saved versions (history snapshots) of a page, newest ' +
|
async ({ pageId, cursor }) =>
|
||||||
'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 }) =>
|
|
||||||
await client.listPageHistory(pageId, cursor),
|
await client.listPageHistory(pageId, cursor),
|
||||||
}),
|
),
|
||||||
|
|
||||||
getPageHistory: tool({
|
getPageHistory: tool({
|
||||||
description:
|
description:
|
||||||
@@ -603,24 +581,11 @@ export class AiChatToolsService {
|
|||||||
await client.getPageHistory(historyId),
|
await client.getPageHistory(historyId),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
diffPageVersions: tool({
|
diffPageVersions: sharedTool(
|
||||||
description:
|
sharedToolSpecs.diffPageVersions,
|
||||||
'Diff two versions of a page and return the change set. from/to ' +
|
async ({ pageId, 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 }) =>
|
|
||||||
await client.diffPageVersions(pageId, from, to),
|
await client.diffPageVersions(pageId, from, to),
|
||||||
}),
|
),
|
||||||
|
|
||||||
exportPageMarkdown: tool({
|
exportPageMarkdown: tool({
|
||||||
description:
|
description:
|
||||||
@@ -638,46 +603,10 @@ export class AiChatToolsService {
|
|||||||
|
|
||||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||||
|
|
||||||
editPageText: tool({
|
editPageText: sharedTool(
|
||||||
description:
|
sharedToolSpecs.editPageText,
|
||||||
'Surgical find/replace inside a page\'s text, preserving all block ' +
|
async ({ pageId, edits }) => await client.editPageText(pageId, edits),
|
||||||
'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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
patchNode: tool({
|
patchNode: tool({
|
||||||
description:
|
description:
|
||||||
@@ -767,17 +696,10 @@ export class AiChatToolsService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteNode: tool({
|
deleteNode: sharedTool(
|
||||||
description:
|
sharedToolSpecs.deleteNode,
|
||||||
'Remove a content BLOCK by its id (NOT a page). Reversible: the ' +
|
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||||
'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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
updatePageJson: tool({
|
updatePageJson: tool({
|
||||||
description:
|
description:
|
||||||
@@ -866,35 +788,17 @@ export class AiChatToolsService {
|
|||||||
await client.tableUpdateCell(pageId, tableRef, row, col, text),
|
await client.tableUpdateCell(pageId, tableRef, row, col, text),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
copyPageContent: tool({
|
copyPageContent: sharedTool(
|
||||||
description:
|
sharedToolSpecs.copyPageContent,
|
||||||
"Replace the target page's BODY with the source page's body " +
|
async ({ sourcePageId, targetPageId }) =>
|
||||||
'(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 }) =>
|
|
||||||
await client.copyPageContent(sourcePageId, targetPageId),
|
await client.copyPageContent(sourcePageId, targetPageId),
|
||||||
}),
|
),
|
||||||
|
|
||||||
importPageMarkdown: tool({
|
importPageMarkdown: sharedTool(
|
||||||
description:
|
sharedToolSpecs.importPageMarkdown,
|
||||||
"Replace a page's body from Docmost-flavoured Markdown (as produced " +
|
async ({ pageId, markdown }) =>
|
||||||
'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 }) =>
|
|
||||||
await client.importPageMarkdown(pageId, markdown),
|
await client.importPageMarkdown(pageId, markdown),
|
||||||
}),
|
),
|
||||||
|
|
||||||
sharePage: tool({
|
sharePage: tool({
|
||||||
description:
|
description:
|
||||||
@@ -912,27 +816,15 @@ export class AiChatToolsService {
|
|||||||
await client.sharePage(pageId, searchIndexing),
|
await client.sharePage(pageId, searchIndexing),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
unsharePage: tool({
|
unsharePage: sharedTool(
|
||||||
description:
|
sharedToolSpecs.unsharePage,
|
||||||
'Remove the public share of a page (reverses sharePage).',
|
async ({ pageId }) => await client.unsharePage(pageId),
|
||||||
inputSchema: z.object({
|
),
|
||||||
pageId: z.string().describe('The id of the page to unshare.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId }) => await client.unsharePage(pageId),
|
|
||||||
}),
|
|
||||||
|
|
||||||
restorePageVersion: tool({
|
restorePageVersion: sharedTool(
|
||||||
description:
|
sharedToolSpecs.restorePageVersion,
|
||||||
'Restore a past version by writing its content back as the current ' +
|
async ({ historyId }) => await client.restorePageVersion(historyId),
|
||||||
'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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
transformPage: tool({
|
transformPage: tool({
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -167,8 +167,29 @@ export interface DocmostClientCtor {
|
|||||||
new (config: DocmostClientConfig): DocmostClientLike;
|
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 {
|
interface DocmostMcpModule {
|
||||||
DocmostClient: DocmostClientCtor;
|
DocmostClient: DocmostClientCtor;
|
||||||
|
SHARED_TOOL_SPECS: Record<string, SharedToolSpec>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TS with module:commonjs downlevels a literal `import()` to `require()`, which
|
// 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<{
|
export async function loadDocmostMcp(): Promise<{
|
||||||
DocmostClient: DocmostClientCtor;
|
DocmostClient: DocmostClientCtor;
|
||||||
|
sharedToolSpecs: Record<string, SharedToolSpec>;
|
||||||
}> {
|
}> {
|
||||||
if (!modulePromise) {
|
if (!modulePromise) {
|
||||||
modulePromise = (async () => {
|
modulePromise = (async () => {
|
||||||
@@ -206,5 +228,15 @@ export async function loadDocmostMcp(): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const mod = await modulePromise;
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,31 @@ import { CamelCasePlugin, Kysely } from 'kysely';
|
|||||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||||
import * as postgres from 'postgres';
|
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
|
* Isolated test database connection string. The dev DB is `docmost`; tests run
|
||||||
* against a dedicated `docmost_test` that global-setup drops + recreates +
|
* against a dedicated `docmost_test` that global-setup drops + recreates +
|
||||||
@@ -58,21 +83,27 @@ export async function destroyTestDb(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Seeding helpers ---------------------------------------------------------
|
// --- Seeding helpers ---------------------------------------------------------
|
||||||
// Insert minimal valid rows (only the columns the tests need + NOT NULL ones).
|
// Each helper inserts a minimal valid row (only the columns the tests need plus
|
||||||
// Plain randomUUID() is fine for FK integrity in tests (the app uses uuid v7).
|
// 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(
|
export async function createWorkspace(
|
||||||
db: Kysely<any>,
|
db: Kysely<any>,
|
||||||
overrides: { settings?: unknown; name?: string } = {},
|
overrides: { settings?: unknown; name?: string } = {},
|
||||||
): Promise<{ id: string; settings: any }> {
|
): Promise<{ id: string; settings: any }> {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
const suffix = shortId(id);
|
||||||
const row = await db
|
const row = await db
|
||||||
.insertInto('workspaces')
|
.insertInto('workspaces')
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
name: overrides.name ?? `ws-${id.slice(0, 8)}`,
|
name: overrides.name ?? `ws-${suffix}`,
|
||||||
// hostname is uniquely constrained; keep it unique per workspace.
|
// 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),
|
settings: overrides.settings === undefined ? null : (overrides.settings as any),
|
||||||
})
|
})
|
||||||
.returning(['id', 'settings'])
|
.returning(['id', 'settings'])
|
||||||
@@ -86,12 +117,13 @@ export async function createUser(
|
|||||||
overrides: { email?: string; name?: string } = {},
|
overrides: { email?: string; name?: string } = {},
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
const suffix = shortId(id);
|
||||||
const row = await db
|
const row = await db
|
||||||
.insertInto('users')
|
.insertInto('users')
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
email: overrides.email ?? `user-${id.slice(0, 8)}@example.test`,
|
email: overrides.email ?? `user-${suffix}@example.test`,
|
||||||
name: overrides.name ?? `user-${id.slice(0, 8)}`,
|
name: overrides.name ?? `user-${suffix}`,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
})
|
})
|
||||||
.returning(['id'])
|
.returning(['id'])
|
||||||
@@ -105,13 +137,14 @@ export async function createSpace(
|
|||||||
overrides: { slug?: string; name?: string } = {},
|
overrides: { slug?: string; name?: string } = {},
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
const suffix = shortId(id);
|
||||||
const row = await db
|
const row = await db
|
||||||
.insertInto('spaces')
|
.insertInto('spaces')
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
name: overrides.name ?? `space-${id.slice(0, 8)}`,
|
name: overrides.name ?? `space-${suffix}`,
|
||||||
// slug is unique per workspace + NOT NULL.
|
// slug is unique per workspace + NOT NULL.
|
||||||
slug: overrides.slug ?? `space-${id.slice(0, 8)}`,
|
slug: overrides.slug ?? `space-${suffix}`,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
})
|
})
|
||||||
.returning(['id'])
|
.returning(['id'])
|
||||||
@@ -124,13 +157,14 @@ export async function createPage(
|
|||||||
args: { workspaceId: string; spaceId: string; title?: string },
|
args: { workspaceId: string; spaceId: string; title?: string },
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
const suffix = shortId(id);
|
||||||
const row = await db
|
const row = await db
|
||||||
.insertInto('pages')
|
.insertInto('pages')
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
// slug_id is NOT NULL + globally unique.
|
// slug_id is NOT NULL + globally unique.
|
||||||
slugId: `slug-${id.slice(0, 8)}`,
|
slugId: `slug-${suffix}`,
|
||||||
title: args.title ?? `page-${id.slice(0, 8)}`,
|
title: args.title ?? `page-${suffix}`,
|
||||||
spaceId: args.spaceId,
|
spaceId: args.spaceId,
|
||||||
workspaceId: args.workspaceId,
|
workspaceId: args.workspaceId,
|
||||||
})
|
})
|
||||||
@@ -186,7 +220,7 @@ export async function createChat(
|
|||||||
workspaceId: args.workspaceId,
|
workspaceId: args.workspaceId,
|
||||||
creatorId: args.creatorId,
|
creatorId: args.creatorId,
|
||||||
roleId: args.roleId ?? null,
|
roleId: args.roleId ?? null,
|
||||||
title: args.title ?? `chat-${id.slice(0, 8)}`,
|
title: args.title ?? `chat-${shortId(id)}`,
|
||||||
})
|
})
|
||||||
.returning(['id'])
|
.returning(['id'])
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import { fileURLToPath } from "url";
|
|||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { DocmostClient } from "./client.js";
|
import { DocmostClient } from "./client.js";
|
||||||
import { parseNodeArg } from "./lib/parse-node-arg.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
|
// 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
|
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
|
||||||
// directly — for the credentials variant OR the per-user getToken variant.
|
// directly — for the credentials variant OR the per-user getToken variant.
|
||||||
export { DocmostClient } from "./client.js";
|
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
|
// Read version from package.json
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -46,17 +51,27 @@ export function createDocmostMcpServer(config) {
|
|||||||
name: "docmost-mcp",
|
name: "docmost-mcp",
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
}, { instructions: SERVER_INSTRUCTIONS });
|
}, { 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
|
// Tool: get_workspace
|
||||||
server.registerTool("get_workspace", {
|
registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
|
||||||
description: "Get the current Docmost workspace",
|
|
||||||
}, async () => {
|
|
||||||
const workspace = await docmostClient.getWorkspace();
|
const workspace = await docmostClient.getWorkspace();
|
||||||
return jsonContent(workspace);
|
return jsonContent(workspace);
|
||||||
});
|
});
|
||||||
// Tool: list_spaces
|
// Tool: list_spaces
|
||||||
server.registerTool("list_spaces", {
|
registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
|
||||||
description: "List all available spaces in Docmost",
|
|
||||||
}, async () => {
|
|
||||||
const spaces = await docmostClient.getSpaces();
|
const spaces = await docmostClient.getSpaces();
|
||||||
return jsonContent(spaces);
|
return jsonContent(spaces);
|
||||||
});
|
});
|
||||||
@@ -97,43 +112,17 @@ export function createDocmostMcpServer(config) {
|
|||||||
return jsonContent(page);
|
return jsonContent(page);
|
||||||
});
|
});
|
||||||
// Tool: get_page_json
|
// Tool: get_page_json
|
||||||
server.registerTool("get_page_json", {
|
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
||||||
description: "Get page details with the raw ProseMirror JSON content (lossless: " +
|
|
||||||
"includes block ids, callouts, tables, link/image attributes) plus the " +
|
|
||||||
"slugId used in URLs. Use together with update_page_json for precise " +
|
|
||||||
"structural edits, or edit_page_text for simple text fixes.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const page = await docmostClient.getPageJson(pageId);
|
const page = await docmostClient.getPageJson(pageId);
|
||||||
return jsonContent(page);
|
return jsonContent(page);
|
||||||
});
|
});
|
||||||
// Tool: get_outline
|
// Tool: get_outline
|
||||||
server.registerTool("get_outline", {
|
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
|
||||||
description: "Return a COMPACT outline of a page's top-level blocks ({index, type, " +
|
|
||||||
"id, level, firstText}; tables add rows/cols/header; lists add item " +
|
|
||||||
"count) WITHOUT the full document body. Use it to locate sections/tables " +
|
|
||||||
"and grab block ids cheaply before get_node / patch_node / insert_node.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.getOutline(pageId);
|
const result = await docmostClient.getOutline(pageId);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
// Tool: get_node
|
// Tool: get_node
|
||||||
server.registerTool("get_node", {
|
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
|
||||||
description: "Fetch a single node's full ProseMirror subtree (lossless) without " +
|
|
||||||
"pulling the whole document. `nodeId` is a block id from get_outline/" +
|
|
||||||
"get_page_json (works for headings/paragraphs/callouts/images), OR " +
|
|
||||||
"`#<index>` to fetch a top-level block by its outline index — use the " +
|
|
||||||
"`#<index>` form for tables/rows/cells, which carry no id.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
nodeId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, nodeId }) => {
|
|
||||||
const result = await docmostClient.getNode(pageId, nodeId);
|
const result = await docmostClient.getNode(pageId, nodeId);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
@@ -270,35 +259,12 @@ export function createDocmostMcpServer(config) {
|
|||||||
return { content: [{ type: "text", text: md }] };
|
return { content: [{ type: "text", text: md }] };
|
||||||
});
|
});
|
||||||
// Tool: import_page_markdown
|
// Tool: import_page_markdown
|
||||||
server.registerTool("import_page_markdown", {
|
registerShared(SHARED_TOOL_SPECS.importPageMarkdown, async ({ pageId, 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 }) => {
|
|
||||||
const res = await docmostClient.importPageMarkdown(pageId, markdown);
|
const res = await docmostClient.importPageMarkdown(pageId, markdown);
|
||||||
return jsonContent(res);
|
return jsonContent(res);
|
||||||
});
|
});
|
||||||
// Tool: copy_page_content
|
// Tool: copy_page_content
|
||||||
server.registerTool("copy_page_content", {
|
registerShared(SHARED_TOOL_SPECS.copyPageContent, async ({ sourcePageId, targetPageId }) => {
|
||||||
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 }) => {
|
|
||||||
const result = await docmostClient.copyPageContent(sourcePageId, targetPageId);
|
const result = await docmostClient.copyPageContent(sourcePageId, targetPageId);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
@@ -315,40 +281,7 @@ export function createDocmostMcpServer(config) {
|
|||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
// Tool: edit_page_text
|
// Tool: edit_page_text
|
||||||
server.registerTool("edit_page_text", {
|
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
||||||
description: "Surgical find/replace inside a page's text. Preserves ALL structure: " +
|
|
||||||
"block ids, marks, links, callouts, tables. A `find` MAY cross " +
|
|
||||||
"bold/italic/link boundaries; the replacement inherits marks from the " +
|
|
||||||
"unchanged common prefix/suffix (editing plain text next to a bold word " +
|
|
||||||
"keeps it bold; editing inside a bold word keeps the new text bold). " +
|
|
||||||
"Each `find` must match exactly once (or set replaceAll). The batch " +
|
|
||||||
"applies what it can and returns applied[] + failed[]; a fully-unmatched " +
|
|
||||||
"batch writes nothing and errors. `find` should be the literal rendered " +
|
|
||||||
"text (no markdown). Markdown wrappers (**bold**, *italic*, `code`) and " +
|
|
||||||
"trailing emoji are tolerated via a strip-and-retry fallback, but plain " +
|
|
||||||
"text is preferred. Examples: edits:[{find:\"teh\"," +
|
|
||||||
"replace:\"the\"}]; edits:[{find:\"Hello world\",replace:\"Hello there\"}] " +
|
|
||||||
"(crosses a bold boundary). This is the preferred tool for fixing " +
|
|
||||||
"wording, typos, numbers, names. It edits plain text only and CANNOT " +
|
|
||||||
"change formatting marks: formatting changes (markdown markers in " +
|
|
||||||
"find/replace) are refused — use patch_node/update_page_json to change " +
|
|
||||||
"marks. The result includes a `verify` change-report of what actually " +
|
|
||||||
"changed (text/block/mark deltas).",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page to edit"),
|
|
||||||
edits: z
|
|
||||||
.array(z.object({
|
|
||||||
find: z.string().describe("Exact text to find"),
|
|
||||||
replace: z.string().describe("Replacement text (may be empty)"),
|
|
||||||
replaceAll: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe("Replace every occurrence (default: must match once)"),
|
|
||||||
}))
|
|
||||||
.min(1)
|
|
||||||
.describe("List of find/replace operations, applied in order"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, edits }) => {
|
|
||||||
const result = await docmostClient.editPageText(pageId, edits);
|
const result = await docmostClient.editPageText(pageId, edits);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
@@ -417,14 +350,7 @@ export function createDocmostMcpServer(config) {
|
|||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
// Tool: delete_node
|
// Tool: delete_node
|
||||||
server.registerTool("delete_node", {
|
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
||||||
description: "Remove a single block by its attrs.id (from get_page_json) WITHOUT " +
|
|
||||||
"resending the whole document.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
nodeId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, nodeId }) => {
|
|
||||||
const result = await docmostClient.deleteNode(pageId, nodeId);
|
const result = await docmostClient.deleteNode(pageId, nodeId);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
@@ -510,19 +436,12 @@ export function createDocmostMcpServer(config) {
|
|||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
// Tool: unshare_page
|
// Tool: unshare_page
|
||||||
server.registerTool("unshare_page", {
|
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
|
||||||
description: "Remove the public share of a page (revokes the public URL).",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to unshare"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.unsharePage(pageId);
|
const result = await docmostClient.unsharePage(pageId);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
// Tool: list_shares
|
// Tool: list_shares
|
||||||
server.registerTool("list_shares", {
|
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
||||||
description: "List all public shares in the workspace with page titles and public URLs.",
|
|
||||||
}, async () => {
|
|
||||||
const result = await docmostClient.listShares();
|
const result = await docmostClient.listShares();
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
@@ -747,55 +666,17 @@ export function createDocmostMcpServer(config) {
|
|||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
// Tool: diff_page_versions
|
// Tool: diff_page_versions
|
||||||
server.registerTool("diff_page_versions", {
|
registerShared(SHARED_TOOL_SPECS.diffPageVersions, async ({ pageId, from, to }) => {
|
||||||
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 }) => {
|
|
||||||
const result = await docmostClient.diffPageVersions(pageId, from, to);
|
const result = await docmostClient.diffPageVersions(pageId, from, to);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
// Tool: list_page_history
|
// Tool: list_page_history
|
||||||
server.registerTool("list_page_history", {
|
registerShared(SHARED_TOOL_SPECS.listPageHistory, async ({ pageId, cursor }) => {
|
||||||
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 }) => {
|
|
||||||
const result = await docmostClient.listPageHistory(pageId, cursor);
|
const result = await docmostClient.listPageHistory(pageId, cursor);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
// Tool: restore_page_version
|
// Tool: restore_page_version
|
||||||
server.registerTool("restore_page_version", {
|
registerShared(SHARED_TOOL_SPECS.restorePageVersion, async ({ historyId }) => {
|
||||||
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 }) => {
|
|
||||||
const result = await docmostClient.restorePageVersion(historyId);
|
const result = await docmostClient.restorePageVersion(historyId);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -732,6 +732,59 @@ const Embed = Node.create({
|
|||||||
return ["div", { "data-type": "embed", ...HTMLAttributes }, 0];
|
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. */
|
/** Shared attribute set for drawio/excalidraw diagram nodes. */
|
||||||
const diagramAttributes = () => ({
|
const diagramAttributes = () => ({
|
||||||
src: {
|
src: {
|
||||||
@@ -1062,6 +1115,7 @@ export const docmostExtensions = [
|
|||||||
Video,
|
Video,
|
||||||
Youtube,
|
Youtube,
|
||||||
Embed,
|
Embed,
|
||||||
|
HtmlEmbed,
|
||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Columns,
|
Columns,
|
||||||
|
|||||||
212
packages/mcp/build/tool-specs.js
Normal file
212
packages/mcp/build/tool-specs.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// Zod-agnostic shared tool-spec registry consumed by BOTH the zod-v3 MCP server
|
||||||
|
// (packages/mcp/src/index.ts) and the zod-v4 in-app AI-SDK service
|
||||||
|
// (apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts). Intentionally
|
||||||
|
// imports NO zod: each consumer passes its OWN zod namespace into buildShape,
|
||||||
|
// because the two packages are on different zod majors (v3 here, v4 in the
|
||||||
|
// server) and a zod schema object built with one major cannot be reused by the
|
||||||
|
// other. The builders below only touch z.string()/.min()/.optional()/.describe(),
|
||||||
|
// z.array() and z.object() — API identical across v3 and v4 — so a single
|
||||||
|
// builder works with either namespace.
|
||||||
|
//
|
||||||
|
// Only tools whose snake_case/camelCase name, input schema AND model-facing
|
||||||
|
// description are genuinely identical across both layers live here. Tools that
|
||||||
|
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
|
||||||
|
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
|
||||||
|
// per-layer and are NOT represented here.
|
||||||
|
export const SHARED_TOOL_SPECS = {
|
||||||
|
// --- no-argument read tools ---
|
||||||
|
getWorkspace: {
|
||||||
|
mcpName: 'get_workspace',
|
||||||
|
inAppKey: 'getWorkspace',
|
||||||
|
description: 'Fetch metadata about the current workspace (name, settings).',
|
||||||
|
},
|
||||||
|
listSpaces: {
|
||||||
|
mcpName: 'list_spaces',
|
||||||
|
inAppKey: 'listSpaces',
|
||||||
|
description: 'List the spaces the current user can access. Returns the array of ' +
|
||||||
|
'spaces (id, name, slug, ...).',
|
||||||
|
},
|
||||||
|
listShares: {
|
||||||
|
mcpName: 'list_shares',
|
||||||
|
inAppKey: 'listShares',
|
||||||
|
description: 'List all public shares in the workspace with page titles and public URLs.',
|
||||||
|
},
|
||||||
|
// --- single-pageId read tools ---
|
||||||
|
getPageJson: {
|
||||||
|
mcpName: 'get_page_json',
|
||||||
|
inAppKey: 'getPageJson',
|
||||||
|
description: 'Get page details with the raw ProseMirror JSON content (lossless: ' +
|
||||||
|
'includes block ids, callouts, tables, link/image attributes) plus the ' +
|
||||||
|
'slugId used in URLs. Use the block ids it returns to make precise ' +
|
||||||
|
'structural edits or surgical text edits without resending the page.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
getOutline: {
|
||||||
|
mcpName: 'get_outline',
|
||||||
|
inAppKey: 'getOutline',
|
||||||
|
description: "Return a COMPACT outline of a page's top-level blocks ({index, type, " +
|
||||||
|
'id, level, firstText}; tables add rows/cols/header; lists add item ' +
|
||||||
|
'count) WITHOUT the full document body. Use it to locate sections/tables ' +
|
||||||
|
'and grab block ids cheaply before fetching, patching or inserting ' +
|
||||||
|
'individual blocks.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// --- two-id read tool ---
|
||||||
|
getNode: {
|
||||||
|
mcpName: 'get_node',
|
||||||
|
inAppKey: 'getNode',
|
||||||
|
description: "Fetch a single node's full ProseMirror subtree (lossless) without " +
|
||||||
|
'pulling the whole document. `nodeId` is a block id from the page ' +
|
||||||
|
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
|
||||||
|
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
||||||
|
'`#<index>` form for tables/rows/cells, which carry no id.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
nodeId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// --- node delete ---
|
||||||
|
deleteNode: {
|
||||||
|
mcpName: 'delete_node',
|
||||||
|
inAppKey: 'deleteNode',
|
||||||
|
description: 'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' +
|
||||||
|
'resending the whole document.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
nodeId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// --- share management ---
|
||||||
|
unsharePage: {
|
||||||
|
mcpName: 'unshare_page',
|
||||||
|
inAppKey: 'unsharePage',
|
||||||
|
description: 'Remove the public share of a page (revokes the public URL).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('ID of the page to unshare'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// --- version history ---
|
||||||
|
diffPageVersions: {
|
||||||
|
mcpName: 'diff_page_versions',
|
||||||
|
inAppKey: 'diffPageVersions',
|
||||||
|
description: 'Diff two versions of a page and return a Docmost-equivalent change set ' +
|
||||||
|
'(inserted/deleted text, integrity counts for images/links/tables/' +
|
||||||
|
'callouts/footnote markers, and a human-readable markdown summary). ' +
|
||||||
|
"`from`/`to` each accept a historyId, or null/'current' for the page's " +
|
||||||
|
'current content (defaults: from=current, to=current — pass a historyId ' +
|
||||||
|
'from the page-history list to compare against the live page).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
from: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("historyId, or 'current'/omit for current content"),
|
||||||
|
to: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("historyId, or 'current'/omit for current content"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
listPageHistory: {
|
||||||
|
mcpName: 'list_page_history',
|
||||||
|
inAppKey: 'listPageHistory',
|
||||||
|
description: "List a page's saved versions (Docmost auto-snapshots on every save), " +
|
||||||
|
'newest first, cursor-paginated. Returns { items, nextCursor }; each ' +
|
||||||
|
"item's id is the historyId to pass to the page diff or restore tools.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
cursor: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Pagination cursor from a previous nextCursor'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
restorePageVersion: {
|
||||||
|
mcpName: 'restore_page_version',
|
||||||
|
inAppKey: 'restorePageVersion',
|
||||||
|
description: 'Restore a page to a saved version: writes that version\'s content back ' +
|
||||||
|
'as the page\'s current content (Docmost has no restore endpoint, so ' +
|
||||||
|
'this creates a NEW history snapshot — the restore is itself revertible). ' +
|
||||||
|
'Get the historyId from the page-history list.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
historyId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// --- markdown round-trip ---
|
||||||
|
importPageMarkdown: {
|
||||||
|
mcpName: 'import_page_markdown',
|
||||||
|
inAppKey: 'importPageMarkdown',
|
||||||
|
description: "Replace a page's content from a self-contained Docmost-flavoured " +
|
||||||
|
'Markdown file produced by the page-Markdown export tool. Restores comment ' +
|
||||||
|
'highlight anchors and diagrams from their inline HTML. NOTE: comment ' +
|
||||||
|
'thread records are NOT created/updated/deleted on the server by this ' +
|
||||||
|
'tool — only the page body + inline comment marks are written; manage ' +
|
||||||
|
'comment threads via the comment tools/UI.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
markdown: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// --- server-side content copy ---
|
||||||
|
copyPageContent: {
|
||||||
|
mcpName: 'copy_page_content',
|
||||||
|
inAppKey: 'copyPageContent',
|
||||||
|
description: "Replace targetPageId's content with a copy of sourcePageId's content, " +
|
||||||
|
'entirely server-side — the document is NOT sent through the model. The ' +
|
||||||
|
'target keeps its own title and slug; only its body is replaced. Ideal ' +
|
||||||
|
"for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
sourcePageId: z.string().min(1).describe('Page to copy content FROM'),
|
||||||
|
targetPageId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('Page whose content is REPLACED (title/slug kept)'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// --- surgical text edit (folds in the documented drift-bug fix) ---
|
||||||
|
//
|
||||||
|
// CANONICAL description is the CORRECTED in-app wording: a formatting-only
|
||||||
|
// change is REFUSED into failed[] (not silently stripped-and-retried). The
|
||||||
|
// stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry
|
||||||
|
// fallback" is intentionally absent here.
|
||||||
|
editPageText: {
|
||||||
|
mcpName: 'edit_page_text',
|
||||||
|
inAppKey: 'editPageText',
|
||||||
|
description: "Surgical find/replace inside a page's text, preserving all block " +
|
||||||
|
'ids and marks. A find MAY cross bold/italic/link boundaries; the ' +
|
||||||
|
'replacement inherits marks from the unchanged common prefix/suffix ' +
|
||||||
|
'(so editing plain text next to a bold word keeps it bold, and ' +
|
||||||
|
'editing inside a bold word keeps the new text bold). Each find must ' +
|
||||||
|
'match exactly once unless replaceAll is set. The batch applies what ' +
|
||||||
|
'it can and returns applied[] + failed[] plus a verify change-report ' +
|
||||||
|
'(the text/marks/structure that ACTUALLY changed — read it to confirm ' +
|
||||||
|
'your edit landed; do not assume success); a fully-unmatched batch ' +
|
||||||
|
'writes nothing and errors. find and replace are LITERAL text, not ' +
|
||||||
|
'markdown. This tool edits plain text ONLY and CANNOT add or remove ' +
|
||||||
|
'formatting marks: a formatting change — find/replace that differ only ' +
|
||||||
|
'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' +
|
||||||
|
'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' +
|
||||||
|
'failed[]. To change bold/italic/strike/code/link, read the block as ' +
|
||||||
|
'page JSON and use a structural node patch/update to set its marks. ' +
|
||||||
|
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
|
||||||
|
'world",replace:"Hello there"}] (crosses a bold boundary).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().describe('ID of the page to edit'),
|
||||||
|
edits: z
|
||||||
|
.array(z.object({
|
||||||
|
find: z.string().describe('Exact text to find'),
|
||||||
|
replace: z.string().describe('Replacement text (may be empty)'),
|
||||||
|
replaceAll: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Replace every occurrence (default: must match once)'),
|
||||||
|
}))
|
||||||
|
.min(1)
|
||||||
|
.describe('List of find/replace operations, applied in order'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
|
|||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
||||||
import { parseNodeArg } from "./lib/parse-node-arg.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
|
// 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
|
// 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 { DocmostClient } from "./client.js";
|
||||||
export type { DocmostMcpConfig } 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
|
// Read version from package.json
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -62,29 +69,40 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
|||||||
{ instructions: SERVER_INSTRUCTIONS },
|
{ 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
|
// Tool: get_workspace
|
||||||
server.registerTool(
|
registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
|
||||||
"get_workspace",
|
|
||||||
{
|
|
||||||
description: "Get the current Docmost workspace",
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const workspace = await docmostClient.getWorkspace();
|
const workspace = await docmostClient.getWorkspace();
|
||||||
return jsonContent(workspace);
|
return jsonContent(workspace);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: list_spaces
|
// Tool: list_spaces
|
||||||
server.registerTool(
|
registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
|
||||||
"list_spaces",
|
|
||||||
{
|
|
||||||
description: "List all available spaces in Docmost",
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const spaces = await docmostClient.getSpaces();
|
const spaces = await docmostClient.getSpaces();
|
||||||
return jsonContent(spaces);
|
return jsonContent(spaces);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: list_pages
|
// Tool: list_pages
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
@@ -137,63 +155,22 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: get_page_json
|
// Tool: get_page_json
|
||||||
server.registerTool(
|
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
||||||
"get_page_json",
|
const page = await docmostClient.getPageJson(pageId);
|
||||||
{
|
return jsonContent(page);
|
||||||
description:
|
});
|
||||||
"Get page details with the raw ProseMirror JSON content (lossless: " +
|
|
||||||
"includes block ids, callouts, tables, link/image attributes) plus the " +
|
|
||||||
"slugId used in URLs. Use together with update_page_json for precise " +
|
|
||||||
"structural edits, or edit_page_text for simple text fixes.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId }) => {
|
|
||||||
const page = await docmostClient.getPageJson(pageId);
|
|
||||||
return jsonContent(page);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: get_outline
|
// Tool: get_outline
|
||||||
server.registerTool(
|
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
|
||||||
"get_outline",
|
const result = await docmostClient.getOutline(pageId);
|
||||||
{
|
return jsonContent(result);
|
||||||
description:
|
});
|
||||||
"Return a COMPACT outline of a page's top-level blocks ({index, type, " +
|
|
||||||
"id, level, firstText}; tables add rows/cols/header; lists add item " +
|
|
||||||
"count) WITHOUT the full document body. Use it to locate sections/tables " +
|
|
||||||
"and grab block ids cheaply before get_node / patch_node / insert_node.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.getOutline(pageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: get_node
|
// Tool: get_node
|
||||||
server.registerTool(
|
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
|
||||||
"get_node",
|
const result = await docmostClient.getNode(pageId, nodeId);
|
||||||
{
|
return jsonContent(result);
|
||||||
description:
|
});
|
||||||
"Fetch a single node's full ProseMirror subtree (lossless) without " +
|
|
||||||
"pulling the whole document. `nodeId` is a block id from get_outline/" +
|
|
||||||
"get_page_json (works for headings/paragraphs/callouts/images), OR " +
|
|
||||||
"`#<index>` to fetch a top-level block by its outline index — use the " +
|
|
||||||
"`#<index>` form for tables/rows/cells, which carry no id.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
nodeId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, nodeId }) => {
|
|
||||||
const result = await docmostClient.getNode(pageId, nodeId);
|
|
||||||
return jsonContent(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: table_get
|
// Tool: table_get
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
@@ -387,21 +364,8 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: import_page_markdown
|
// Tool: import_page_markdown
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"import_page_markdown",
|
SHARED_TOOL_SPECS.importPageMarkdown,
|
||||||
{
|
|
||||||
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 }) => {
|
async ({ pageId, markdown }) => {
|
||||||
const res = await docmostClient.importPageMarkdown(pageId, markdown);
|
const res = await docmostClient.importPageMarkdown(pageId, markdown);
|
||||||
return jsonContent(res);
|
return jsonContent(res);
|
||||||
@@ -409,22 +373,8 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: copy_page_content
|
// Tool: copy_page_content
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"copy_page_content",
|
SHARED_TOOL_SPECS.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'.",
|
|
||||||
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 }) => {
|
async ({ sourcePageId, targetPageId }) => {
|
||||||
const result = await docmostClient.copyPageContent(
|
const result = await docmostClient.copyPageContent(
|
||||||
sourcePageId,
|
sourcePageId,
|
||||||
@@ -453,50 +403,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: edit_page_text
|
// Tool: edit_page_text
|
||||||
server.registerTool(
|
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
||||||
"edit_page_text",
|
const result = await docmostClient.editPageText(pageId, edits);
|
||||||
{
|
return jsonContent(result);
|
||||||
description:
|
});
|
||||||
"Surgical find/replace inside a page's text. Preserves ALL structure: " +
|
|
||||||
"block ids, marks, links, callouts, tables. A `find` MAY cross " +
|
|
||||||
"bold/italic/link boundaries; the replacement inherits marks from the " +
|
|
||||||
"unchanged common prefix/suffix (editing plain text next to a bold word " +
|
|
||||||
"keeps it bold; editing inside a bold word keeps the new text bold). " +
|
|
||||||
"Each `find` must match exactly once (or set replaceAll). The batch " +
|
|
||||||
"applies what it can and returns applied[] + failed[]; a fully-unmatched " +
|
|
||||||
"batch writes nothing and errors. `find` should be the literal rendered " +
|
|
||||||
"text (no markdown). Markdown wrappers (**bold**, *italic*, `code`) and " +
|
|
||||||
"trailing emoji are tolerated via a strip-and-retry fallback, but plain " +
|
|
||||||
"text is preferred. Examples: edits:[{find:\"teh\"," +
|
|
||||||
"replace:\"the\"}]; edits:[{find:\"Hello world\",replace:\"Hello there\"}] " +
|
|
||||||
"(crosses a bold boundary). This is the preferred tool for fixing " +
|
|
||||||
"wording, typos, numbers, names. It edits plain text only and CANNOT " +
|
|
||||||
"change formatting marks: formatting changes (markdown markers in " +
|
|
||||||
"find/replace) are refused — use patch_node/update_page_json to change " +
|
|
||||||
"marks. The result includes a `verify` change-report of what actually " +
|
|
||||||
"changed (text/block/mark deltas).",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page to edit"),
|
|
||||||
edits: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
find: z.string().describe("Exact text to find"),
|
|
||||||
replace: z.string().describe("Replacement text (may be empty)"),
|
|
||||||
replaceAll: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe("Replace every occurrence (default: must match once)"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.min(1)
|
|
||||||
.describe("List of find/replace operations, applied in order"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, edits }) => {
|
|
||||||
const result = await docmostClient.editPageText(pageId, edits);
|
|
||||||
return jsonContent(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: patch_node
|
// Tool: patch_node
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
@@ -579,22 +489,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: delete_node
|
// Tool: delete_node
|
||||||
server.registerTool(
|
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
||||||
"delete_node",
|
const result = await docmostClient.deleteNode(pageId, nodeId);
|
||||||
{
|
return jsonContent(result);
|
||||||
description:
|
});
|
||||||
"Remove a single block by its attrs.id (from get_page_json) WITHOUT " +
|
|
||||||
"resending the whole document.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
nodeId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, nodeId }) => {
|
|
||||||
const result = await docmostClient.deleteNode(pageId, nodeId);
|
|
||||||
return jsonContent(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: insert_image
|
// Tool: insert_image
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
@@ -705,32 +603,16 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: unshare_page
|
// Tool: unshare_page
|
||||||
server.registerTool(
|
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
|
||||||
"unshare_page",
|
const result = await docmostClient.unsharePage(pageId);
|
||||||
{
|
return jsonContent(result);
|
||||||
description: "Remove the public share of a page (revokes the public URL).",
|
});
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to unshare"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.unsharePage(pageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: list_shares
|
// Tool: list_shares
|
||||||
server.registerTool(
|
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
||||||
"list_shares",
|
const result = await docmostClient.listShares();
|
||||||
{
|
return jsonContent(result);
|
||||||
description:
|
});
|
||||||
"List all public shares in the workspace with page titles and public URLs.",
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const result = await docmostClient.listShares();
|
|
||||||
return jsonContent(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: move_page
|
// Tool: move_page
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
@@ -1046,28 +928,8 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: diff_page_versions
|
// Tool: diff_page_versions
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"diff_page_versions",
|
SHARED_TOOL_SPECS.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 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 }) => {
|
async ({ pageId, from, to }) => {
|
||||||
const result = await docmostClient.diffPageVersions(pageId, from, to);
|
const result = await docmostClient.diffPageVersions(pageId, from, to);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -1075,22 +937,8 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: list_page_history
|
// Tool: list_page_history
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"list_page_history",
|
SHARED_TOOL_SPECS.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 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 }) => {
|
async ({ pageId, cursor }) => {
|
||||||
const result = await docmostClient.listPageHistory(pageId, cursor);
|
const result = await docmostClient.listPageHistory(pageId, cursor);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -1098,18 +946,8 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: restore_page_version
|
// Tool: restore_page_version
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"restore_page_version",
|
SHARED_TOOL_SPECS.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 list_page_history.",
|
|
||||||
inputSchema: {
|
|
||||||
historyId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ historyId }) => {
|
async ({ historyId }) => {
|
||||||
const result = await docmostClient.restorePageVersion(historyId);
|
const result = await docmostClient.restorePageVersion(historyId);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
|
|||||||
269
packages/mcp/src/tool-specs.ts
Normal file
269
packages/mcp/src/tool-specs.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
// Zod-agnostic shared tool-spec registry consumed by BOTH the zod-v3 MCP server
|
||||||
|
// (packages/mcp/src/index.ts) and the zod-v4 in-app AI-SDK service
|
||||||
|
// (apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts). Intentionally
|
||||||
|
// imports NO zod: each consumer passes its OWN zod namespace into buildShape,
|
||||||
|
// because the two packages are on different zod majors (v3 here, v4 in the
|
||||||
|
// server) and a zod schema object built with one major cannot be reused by the
|
||||||
|
// other. The builders below only touch z.string()/.min()/.optional()/.describe(),
|
||||||
|
// z.array() and z.object() — API identical across v3 and v4 — so a single
|
||||||
|
// builder works with either namespace.
|
||||||
|
//
|
||||||
|
// Only tools whose snake_case/camelCase name, input schema AND model-facing
|
||||||
|
// description are genuinely identical across both layers live here. Tools that
|
||||||
|
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
|
||||||
|
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
|
||||||
|
// per-layer and are NOT represented here.
|
||||||
|
|
||||||
|
// Loose on purpose — see the comment above. The two zod majors expose different
|
||||||
|
// static type surfaces, so typing this precisely would couple the registry to
|
||||||
|
// one of them. Each builder uses only the common, stable subset of the API.
|
||||||
|
type ZodLike = any;
|
||||||
|
|
||||||
|
export interface SharedToolSpec {
|
||||||
|
/** snake_case tool name passed to McpServer.registerTool. */
|
||||||
|
mcpName: string;
|
||||||
|
/** camelCase key in the ai-SDK tools object (the in-app layer). */
|
||||||
|
inAppKey: string;
|
||||||
|
/** Single canonical model-facing description used by both layers. */
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Builds the tool's input schema as a plain object of zod fields (a
|
||||||
|
* ZodRawShape). Called with the consumer's own zod namespace. Omitted for
|
||||||
|
* no-argument tools (the MCP side then registers with no inputSchema and the
|
||||||
|
* in-app side uses z.object({})).
|
||||||
|
*/
|
||||||
|
buildShape?: (z: ZodLike) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHARED_TOOL_SPECS = {
|
||||||
|
// --- no-argument read tools ---
|
||||||
|
|
||||||
|
getWorkspace: {
|
||||||
|
mcpName: 'get_workspace',
|
||||||
|
inAppKey: 'getWorkspace',
|
||||||
|
description: 'Fetch metadata about the current workspace (name, settings).',
|
||||||
|
},
|
||||||
|
|
||||||
|
listSpaces: {
|
||||||
|
mcpName: 'list_spaces',
|
||||||
|
inAppKey: 'listSpaces',
|
||||||
|
description:
|
||||||
|
'List the spaces the current user can access. Returns the array of ' +
|
||||||
|
'spaces (id, name, slug, ...).',
|
||||||
|
},
|
||||||
|
|
||||||
|
listShares: {
|
||||||
|
mcpName: 'list_shares',
|
||||||
|
inAppKey: 'listShares',
|
||||||
|
description:
|
||||||
|
'List all public shares in the workspace with page titles and public URLs.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- single-pageId read tools ---
|
||||||
|
|
||||||
|
getPageJson: {
|
||||||
|
mcpName: 'get_page_json',
|
||||||
|
inAppKey: 'getPageJson',
|
||||||
|
description:
|
||||||
|
'Get page details with the raw ProseMirror JSON content (lossless: ' +
|
||||||
|
'includes block ids, callouts, tables, link/image attributes) plus the ' +
|
||||||
|
'slugId used in URLs. Use the block ids it returns to make precise ' +
|
||||||
|
'structural edits or surgical text edits without resending the page.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
getOutline: {
|
||||||
|
mcpName: 'get_outline',
|
||||||
|
inAppKey: 'getOutline',
|
||||||
|
description:
|
||||||
|
"Return a COMPACT outline of a page's top-level blocks ({index, type, " +
|
||||||
|
'id, level, firstText}; tables add rows/cols/header; lists add item ' +
|
||||||
|
'count) WITHOUT the full document body. Use it to locate sections/tables ' +
|
||||||
|
'and grab block ids cheaply before fetching, patching or inserting ' +
|
||||||
|
'individual blocks.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- two-id read tool ---
|
||||||
|
|
||||||
|
getNode: {
|
||||||
|
mcpName: 'get_node',
|
||||||
|
inAppKey: 'getNode',
|
||||||
|
description:
|
||||||
|
"Fetch a single node's full ProseMirror subtree (lossless) without " +
|
||||||
|
'pulling the whole document. `nodeId` is a block id from the page ' +
|
||||||
|
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
|
||||||
|
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
||||||
|
'`#<index>` form for tables/rows/cells, which carry no id.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
nodeId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- node delete ---
|
||||||
|
|
||||||
|
deleteNode: {
|
||||||
|
mcpName: 'delete_node',
|
||||||
|
inAppKey: 'deleteNode',
|
||||||
|
description:
|
||||||
|
'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' +
|
||||||
|
'resending the whole document.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
nodeId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- share management ---
|
||||||
|
|
||||||
|
unsharePage: {
|
||||||
|
mcpName: 'unshare_page',
|
||||||
|
inAppKey: 'unsharePage',
|
||||||
|
description: 'Remove the public share of a page (revokes the public URL).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('ID of the page to unshare'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- version history ---
|
||||||
|
|
||||||
|
diffPageVersions: {
|
||||||
|
mcpName: 'diff_page_versions',
|
||||||
|
inAppKey: 'diffPageVersions',
|
||||||
|
description:
|
||||||
|
'Diff two versions of a page and return a Docmost-equivalent change set ' +
|
||||||
|
'(inserted/deleted text, integrity counts for images/links/tables/' +
|
||||||
|
'callouts/footnote markers, and a human-readable markdown summary). ' +
|
||||||
|
"`from`/`to` each accept a historyId, or null/'current' for the page's " +
|
||||||
|
'current content (defaults: from=current, to=current — pass a historyId ' +
|
||||||
|
'from the page-history list to compare against the live page).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
from: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("historyId, or 'current'/omit for current content"),
|
||||||
|
to: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("historyId, or 'current'/omit for current content"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
listPageHistory: {
|
||||||
|
mcpName: 'list_page_history',
|
||||||
|
inAppKey: 'listPageHistory',
|
||||||
|
description:
|
||||||
|
"List a page's saved versions (Docmost auto-snapshots on every save), " +
|
||||||
|
'newest first, cursor-paginated. Returns { items, nextCursor }; each ' +
|
||||||
|
"item's id is the historyId to pass to the page diff or restore tools.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
cursor: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Pagination cursor from a previous nextCursor'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
restorePageVersion: {
|
||||||
|
mcpName: 'restore_page_version',
|
||||||
|
inAppKey: 'restorePageVersion',
|
||||||
|
description:
|
||||||
|
'Restore a page to a saved version: writes that version\'s content back ' +
|
||||||
|
'as the page\'s current content (Docmost has no restore endpoint, so ' +
|
||||||
|
'this creates a NEW history snapshot — the restore is itself revertible). ' +
|
||||||
|
'Get the historyId from the page-history list.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
historyId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- markdown round-trip ---
|
||||||
|
|
||||||
|
importPageMarkdown: {
|
||||||
|
mcpName: 'import_page_markdown',
|
||||||
|
inAppKey: 'importPageMarkdown',
|
||||||
|
description:
|
||||||
|
"Replace a page's content from a self-contained Docmost-flavoured " +
|
||||||
|
'Markdown file produced by the page-Markdown export tool. Restores comment ' +
|
||||||
|
'highlight anchors and diagrams from their inline HTML. NOTE: comment ' +
|
||||||
|
'thread records are NOT created/updated/deleted on the server by this ' +
|
||||||
|
'tool — only the page body + inline comment marks are written; manage ' +
|
||||||
|
'comment threads via the comment tools/UI.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
markdown: z.string().min(1),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- server-side content copy ---
|
||||||
|
|
||||||
|
copyPageContent: {
|
||||||
|
mcpName: 'copy_page_content',
|
||||||
|
inAppKey: 'copyPageContent',
|
||||||
|
description:
|
||||||
|
"Replace targetPageId's content with a copy of sourcePageId's content, " +
|
||||||
|
'entirely server-side — the document is NOT sent through the model. The ' +
|
||||||
|
'target keeps its own title and slug; only its body is replaced. Ideal ' +
|
||||||
|
"for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
sourcePageId: z.string().min(1).describe('Page to copy content FROM'),
|
||||||
|
targetPageId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('Page whose content is REPLACED (title/slug kept)'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- surgical text edit (folds in the documented drift-bug fix) ---
|
||||||
|
//
|
||||||
|
// CANONICAL description is the CORRECTED in-app wording: a formatting-only
|
||||||
|
// change is REFUSED into failed[] (not silently stripped-and-retried). The
|
||||||
|
// stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry
|
||||||
|
// fallback" is intentionally absent here.
|
||||||
|
editPageText: {
|
||||||
|
mcpName: 'edit_page_text',
|
||||||
|
inAppKey: 'editPageText',
|
||||||
|
description:
|
||||||
|
"Surgical find/replace inside a page's text, preserving all block " +
|
||||||
|
'ids and marks. A find MAY cross bold/italic/link boundaries; the ' +
|
||||||
|
'replacement inherits marks from the unchanged common prefix/suffix ' +
|
||||||
|
'(so editing plain text next to a bold word keeps it bold, and ' +
|
||||||
|
'editing inside a bold word keeps the new text bold). Each find must ' +
|
||||||
|
'match exactly once unless replaceAll is set. The batch applies what ' +
|
||||||
|
'it can and returns applied[] + failed[] plus a verify change-report ' +
|
||||||
|
'(the text/marks/structure that ACTUALLY changed — read it to confirm ' +
|
||||||
|
'your edit landed; do not assume success); a fully-unmatched batch ' +
|
||||||
|
'writes nothing and errors. find and replace are LITERAL text, not ' +
|
||||||
|
'markdown. This tool edits plain text ONLY and CANNOT add or remove ' +
|
||||||
|
'formatting marks: a formatting change — find/replace that differ only ' +
|
||||||
|
'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' +
|
||||||
|
'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' +
|
||||||
|
'failed[]. To change bold/italic/strike/code/link, read the block as ' +
|
||||||
|
'page JSON and use a structural node patch/update to set its marks. ' +
|
||||||
|
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
|
||||||
|
'world",replace:"Hello there"}] (crosses a bold boundary).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().describe('ID of the page to edit'),
|
||||||
|
edits: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
find: z.string().describe('Exact text to find'),
|
||||||
|
replace: z.string().describe('Replacement text (may be empty)'),
|
||||||
|
replaceAll: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Replace every occurrence (default: must match once)'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.describe('List of find/replace operations, applied in order'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} satisfies Record<string, SharedToolSpec>;
|
||||||
90
packages/mcp/test/unit/tool-specs.test.mjs
Normal file
90
packages/mcp/test/unit/tool-specs.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js";
|
||||||
|
|
||||||
|
// The shared registry is consumed by BOTH the zod-v3 MCP server and the zod-v4
|
||||||
|
// in-app AI-SDK service, so every spec must carry the cross-layer wiring
|
||||||
|
// (mcpName + inAppKey) and its builders must produce the right field set when
|
||||||
|
// called with a real zod namespace.
|
||||||
|
|
||||||
|
test("every spec exposes mcpName + inAppKey, and the key matches inAppKey", () => {
|
||||||
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||||
|
assert.equal(typeof spec.mcpName, "string");
|
||||||
|
assert.ok(spec.mcpName.length > 0, `${key}: empty mcpName`);
|
||||||
|
assert.equal(typeof spec.inAppKey, "string");
|
||||||
|
assert.ok(spec.inAppKey.length > 0, `${key}: empty inAppKey`);
|
||||||
|
assert.equal(typeof spec.description, "string");
|
||||||
|
assert.ok(spec.description.length > 0, `${key}: empty description`);
|
||||||
|
// The registry is keyed by inAppKey — keep the two in sync.
|
||||||
|
assert.equal(spec.inAppKey, key, `${key}: registry key must equal inAppKey`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mcpName uses snake_case and inAppKey uses camelCase", () => {
|
||||||
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||||
|
assert.match(spec.mcpName, /^[a-z0-9]+(_[a-z0-9]+)*$/, `${key}: mcpName not snake_case`);
|
||||||
|
assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mcpName and inAppKey are each unique across the registry", () => {
|
||||||
|
const mcpNames = new Set();
|
||||||
|
const inAppKeys = new Set();
|
||||||
|
for (const spec of Object.values(SHARED_TOOL_SPECS)) {
|
||||||
|
assert.ok(!mcpNames.has(spec.mcpName), `duplicate mcpName: ${spec.mcpName}`);
|
||||||
|
assert.ok(!inAppKeys.has(spec.inAppKey), `duplicate inAppKey: ${spec.inAppKey}`);
|
||||||
|
mcpNames.add(spec.mcpName);
|
||||||
|
inAppKeys.add(spec.inAppKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildShape (when present) returns a usable ZodRawShape with a real zod", () => {
|
||||||
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||||
|
if (!spec.buildShape) continue;
|
||||||
|
const shape = spec.buildShape(z);
|
||||||
|
assert.equal(typeof shape, "object");
|
||||||
|
// Each field must be a real zod type so z.object(shape) compiles a schema.
|
||||||
|
for (const [field, zt] of Object.entries(shape)) {
|
||||||
|
assert.ok(
|
||||||
|
zt && typeof zt.parse === "function",
|
||||||
|
`${key}.${field}: not a zod type`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The compiled object schema must parse a minimal valid input.
|
||||||
|
assert.doesNotThrow(() => z.object(shape));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => {
|
||||||
|
const spec = SHARED_TOOL_SPECS.editPageText;
|
||||||
|
assert.equal(spec.mcpName, "edit_page_text");
|
||||||
|
const shape = spec.buildShape(z);
|
||||||
|
assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]);
|
||||||
|
// A valid edits batch parses.
|
||||||
|
const schema = z.object(shape);
|
||||||
|
const parsed = schema.parse({
|
||||||
|
pageId: "p1",
|
||||||
|
edits: [{ find: "teh", replace: "the" }],
|
||||||
|
});
|
||||||
|
assert.equal(parsed.pageId, "p1");
|
||||||
|
assert.equal(parsed.edits.length, 1);
|
||||||
|
// The canonical description must NOT carry the stale MCP strip-and-retry claim.
|
||||||
|
assert.ok(
|
||||||
|
!/strip-and-retry/i.test(spec.description),
|
||||||
|
"editPageText description still claims strip-and-retry",
|
||||||
|
);
|
||||||
|
assert.match(spec.description, /REFUSED into\s+failed\[\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getNode builder produces exactly { pageId, nodeId }", () => {
|
||||||
|
const shape = SHARED_TOOL_SPECS.getNode.buildShape(z);
|
||||||
|
assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
|
||||||
|
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
|
||||||
|
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user