diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx
index 75418986..0799784f 100644
--- a/apps/client/src/features/ai-chat/components/chat-thread.tsx
+++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx
@@ -175,6 +175,11 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
+ // Classify the turn error into a heading + detail so the banner names the cause
+ // (connection reset, timeout, rate limit, context overflow, quota, ...) instead
+ // of a generic "Something went wrong".
+ const errorView = error ? describeChatError(error.message ?? "", t) : null;
+
// Clicking a role card both binds the role to THIS new chat and immediately
// starts the conversation. roleIdRef is set synchronously here because the
// parent's selectedRoleId state update would only reach roleIdRef on the next
@@ -198,15 +203,15 @@ export default function ChatThread({
assistantName={assistantName}
/>
- {error && (
+ {errorView && (
}
mb="xs"
- title={t("Something went wrong")}
+ title={errorView.title}
>
- {describeChatError(error.message ?? "", t)}
+ {errorView.detail}
)}
diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx
index 2feaa8de..67e69f3e 100644
--- a/apps/client/src/features/ai-chat/components/message-item.tsx
+++ b/apps/client/src/features/ai-chat/components/message-item.tsx
@@ -114,14 +114,18 @@ export default function MessageItem({
{(() => {
const errorText = (message.metadata as { error?: string } | undefined)?.error;
if (!errorText) return null;
+ // Same classified-error banner as the live chat: a heading naming the
+ // cause plus a one-line detail.
+ const errorView = describeChatError(errorText, t);
return (
}
mt={4}
+ title={errorView.title}
>
- {describeChatError(errorText, t)}
+ {errorView.detail}
);
})()}
diff --git a/apps/client/src/features/ai-chat/utils/error-message.test.ts b/apps/client/src/features/ai-chat/utils/error-message.test.ts
index 83d52b3c..f60f8cb4 100644
--- a/apps/client/src/features/ai-chat/utils/error-message.test.ts
+++ b/apps/client/src/features/ai-chat/utils/error-message.test.ts
@@ -6,48 +6,163 @@ import { describeChatError } from "./error-message";
const t = (key: string) => key;
describe("describeChatError", () => {
- it('surfaces a provider "402: ..." stream error verbatim', () => {
- expect(describeChatError("402: Insufficient credits", t)).toBe(
- "402: Insufficient credits",
- );
- });
-
- it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
- // A provider message mentioning the number 403 must be surfaced verbatim,
- // never folded into the "AI chat is disabled" gating message.
- const msg = "429: rate limited after 403 attempts";
- expect(describeChatError(msg, t)).toBe(msg);
- });
-
- it('maps a {"statusCode":403} body to the disabled message', () => {
+ it('maps a {"statusCode":403} body to the disabled heading', () => {
const body = '{"statusCode":403,"message":"Forbidden"}';
- expect(describeChatError(body, t)).toBe(
- "AI chat is disabled for this workspace.",
- );
+ expect(describeChatError(body, t)).toEqual({
+ title: "AI chat is disabled",
+ detail: "AI chat is disabled for this workspace.",
+ });
});
- it('maps a {"statusCode":503} body to the not-configured message', () => {
+ it('maps a {"statusCode":503} body to the not-configured heading', () => {
const body = '{"statusCode":503,"message":"Service Unavailable"}';
- expect(describeChatError(body, t)).toBe(
- "The AI provider is not configured. Ask an administrator to set it up.",
+ expect(describeChatError(body, t)).toEqual({
+ title: "AI provider not configured",
+ detail:
+ "The AI provider is not configured. Ask an administrator to set it up.",
+ });
+ });
+
+ it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
+ expect(
+ describeChatError("Cannot connect to API: read ECONNRESET", t).title,
+ ).toBe("Lost connection to the AI provider");
+ });
+
+ it('classifies "fetch failed" as a lost-connection error', () => {
+ expect(describeChatError("fetch failed", t).title).toBe(
+ "Lost connection to the AI provider",
);
});
- it('falls back to the generic message for "An error occurred."', () => {
- expect(describeChatError("An error occurred.", t)).toBe(
- "The AI agent could not respond. Please try again.",
+ it("classifies ETIMEDOUT as a timeout", () => {
+ expect(describeChatError("ETIMEDOUT", t).title).toBe(
+ "The AI provider timed out",
);
});
- it('falls back to the generic message for "Internal server error"', () => {
- expect(describeChatError("Internal server error", t)).toBe(
- "The AI agent could not respond. Please try again.",
+ it('classifies "504: Gateway Timeout" as a timeout', () => {
+ expect(describeChatError("504: Gateway Timeout", t).title).toBe(
+ "The AI provider timed out",
);
});
- it("falls back to the generic message for empty input", () => {
- expect(describeChatError("", t)).toBe(
- "The AI agent could not respond. Please try again.",
+ it('classifies "429: Too Many Requests" as rate limited', () => {
+ expect(describeChatError("429: Too Many Requests", t).title).toBe(
+ "Rate limited by the AI provider",
+ );
+ });
+
+ it('does NOT misclassify a body that merely contains "403" as disabled', () => {
+ // Regression intent: a provider message mentioning the number 403 must never
+ // be folded into the "AI chat is disabled" gating heading. Here the 429
+ // signature wins (checked before any bare-403 logic exists), so it maps to
+ // the rate-limit category instead.
+ const view = describeChatError("429: rate limited after 403 attempts", t);
+ expect(view.title).toBe("Rate limited by the AI provider");
+ expect(view.title).not.toBe("AI chat is disabled");
+ });
+
+ it("classifies a context-window overflow as too-large", () => {
+ expect(
+ describeChatError(
+ "This model's maximum context length is 128000 tokens",
+ t,
+ ).title,
+ ).toBe("The conversation is too large");
+ });
+
+ it('classifies "402: Insufficient credits" as quota exceeded', () => {
+ expect(describeChatError("402: Insufficient credits", t).title).toBe(
+ "AI provider quota exceeded",
+ );
+ });
+
+ it('classifies "401: Unauthorized" as an auth failure', () => {
+ expect(describeChatError("401: Unauthorized", t).title).toBe(
+ "AI provider authentication failed",
+ );
+ });
+
+ it("falls back to the generic heading + detail for empty input", () => {
+ expect(describeChatError("", t)).toEqual({
+ title: "Something went wrong",
+ detail: "The AI agent could not respond. Please try again.",
+ });
+ });
+
+ it('falls back to the generic heading + detail for "An error occurred."', () => {
+ expect(describeChatError("An error occurred.", t)).toEqual({
+ title: "Something went wrong",
+ detail: "The AI agent could not respond. Please try again.",
+ });
+ });
+
+ it('falls back to the generic heading + detail for "Internal server error"', () => {
+ expect(describeChatError("Internal server error", t)).toEqual({
+ title: "Something went wrong",
+ detail: "The AI agent could not respond. Please try again.",
+ });
+ });
+
+ it("surfaces an unknown-but-informative provider detail verbatim under the generic heading", () => {
+ expect(describeChatError("418: I'm a teapot", t)).toEqual({
+ title: "Something went wrong",
+ detail: "418: I'm a teapot",
+ });
+ });
+
+ it("does NOT treat a number inside the response body as a leading status code (no auth)", () => {
+ // The real status (500) leads the string; the "401" lives in the snippet and
+ // must not trigger the auth category. The verbatim provider text is surfaced.
+ const body =
+ "500: Server error | response body: model gpt-4o-401-preview not found";
+ expect(describeChatError(body, t)).toEqual({
+ title: "Something went wrong",
+ detail: body,
+ });
+ });
+
+ it("does NOT treat a passing mention of billing as a quota error", () => {
+ // "billing" is no longer a quota signature; the verbatim text is surfaced.
+ const body = "502: Bad Gateway | response body: see our billing page";
+ expect(describeChatError(body, t)).toEqual({
+ title: "Something went wrong",
+ detail: body,
+ });
+ });
+
+ it('still rate-limits "429: rate limited after 403 attempts" and never disables', () => {
+ const view = describeChatError("429: rate limited after 403 attempts", t);
+ expect(view.title).toBe("Rate limited by the AI provider");
+ expect(view.title).not.toBe("AI chat is disabled");
+ });
+
+ it('does NOT treat "rate limit" inside the response body as a rate-limit error', () => {
+ // The textual rate-limit phrase lives only in the response-body snippet, and
+ // the leading 500 is not a classified numeric code, so it must not leak into
+ // the rate-limit category. (The detail itself falls back to the generic line
+ // here because the leading message contains "Internal Server Error", which
+ // providerDetail suppresses — the title is what this case pins.)
+ const body =
+ "500: Internal Server Error | response body: rate limit info: see our docs";
+ expect(describeChatError(body, t).title).toBe("Something went wrong");
+ expect(describeChatError(body, t).title).not.toBe(
+ "Rate limited by the AI provider",
+ );
+ });
+
+ it('does NOT treat ETIMEDOUT inside the response body as a timeout', () => {
+ // The 503 leads the string but is not a classified numeric code, and the
+ // ETIMEDOUT signature appears only in the body, so it must not leak into the
+ // timeout category; the verbatim text is surfaced under the generic heading.
+ const body = "503: x | response body: ETIMEDOUT appears in this log line";
+ expect(describeChatError(body, t)).toEqual({
+ title: "Something went wrong",
+ detail: body,
+ });
+ expect(describeChatError(body, t).title).not.toBe(
+ "The AI provider timed out",
);
});
});
diff --git a/apps/client/src/features/ai-chat/utils/error-message.ts b/apps/client/src/features/ai-chat/utils/error-message.ts
index 257fbd53..eae82a5c 100644
--- a/apps/client/src/features/ai-chat/utils/error-message.ts
+++ b/apps/client/src/features/ai-chat/utils/error-message.ts
@@ -1,24 +1,158 @@
/**
- * Turn an AI chat error message into a friendly inline string. Used for BOTH the
- * live `useChat().error` (its `.message`) and a persisted assistant error stored
- * in `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
- * body carrying a numeric "statusCode" field (matched precisely, not by bare
- * substring, so a provider message that merely contains "403"/"503"/"disabled" is
- * never misclassified). Everything else — provider stream failures forwarded as
- * ": " (402 credits, 429 rate limit, ...) — is surfaced verbatim.
+ * A classified AI chat error: a short bold heading naming the cause category and
+ * a one-line human-readable detail / next step. Both strings are already passed
+ * through `t`, so callers render them directly.
+ */
+export interface ChatErrorView {
+ title: string;
+ detail: string;
+}
+
+/**
+ * Turn an AI chat error message into a friendly heading + detail. Used for BOTH
+ * the live `useChat().error` (its `.message`) and a persisted assistant error in
+ * `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
+ * body carrying a numeric "statusCode" (matched precisely, not by bare substring,
+ * so a provider message that merely contains "403"/"503" is never misclassified).
+ * Known provider/network failures (connection reset, timeout, rate limit, context
+ * overflow, quota, auth) are mapped to a clear category; anything else falls back
+ * to the raw provider detail (or a generic line) under the original heading.
*/
export function describeChatError(
message: string,
t: (key: string) => string,
-): string {
+): ChatErrorView {
const msg = message ?? "";
+
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
- return t("AI chat is disabled for this workspace.");
+ return {
+ title: t("AI chat is disabled"),
+ detail: t("AI chat is disabled for this workspace."),
+ };
}
if (/"statusCode"\s*:\s*503\b/.test(msg)) {
- return t("The AI provider is not configured. Ask an administrator to set it up.");
+ return {
+ title: t("AI provider not configured"),
+ detail: t(
+ "The AI provider is not configured. Ask an administrator to set it up.",
+ ),
+ };
}
- return providerDetail(msg) ?? t("The AI agent could not respond. Please try again.");
+
+ const category = classifyProviderError(msg);
+ if (category) {
+ return { title: t(category.title), detail: t(category.detail) };
+ }
+
+ // Unknown error: surface the raw provider detail when it is informative,
+ // otherwise a generic line. The heading stays the original generic one.
+ return {
+ title: t("Something went wrong"),
+ detail:
+ providerDetail(msg) ??
+ t("The AI agent could not respond. Please try again."),
+ };
+}
+
+interface ErrorCategory {
+ /** English key for the bold heading. */
+ title: string;
+ /** English key for the one-line explanation. */
+ detail: string;
+}
+
+/**
+ * Map a provider/network error string to a friendly category. Order matters: the
+ * most specific signatures are tested first. Returns null when nothing matches,
+ * so the caller can fall back to the raw provider text. The English keys returned
+ * here are passed through `t` by the caller.
+ *
+ * The server formats provider errors as ": | response body:
+ * " (see server-side describeProviderError), so the HTTP status is always
+ * the LEADING token. We match a numeric code only when it leads the string, so a
+ * number inside the response-body snippet never triggers a category; textual
+ * signatures are matched only against the leading message (before the response
+ * body), so a phrase inside the snippet never triggers a category either.
+ */
+function classifyProviderError(msg: string): ErrorCategory | null {
+ const code = /^\s*(\d{3})\b/.exec(msg)?.[1] ?? "";
+ // The server appends "| response body: " to provider errors; match
+ // textual signatures only against the leading provider message so a phrase
+ // inside the response-body snippet never triggers a wrong category. The numeric
+ // status code is read from the start of the full string above.
+ const head = msg.split(/\|\s*response body:/i)[0];
+
+ // Connection dropped / provider unreachable. ECONNRESET is the production case:
+ // the LLM socket was reset mid-stream. "terminated" is scoped to a connection/
+ // stream context so it does not match benign "... was terminated" messages.
+ if (
+ /ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
+ head,
+ )
+ ) {
+ return {
+ title: "Lost connection to the AI provider",
+ detail:
+ "The connection to the AI provider dropped before the answer finished. Please try again.",
+ };
+ }
+ // Timeout.
+ if (
+ code === "504" ||
+ code === "408" ||
+ /ETIMEDOUT|timed[\s-]?out|\btimeout\b/i.test(head)
+ ) {
+ return {
+ title: "The AI provider timed out",
+ detail: "The AI provider took too long to respond. Please try again.",
+ };
+ }
+ // Rate limited.
+ if (code === "429" || /rate[\s-]?limit|too many requests/i.test(head)) {
+ return {
+ title: "Rate limited by the AI provider",
+ detail:
+ "The AI provider is rate-limiting requests. Wait a moment and try again.",
+ };
+ }
+ // Context window / token budget exceeded.
+ if (
+ code === "413" ||
+ /context[\s_-]?(?:length|window)|maximum context|context_length_exceeded|too many tokens|maximum[^.]*tokens|reduce the length/i.test(
+ head,
+ )
+ ) {
+ return {
+ title: "The conversation is too large",
+ detail:
+ "The document and search results exceeded the model's context window. Start a new chat or narrow the request.",
+ };
+ }
+ // Out of credits / quota / payment required.
+ if (
+ code === "402" ||
+ /payment required|insufficient (?:credits|quota|funds|balance)|out of credits|quota (?:exceeded|exhausted)/i.test(
+ head,
+ )
+ ) {
+ return {
+ title: "AI provider quota exceeded",
+ detail:
+ "The AI provider rejected the request because of credits or quota. Check the provider account.",
+ };
+ }
+ // Authentication / bad API key.
+ if (
+ code === "401" ||
+ /\bunauthorized\b|invalid api key|user not found|\bauthentication\b/i.test(head)
+ ) {
+ return {
+ title: "AI provider authentication failed",
+ detail:
+ "The AI provider rejected the credentials. Ask an administrator to check the API key.",
+ };
+ }
+ return null;
}
/**
diff --git a/apps/client/src/features/share/components/share-ai-widget.tsx b/apps/client/src/features/share/components/share-ai-widget.tsx
index b013122b..b5c285da 100644
--- a/apps/client/src/features/share/components/share-ai-widget.tsx
+++ b/apps/client/src/features/share/components/share-ai-widget.tsx
@@ -88,6 +88,10 @@ export default function ShareAiWidget({
const isStreaming = status === "submitted" || status === "streaming";
+ // Same classified-error banner as the internal chat: name the cause instead of a
+ // generic heading.
+ const errorView = error ? describeChatError(error.message ?? "", t) : null;
+
const handleSend = () => {
const text = input.trim();
if (!text || isStreaming) return;
@@ -173,18 +177,18 @@ export default function ShareAiWidget({
/>
- {error && (
+ {errorView && (
}
mx="sm"
mb="xs"
- title={t("Something went wrong")}
+ title={errorView.title}
>
- {/* Surface the real cause (provider/gating message) instead of a
+ {/* Surface the real cause (provider/gating category) instead of a
generic line — same helper the internal chat uses. */}
- {describeChatError(error.message ?? "", t)}
+ {errorView.detail}
)}
diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts
index becf082f..5add9494 100644
--- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts
+++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts
@@ -1,6 +1,19 @@
import { AiChatToolsService } from './ai-chat-tools.service';
import * as loader from './docmost-client.loader';
import type { DocmostClientLike } from './docmost-client.loader';
+// The real zod-agnostic shared tool-spec registry. It has no runtime deps, so
+// importing the TS source directly keeps these mocks honest: the service builds
+// the shared tools from exactly the specs the package ships, not a hand-stub.
+import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
+
+// loadDocmostMcp now resolves to { DocmostClient, sharedToolSpecs }. Every mock
+// below must supply sharedToolSpecs or the service throws while building the
+// shared tools. Factor the resolved-value shape so the three mock sites stay in
+// sync.
+const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({
+ DocmostClient,
+ sharedToolSpecs: SHARED_TOOL_SPECS as Record,
+});
/**
* Guardrail test (§14 [H4]): the adapter's `deletePage` write tool must be a
@@ -37,11 +50,11 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
beforeEach(() => {
deletePageCalls.length = 0;
// Intercept the ESM loader so `new DocmostClient(config)` returns our fake.
- jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
- DocmostClient: function () {
+ jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
+ mockLoaded(function () {
return fakeClient as DocmostClientLike;
- } as unknown as loader.DocmostClientCtor,
- });
+ } as unknown as loader.DocmostClientCtor),
+ );
// The new semanticSearch deps (aiService + repos) are not exercised by the
// deletePage guardrail tests; pass stubs to satisfy the constructor arity.
service = new AiChatToolsService(
@@ -144,11 +157,11 @@ describe('AiChatToolsService expanded toolset guardrails', () => {
let service: AiChatToolsService;
beforeEach(() => {
- jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
- DocmostClient: function () {
+ jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
+ mockLoaded(function () {
return fakeClient as DocmostClientLike;
- } as unknown as loader.DocmostClientCtor,
- });
+ } as unknown as loader.DocmostClientCtor),
+ );
service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,
@@ -252,11 +265,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
patchNodeCalls.length = 0;
insertNodeCalls.length = 0;
updatePageJsonCalls.length = 0;
- jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
- DocmostClient: function () {
+ jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
+ mockLoaded(function () {
return fakeClient as DocmostClientLike;
- } as unknown as loader.DocmostClientCtor,
- });
+ } as unknown as loader.DocmostClientCtor),
+ );
service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,
diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts
index afe0404f..e5fcd5ba 100644
--- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts
+++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts
@@ -11,6 +11,7 @@ import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'
import {
loadDocmostMcp,
type DocmostClientLike,
+ type SharedToolSpec,
} from './docmost-client.loader';
import { resolveCurrentPageResult } from './current-page.util';
import { parseNodeArg } from './parse-node-arg';
@@ -84,13 +85,29 @@ export class AiChatToolsService {
aiChatId,
});
- const { DocmostClient } = await loadDocmostMcp();
+ const { DocmostClient, sharedToolSpecs } = await loadDocmostMcp();
const client: DocmostClientLike = new DocmostClient({
apiUrl,
getToken,
getCollabToken,
});
+ // Build an ai-SDK tool from a shared, zod-agnostic spec. The spec owns the
+ // canonical description + (optional) schema builder, which is invoked with
+ // THIS layer's zod (v4); only the execute body is supplied per call. No-arg
+ // specs (no buildShape) get an empty object schema.
+ const sharedTool = (
+ spec: SharedToolSpec,
+ execute: Tool['execute'],
+ ): Tool =>
+ tool({
+ description: spec.description,
+ inputSchema: spec.buildShape
+ ? z.object(spec.buildShape(z) as z.ZodRawShape)
+ : z.object({}),
+ execute,
+ });
+
return {
searchPages: tool({
description:
@@ -403,20 +420,15 @@ export class AiChatToolsService {
// --- READ tools (added) ---
- getWorkspace: tool({
- description:
- 'Fetch metadata about the current workspace (name, settings).',
- inputSchema: z.object({}),
- execute: async () => await client.getWorkspace(),
- }),
+ getWorkspace: sharedTool(
+ sharedToolSpecs.getWorkspace,
+ async () => await client.getWorkspace(),
+ ),
- listSpaces: tool({
- description:
- 'List the spaces the current user can access. Returns the array ' +
- 'of spaces (id, name, slug, ...).',
- inputSchema: z.object({}),
- execute: async () => await client.getSpaces(),
- }),
+ listSpaces: sharedTool(
+ sharedToolSpecs.listSpaces,
+ async () => await client.getSpaces(),
+ ),
listPages: tool({
description:
@@ -464,43 +476,20 @@ export class AiChatToolsService {
await client.listSidebarPages(spaceId, pageId),
}),
- getOutline: tool({
- description:
- "Compact outline of a page's top-level blocks, with block ids. Use " +
- 'it to locate sections/tables and grab block ids before drilling in ' +
- 'with getNode / patchNode / insertNode.',
- inputSchema: z.object({
- pageId: z.string().describe('The id of the page.'),
- }),
- execute: async ({ pageId }) => await client.getOutline(pageId),
- }),
+ getOutline: sharedTool(
+ sharedToolSpecs.getOutline,
+ async ({ pageId }) => await client.getOutline(pageId),
+ ),
- getPageJson: tool({
- description:
- 'Fetch a page as lossless ProseMirror JSON (preserves block ids and ' +
- 'marks). Use this when you need exact structure for node-level edits.',
- inputSchema: z.object({
- pageId: z.string().describe('The id of the page.'),
- }),
- execute: async ({ pageId }) => await client.getPageJson(pageId),
- }),
+ getPageJson: sharedTool(
+ sharedToolSpecs.getPageJson,
+ async ({ pageId }) => await client.getPageJson(pageId),
+ ),
- getNode: tool({
- description:
- "Fetch a single block's full ProseMirror subtree (lossless) by " +
- 'reference.',
- inputSchema: z.object({
- pageId: z.string().describe('The id of the page.'),
- nodeId: z
- .string()
- .describe(
- 'A block id from getOutline, or "#" to select a ' +
- 'top-level block by its outline index (e.g. a table).',
- ),
- }),
- execute: async ({ pageId, nodeId }) =>
- await client.getNode(pageId, nodeId),
- }),
+ getNode: sharedTool(
+ sharedToolSpecs.getNode,
+ async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
+ ),
getTable: tool({
description:
@@ -557,27 +546,16 @@ export class AiChatToolsService {
await client.checkNewComments(spaceId, since, parentPageId),
}),
- listShares: tool({
- description:
- 'List all public shares in the workspace, each with its public URL.',
- inputSchema: z.object({}),
- execute: async () => await client.listShares(),
- }),
+ listShares: sharedTool(
+ sharedToolSpecs.listShares,
+ async () => await client.listShares(),
+ ),
- listPageHistory: tool({
- description:
- 'List the saved versions (history snapshots) of a page, newest ' +
- 'first. Returns one cursor-paginated page of results.',
- inputSchema: z.object({
- pageId: z.string().describe('The id of the page.'),
- cursor: z
- .string()
- .optional()
- .describe('Optional pagination cursor from a previous call.'),
- }),
- execute: async ({ pageId, cursor }) =>
+ listPageHistory: sharedTool(
+ sharedToolSpecs.listPageHistory,
+ async ({ pageId, cursor }) =>
await client.listPageHistory(pageId, cursor),
- }),
+ ),
getPageHistory: tool({
description:
@@ -590,24 +568,11 @@ export class AiChatToolsService {
await client.getPageHistory(historyId),
}),
- diffPageVersions: tool({
- description:
- 'Diff two versions of a page and return the change set. from/to ' +
- "each accept a historyId or 'current' (or omit for current).",
- inputSchema: z.object({
- pageId: z.string().describe('The id of the page.'),
- from: z
- .string()
- .optional()
- .describe("A historyId, or 'current'/omit for current content."),
- to: z
- .string()
- .optional()
- .describe("A historyId, or 'current'/omit for current content."),
- }),
- execute: async ({ pageId, from, to }) =>
+ diffPageVersions: sharedTool(
+ sharedToolSpecs.diffPageVersions,
+ async ({ pageId, from, to }) =>
await client.diffPageVersions(pageId, from, to),
- }),
+ ),
exportPageMarkdown: tool({
description:
@@ -625,46 +590,10 @@ export class AiChatToolsService {
// --- WRITE tools (added; reversible via page history/trash) ---
- editPageText: tool({
- description:
- 'Surgical find/replace inside a page\'s text, preserving all block ' +
- 'ids and marks. A find MAY cross bold/italic/link boundaries; the ' +
- 'replacement inherits marks from the unchanged common prefix/suffix ' +
- '(so editing plain text next to a bold word keeps it bold, and ' +
- 'editing inside a bold word keeps the new text bold). Each find must ' +
- 'match exactly once unless replaceAll is set. The batch applies what ' +
- 'it can and returns applied[] + failed[] plus a verify change-report ' +
- '(the text/marks/structure that ACTUALLY changed — read it to confirm ' +
- 'your edit landed; do not assume success); a fully-unmatched batch ' +
- 'writes nothing and errors. find and replace are LITERAL text, not ' +
- 'markdown. This tool edits plain text ONLY and CANNOT add or remove ' +
- 'formatting marks: a formatting change — find/replace that differ only ' +
- 'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' +
- 'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' +
- 'failed[]. To change bold/italic/strike/code/link, read the block with ' +
- 'getPageJson and use patchNode (or updatePageJson) to set its marks. ' +
- 'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
- 'world",replace:"Hello there"}] (crosses a bold boundary). Reversible: ' +
- 'the previous version is kept in page history.',
- inputSchema: z.object({
- pageId: z.string().describe('The id of the page to edit.'),
- edits: z
- .array(
- z.object({
- find: z.string().describe('Exact text to find.'),
- replace: z.string().describe('Replacement text.'),
- replaceAll: z
- .boolean()
- .optional()
- .describe('Replace every occurrence (default: one match).'),
- }),
- )
- .min(1)
- .describe('One or more find/replace edits.'),
- }),
- execute: async ({ pageId, edits }) =>
- await client.editPageText(pageId, edits),
- }),
+ editPageText: sharedTool(
+ sharedToolSpecs.editPageText,
+ async ({ pageId, edits }) => await client.editPageText(pageId, edits),
+ ),
patchNode: tool({
description:
@@ -754,17 +683,10 @@ export class AiChatToolsService {
},
}),
- deleteNode: tool({
- description:
- 'Remove a content BLOCK by its id (NOT a page). Reversible: the ' +
- 'previous version is kept in page history.',
- inputSchema: z.object({
- pageId: z.string().describe('The id of the page.'),
- nodeId: z.string().describe('The block id to remove.'),
- }),
- execute: async ({ pageId, nodeId }) =>
- await client.deleteNode(pageId, nodeId),
- }),
+ deleteNode: sharedTool(
+ sharedToolSpecs.deleteNode,
+ async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
+ ),
updatePageJson: tool({
description:
@@ -853,35 +775,17 @@ export class AiChatToolsService {
await client.tableUpdateCell(pageId, tableRef, row, col, text),
}),
- copyPageContent: tool({
- description:
- "Replace the target page's BODY with the source page's body " +
- '(title/slug are kept). Runs server-side — no document passes ' +
- 'through the model. Reversible: the target keeps page history.',
- inputSchema: z.object({
- sourcePageId: z.string().describe('The id of the source page.'),
- targetPageId: z
- .string()
- .describe('The id of the target page to overwrite.'),
- }),
- execute: async ({ sourcePageId, targetPageId }) =>
+ copyPageContent: sharedTool(
+ sharedToolSpecs.copyPageContent,
+ async ({ sourcePageId, targetPageId }) =>
await client.copyPageContent(sourcePageId, targetPageId),
- }),
+ ),
- importPageMarkdown: tool({
- description:
- "Replace a page's body from Docmost-flavoured Markdown (as produced " +
- 'by exportPageMarkdown). Reversible: the previous version is kept in ' +
- 'page history.',
- inputSchema: z.object({
- pageId: z.string().describe('The id of the page to overwrite.'),
- markdown: z
- .string()
- .describe('Docmost-flavoured Markdown for the page body.'),
- }),
- execute: async ({ pageId, markdown }) =>
+ importPageMarkdown: sharedTool(
+ sharedToolSpecs.importPageMarkdown,
+ async ({ pageId, markdown }) =>
await client.importPageMarkdown(pageId, markdown),
- }),
+ ),
sharePage: tool({
description:
@@ -899,27 +803,15 @@ export class AiChatToolsService {
await client.sharePage(pageId, searchIndexing),
}),
- unsharePage: tool({
- description:
- 'Remove the public share of a page (reverses sharePage).',
- inputSchema: z.object({
- pageId: z.string().describe('The id of the page to unshare.'),
- }),
- execute: async ({ pageId }) => await client.unsharePage(pageId),
- }),
+ unsharePage: sharedTool(
+ sharedToolSpecs.unsharePage,
+ async ({ pageId }) => await client.unsharePage(pageId),
+ ),
- restorePageVersion: tool({
- description:
- 'Restore a past version by writing its content back as the current ' +
- 'page content. Itself reversible: it creates a new history snapshot.',
- inputSchema: z.object({
- historyId: z
- .string()
- .describe('The id of the history version to restore.'),
- }),
- execute: async ({ historyId }) =>
- await client.restorePageVersion(historyId),
- }),
+ restorePageVersion: sharedTool(
+ sharedToolSpecs.restorePageVersion,
+ async ({ historyId }) => await client.restorePageVersion(historyId),
+ ),
transformPage: tool({
description:
diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts
index 7773fb39..5b740cfe 100644
--- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts
+++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts
@@ -167,8 +167,29 @@ export interface DocmostClientCtor {
new (config: DocmostClientConfig): DocmostClientLike;
}
+/**
+ * Local hand-mirror of the `SharedToolSpec` shape exported from
+ * `@docmost/mcp` (packages/mcp/src/tool-specs.ts). Same approach as
+ * `DocmostClientLike`: we do not import the ESM package's types directly across
+ * the CJS/ESM boundary. The registry itself has no runtime deps, but keeping the
+ * type local avoids coupling the server build to the package's type surface.
+ *
+ * `buildShape` is intentionally zod-agnostic: it returns a plain ZodRawShape
+ * built with whatever zod namespace the caller passes (the server passes its own
+ * zod v4; the MCP package passes its zod v3). See the registry module comment.
+ */
+export interface SharedToolSpec {
+ mcpName: string;
+ inAppKey: string;
+ description: string;
+ // Loose `z` on purpose: the registry is zod-agnostic so the server can pass
+ // its own zod (v4) and the MCP package its own (v3) into the same builder.
+ buildShape?: (z: any) => Record;
+}
+
interface DocmostMcpModule {
DocmostClient: DocmostClientCtor;
+ SHARED_TOOL_SPECS: Record;
}
// TS with module:commonjs downlevels a literal `import()` to `require()`, which
@@ -191,6 +212,7 @@ let modulePromise: Promise | null = null;
*/
export async function loadDocmostMcp(): Promise<{
DocmostClient: DocmostClientCtor;
+ sharedToolSpecs: Record;
}> {
if (!modulePromise) {
modulePromise = (async () => {
@@ -206,5 +228,15 @@ export async function loadDocmostMcp(): Promise<{
});
}
const mod = await modulePromise;
- return { DocmostClient: mod.DocmostClient };
+ if (!mod.SHARED_TOOL_SPECS) {
+ // A stale @docmost/mcp build (missing the shared registry export) would
+ // otherwise surface as a confusing TypeError deep in the tools service.
+ throw new Error(
+ '@docmost/mcp is stale: SHARED_TOOL_SPECS missing — rebuild the package (pnpm --filter @docmost/mcp build).',
+ );
+ }
+ return {
+ DocmostClient: mod.DocmostClient,
+ sharedToolSpecs: mod.SHARED_TOOL_SPECS,
+ };
}
diff --git a/apps/server/test/integration/db.ts b/apps/server/test/integration/db.ts
index bb4001c8..8cf11fdb 100644
--- a/apps/server/test/integration/db.ts
+++ b/apps/server/test/integration/db.ts
@@ -3,6 +3,31 @@ import { CamelCasePlugin, Kysely } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
+/**
+ * db.ts — THE canonical place to seed prerequisite rows for integration tests.
+ *
+ * Seeders here use minimal, explicit `insertInto(...).values(...)` calls and are
+ * DELIBERATELY decoupled from the app's repo `insert*` methods. Those repo
+ * methods carry side effects integration specs do not want — password hashing,
+ * validation, default/derived columns, event emission — so reproducing only the
+ * columns a test needs keeps the fixtures small, fast and predictable.
+ *
+ * CONVENTIONS:
+ * - New entity seeders go HERE (a `createX(db, ...)` helper) rather than as raw
+ * `insertInto` calls scattered across spec files, so the schema knowledge
+ * lives in one place.
+ * - Each seeder inserts only the NOT NULL / uniquely-constrained columns plus
+ * whatever the consuming tests assert on; everything else is left to DB
+ * defaults.
+ * - Plain `randomUUID()` (v4) is fine for FK integrity; the app uses uuid v7,
+ * but tests never depend on id ordering.
+ *
+ * TRADE-OFF: because the column/constraint knowledge below is mirrored from the
+ * Kysely schema rather than derived from it, a migration that changes a NOT NULL
+ * column or a unique constraint can make an insert here fail. When that happens
+ * the fix is to update the relevant seeder, not the spec that calls it.
+ */
+
/**
* Isolated test database connection string. The dev DB is `docmost`; tests run
* against a dedicated `docmost_test` that global-setup drops + recreates +
@@ -58,21 +83,27 @@ export async function destroyTestDb(): Promise {
}
// --- Seeding helpers ---------------------------------------------------------
-// Insert minimal valid rows (only the columns the tests need + NOT NULL ones).
-// Plain randomUUID() is fine for FK integrity in tests (the app uses uuid v7).
+// Each helper inserts a minimal valid row (only the columns the tests need plus
+// the NOT NULL / uniquely-constrained ones) and returns the generated id. See
+// the module doc comment above for why these bypass the app's repo layer.
+
+// Short, human-readable suffix derived from a row's uuid. Used to build unique
+// names/slugs/hostnames for seeded rows so unique constraints never collide.
+const shortId = (id: string): string => id.slice(0, 8);
export async function createWorkspace(
db: Kysely,
overrides: { settings?: unknown; name?: string } = {},
): Promise<{ id: string; settings: any }> {
const id = randomUUID();
+ const suffix = shortId(id);
const row = await db
.insertInto('workspaces')
.values({
id,
- name: overrides.name ?? `ws-${id.slice(0, 8)}`,
+ name: overrides.name ?? `ws-${suffix}`,
// hostname is uniquely constrained; keep it unique per workspace.
- hostname: `host-${id.slice(0, 8)}`,
+ hostname: `host-${suffix}`,
settings: overrides.settings === undefined ? null : (overrides.settings as any),
})
.returning(['id', 'settings'])
@@ -86,12 +117,13 @@ export async function createUser(
overrides: { email?: string; name?: string } = {},
): Promise<{ id: string }> {
const id = randomUUID();
+ const suffix = shortId(id);
const row = await db
.insertInto('users')
.values({
id,
- email: overrides.email ?? `user-${id.slice(0, 8)}@example.test`,
- name: overrides.name ?? `user-${id.slice(0, 8)}`,
+ email: overrides.email ?? `user-${suffix}@example.test`,
+ name: overrides.name ?? `user-${suffix}`,
workspaceId,
})
.returning(['id'])
@@ -105,13 +137,14 @@ export async function createSpace(
overrides: { slug?: string; name?: string } = {},
): Promise<{ id: string }> {
const id = randomUUID();
+ const suffix = shortId(id);
const row = await db
.insertInto('spaces')
.values({
id,
- name: overrides.name ?? `space-${id.slice(0, 8)}`,
+ name: overrides.name ?? `space-${suffix}`,
// slug is unique per workspace + NOT NULL.
- slug: overrides.slug ?? `space-${id.slice(0, 8)}`,
+ slug: overrides.slug ?? `space-${suffix}`,
workspaceId,
})
.returning(['id'])
@@ -124,13 +157,14 @@ export async function createPage(
args: { workspaceId: string; spaceId: string; title?: string },
): Promise<{ id: string }> {
const id = randomUUID();
+ const suffix = shortId(id);
const row = await db
.insertInto('pages')
.values({
id,
// slug_id is NOT NULL + globally unique.
- slugId: `slug-${id.slice(0, 8)}`,
- title: args.title ?? `page-${id.slice(0, 8)}`,
+ slugId: `slug-${suffix}`,
+ title: args.title ?? `page-${suffix}`,
spaceId: args.spaceId,
workspaceId: args.workspaceId,
})
@@ -186,7 +220,7 @@ export async function createChat(
workspaceId: args.workspaceId,
creatorId: args.creatorId,
roleId: args.roleId ?? null,
- title: args.title ?? `chat-${id.slice(0, 8)}`,
+ title: args.title ?? `chat-${shortId(id)}`,
})
.returning(['id'])
.executeTakeFirstOrThrow();
diff --git a/packages/mcp/build/index.js b/packages/mcp/build/index.js
index efc101fc..b0c20413 100644
--- a/packages/mcp/build/index.js
+++ b/packages/mcp/build/index.js
@@ -5,10 +5,15 @@ import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient } from "./client.js";
import { parseNodeArg } from "./lib/parse-node-arg.js";
+import { SHARED_TOOL_SPECS } from "./tool-specs.js";
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
// directly — for the credentials variant OR the per-user getToken variant.
export { DocmostClient } from "./client.js";
+// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK
+// service can read it off the loaded module (it cannot import the ESM package's
+// internals directly; it goes through loadDocmostMcp()).
+export { SHARED_TOOL_SPECS } from "./tool-specs.js";
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -46,17 +51,27 @@ export function createDocmostMcpServer(config) {
name: "docmost-mcp",
version: VERSION,
}, { instructions: SERVER_INSTRUCTIONS });
+ // Register a tool from the shared, zod-agnostic spec registry. The spec owns
+ // the canonical name + model-facing description + (optional) schema builder;
+ // only the execute body is supplied per call. buildShape is invoked with THIS
+ // package's zod (v3); the in-app layer passes its own zod (v4).
+ //
+ // The spec's schema builder returns a plain ZodRawShape (Record in the shared module since it must stay zod-agnostic), so the
+ // McpServer.registerTool overloads cannot infer the execute arg's shape from
+ // it. We type `execute` loosely and cast the call through `any`; runtime
+ // behaviour is unchanged — each execute body destructures the same fields the
+ // builder declares.
+ const registerShared = (spec, execute) => server.registerTool(spec.mcpName, spec.buildShape
+ ? { description: spec.description, inputSchema: spec.buildShape(z) }
+ : { description: spec.description }, execute);
// Tool: get_workspace
- server.registerTool("get_workspace", {
- description: "Get the current Docmost workspace",
- }, async () => {
+ registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
const workspace = await docmostClient.getWorkspace();
return jsonContent(workspace);
});
// Tool: list_spaces
- server.registerTool("list_spaces", {
- description: "List all available spaces in Docmost",
- }, async () => {
+ registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
const spaces = await docmostClient.getSpaces();
return jsonContent(spaces);
});
@@ -97,43 +112,17 @@ export function createDocmostMcpServer(config) {
return jsonContent(page);
});
// Tool: get_page_json
- server.registerTool("get_page_json", {
- description: "Get page details with the raw ProseMirror JSON content (lossless: " +
- "includes block ids, callouts, tables, link/image attributes) plus the " +
- "slugId used in URLs. Use together with update_page_json for precise " +
- "structural edits, or edit_page_text for simple text fixes.",
- inputSchema: {
- pageId: z.string().min(1),
- },
- }, async ({ pageId }) => {
+ registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
const page = await docmostClient.getPageJson(pageId);
return jsonContent(page);
});
// Tool: get_outline
- server.registerTool("get_outline", {
- description: "Return a COMPACT outline of a page's top-level blocks ({index, type, " +
- "id, level, firstText}; tables add rows/cols/header; lists add item " +
- "count) WITHOUT the full document body. Use it to locate sections/tables " +
- "and grab block ids cheaply before get_node / patch_node / insert_node.",
- inputSchema: {
- pageId: z.string().min(1),
- },
- }, async ({ pageId }) => {
+ registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
const result = await docmostClient.getOutline(pageId);
return jsonContent(result);
});
// Tool: get_node
- server.registerTool("get_node", {
- description: "Fetch a single node's full ProseMirror subtree (lossless) without " +
- "pulling the whole document. `nodeId` is a block id from get_outline/" +
- "get_page_json (works for headings/paragraphs/callouts/images), OR " +
- "`#` to fetch a top-level block by its outline index — use the " +
- "`#` form for tables/rows/cells, which carry no id.",
- inputSchema: {
- pageId: z.string().min(1),
- nodeId: z.string().min(1),
- },
- }, async ({ pageId, nodeId }) => {
+ registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
const result = await docmostClient.getNode(pageId, nodeId);
return jsonContent(result);
});
@@ -270,35 +259,12 @@ export function createDocmostMcpServer(config) {
return { content: [{ type: "text", text: md }] };
});
// Tool: import_page_markdown
- server.registerTool("import_page_markdown", {
- description: "Replace a page's content from a self-contained Docmost-flavoured " +
- "Markdown file produced by export_page_markdown. Restores comment " +
- "highlight anchors and diagrams from their inline HTML. NOTE: comment " +
- "thread records are NOT created/updated/deleted on the server by this " +
- "tool — only the page body + inline comment marks are written; manage " +
- "comment threads via the comment tools/UI.",
- inputSchema: {
- pageId: z.string().min(1),
- markdown: z.string().min(1),
- },
- }, async ({ pageId, markdown }) => {
+ registerShared(SHARED_TOOL_SPECS.importPageMarkdown, async ({ pageId, markdown }) => {
const res = await docmostClient.importPageMarkdown(pageId, markdown);
return jsonContent(res);
});
// Tool: copy_page_content
- server.registerTool("copy_page_content", {
- description: "Replace targetPageId's content with a copy of sourcePageId's content, " +
- "entirely server-side — the document is NOT sent through the model. The " +
- "target keeps its own title and slug; only its body is replaced. Ideal " +
- "for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
- inputSchema: {
- sourcePageId: z.string().min(1).describe("Page to copy content FROM"),
- targetPageId: z
- .string()
- .min(1)
- .describe("Page whose content is REPLACED (title/slug kept)"),
- },
- }, async ({ sourcePageId, targetPageId }) => {
+ registerShared(SHARED_TOOL_SPECS.copyPageContent, async ({ sourcePageId, targetPageId }) => {
const result = await docmostClient.copyPageContent(sourcePageId, targetPageId);
return jsonContent(result);
});
@@ -315,40 +281,7 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: edit_page_text
- server.registerTool("edit_page_text", {
- description: "Surgical find/replace inside a page's text. Preserves ALL structure: " +
- "block ids, marks, links, callouts, tables. A `find` MAY cross " +
- "bold/italic/link boundaries; the replacement inherits marks from the " +
- "unchanged common prefix/suffix (editing plain text next to a bold word " +
- "keeps it bold; editing inside a bold word keeps the new text bold). " +
- "Each `find` must match exactly once (or set replaceAll). The batch " +
- "applies what it can and returns applied[] + failed[]; a fully-unmatched " +
- "batch writes nothing and errors. `find` should be the literal rendered " +
- "text (no markdown). Markdown wrappers (**bold**, *italic*, `code`) and " +
- "trailing emoji are tolerated via a strip-and-retry fallback, but plain " +
- "text is preferred. Examples: edits:[{find:\"teh\"," +
- "replace:\"the\"}]; edits:[{find:\"Hello world\",replace:\"Hello there\"}] " +
- "(crosses a bold boundary). This is the preferred tool for fixing " +
- "wording, typos, numbers, names. It edits plain text only and CANNOT " +
- "change formatting marks: formatting changes (markdown markers in " +
- "find/replace) are refused — use patch_node/update_page_json to change " +
- "marks. The result includes a `verify` change-report of what actually " +
- "changed (text/block/mark deltas).",
- inputSchema: {
- pageId: z.string().describe("ID of the page to edit"),
- edits: z
- .array(z.object({
- find: z.string().describe("Exact text to find"),
- replace: z.string().describe("Replacement text (may be empty)"),
- replaceAll: z
- .boolean()
- .optional()
- .describe("Replace every occurrence (default: must match once)"),
- }))
- .min(1)
- .describe("List of find/replace operations, applied in order"),
- },
- }, async ({ pageId, edits }) => {
+ registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
const result = await docmostClient.editPageText(pageId, edits);
return jsonContent(result);
});
@@ -417,14 +350,7 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: delete_node
- server.registerTool("delete_node", {
- description: "Remove a single block by its attrs.id (from get_page_json) WITHOUT " +
- "resending the whole document.",
- inputSchema: {
- pageId: z.string().min(1),
- nodeId: z.string().min(1),
- },
- }, async ({ pageId, nodeId }) => {
+ registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
const result = await docmostClient.deleteNode(pageId, nodeId);
return jsonContent(result);
});
@@ -510,19 +436,12 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: unshare_page
- server.registerTool("unshare_page", {
- description: "Remove the public share of a page (revokes the public URL).",
- inputSchema: {
- pageId: z.string().min(1).describe("ID of the page to unshare"),
- },
- }, async ({ pageId }) => {
+ registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
const result = await docmostClient.unsharePage(pageId);
return jsonContent(result);
});
// Tool: list_shares
- server.registerTool("list_shares", {
- description: "List all public shares in the workspace with page titles and public URLs.",
- }, async () => {
+ registerShared(SHARED_TOOL_SPECS.listShares, async () => {
const result = await docmostClient.listShares();
return jsonContent(result);
});
@@ -747,55 +666,17 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: diff_page_versions
- server.registerTool("diff_page_versions", {
- description: "Diff two versions of a page and return a Docmost-equivalent change set " +
- "(inserted/deleted text, integrity counts for images/links/tables/" +
- "callouts/footnote markers, and a human-readable markdown summary). " +
- "`from`/`to` each accept a historyId, or null/'current' for the page's " +
- "current content (defaults: from=current, to=current — pass a historyId " +
- "from list_page_history to compare against the live page).",
- inputSchema: {
- pageId: z.string().min(1),
- from: z
- .string()
- .optional()
- .describe("historyId, or 'current'/omit for current content"),
- to: z
- .string()
- .optional()
- .describe("historyId, or 'current'/omit for current content"),
- },
- }, async ({ pageId, from, to }) => {
+ registerShared(SHARED_TOOL_SPECS.diffPageVersions, async ({ pageId, from, to }) => {
const result = await docmostClient.diffPageVersions(pageId, from, to);
return jsonContent(result);
});
// Tool: list_page_history
- server.registerTool("list_page_history", {
- description: "List a page's saved versions (Docmost auto-snapshots on every save), " +
- "newest first, cursor-paginated. Returns { items, nextCursor }; each " +
- "item's id is the historyId to pass to diff_page_versions or " +
- "restore_page_version.",
- inputSchema: {
- pageId: z.string().min(1),
- cursor: z
- .string()
- .optional()
- .describe("Pagination cursor from a previous nextCursor"),
- },
- }, async ({ pageId, cursor }) => {
+ registerShared(SHARED_TOOL_SPECS.listPageHistory, async ({ pageId, cursor }) => {
const result = await docmostClient.listPageHistory(pageId, cursor);
return jsonContent(result);
});
// Tool: restore_page_version
- server.registerTool("restore_page_version", {
- description: "Restore a page to a saved version: writes that version's content back " +
- "as the page's current content (Docmost has no restore endpoint, so " +
- "this creates a NEW history snapshot — the restore is itself revertible). " +
- "Get the historyId from list_page_history.",
- inputSchema: {
- historyId: z.string().min(1),
- },
- }, async ({ historyId }) => {
+ registerShared(SHARED_TOOL_SPECS.restorePageVersion, async ({ historyId }) => {
const result = await docmostClient.restorePageVersion(historyId);
return jsonContent(result);
});
diff --git a/packages/mcp/build/lib/docmost-schema.js b/packages/mcp/build/lib/docmost-schema.js
index e89ed5a0..976e2d7f 100644
--- a/packages/mcp/build/lib/docmost-schema.js
+++ b/packages/mcp/build/lib/docmost-schema.js
@@ -732,6 +732,59 @@ const Embed = Node.create({
return ["div", { "data-type": "embed", ...HTMLAttributes }, 0];
},
});
+/**
+ * Docmost raw HTML embed. Block atom; the client renders `source` inside a
+ * sandboxed iframe. The MCP server never renders it — it only needs the
+ * schema to accept and carry the node so a fromYdoc -> transform -> toYdoc
+ * round-trip does not throw "Unknown node type: htmlEmbed". Mirrors the
+ * @docmost/editor-ext node name, attribute keys and flags; keep in sync when
+ * the editor-ext htmlEmbed schema changes.
+ *
+ * NOTE: unlike the canonical editor-ext node, `data-source` here is mapped as
+ * plain text rather than base64-encoded. That is intentional: the MCP write
+ * path carries the node through Yjs (fromYdoc -> toYdoc) on its JSON `source`
+ * attribute and never invokes parseHTML/renderHTML, and htmlEmbed is not
+ * produced from the markdown/HTML (generateJSON) path. If a future HTML path
+ * for htmlEmbed is added here, this mapping must adopt editor-ext's base64
+ * encode/decode to avoid double-encoding `source`.
+ */
+const HtmlEmbed = Node.create({
+ name: "htmlEmbed",
+ group: "block",
+ inline: false,
+ isolating: true,
+ atom: true,
+ defining: true,
+ draggable: true,
+ addAttributes() {
+ return {
+ source: {
+ default: "",
+ parseHTML: (el) => el.getAttribute("data-source") ?? "",
+ renderHTML: (attrs) => ({
+ "data-source": attrs.source ?? "",
+ }),
+ },
+ height: {
+ default: null,
+ parseHTML: (el) => {
+ const v = el.getAttribute("data-height");
+ if (!v)
+ return null;
+ const n = parseInt(v, 10);
+ return Number.isFinite(n) ? n : null;
+ },
+ renderHTML: (attrs) => attrs.height != null ? { "data-height": String(attrs.height) } : {},
+ },
+ };
+ },
+ parseHTML() {
+ return [{ tag: 'div[data-type="htmlEmbed"]' }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return ["div", { "data-type": "htmlEmbed", ...HTMLAttributes }, 0];
+ },
+});
/** Shared attribute set for drawio/excalidraw diagram nodes. */
const diagramAttributes = () => ({
src: {
@@ -1062,6 +1115,7 @@ export const docmostExtensions = [
Video,
Youtube,
Embed,
+ HtmlEmbed,
Drawio,
Excalidraw,
Columns,
diff --git a/packages/mcp/build/tool-specs.js b/packages/mcp/build/tool-specs.js
new file mode 100644
index 00000000..d834e657
--- /dev/null
+++ b/packages/mcp/build/tool-specs.js
@@ -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 ' +
+ '`#` to fetch a top-level block by its outline index — use the ' +
+ '`#` 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'),
+ }),
+ },
+};
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
index 09bd2142..54e40bca 100644
--- a/packages/mcp/src/index.ts
+++ b/packages/mcp/src/index.ts
@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient, DocmostMcpConfig } from "./client.js";
import { parseNodeArg } from "./lib/parse-node-arg.js";
+import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
@@ -12,6 +13,12 @@ import { parseNodeArg } from "./lib/parse-node-arg.js";
export { DocmostClient } from "./client.js";
export type { DocmostMcpConfig } from "./client.js";
+// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK
+// service can read it off the loaded module (it cannot import the ESM package's
+// internals directly; it goes through loadDocmostMcp()).
+export { SHARED_TOOL_SPECS } from "./tool-specs.js";
+export type { SharedToolSpec } from "./tool-specs.js";
+
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -62,29 +69,40 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
{ instructions: SERVER_INSTRUCTIONS },
);
+ // Register a tool from the shared, zod-agnostic spec registry. The spec owns
+ // the canonical name + model-facing description + (optional) schema builder;
+ // only the execute body is supplied per call. buildShape is invoked with THIS
+ // package's zod (v3); the in-app layer passes its own zod (v4).
+ //
+ // The spec's schema builder returns a plain ZodRawShape (Record in the shared module since it must stay zod-agnostic), so the
+ // McpServer.registerTool overloads cannot infer the execute arg's shape from
+ // it. We type `execute` loosely and cast the call through `any`; runtime
+ // behaviour is unchanged — each execute body destructures the same fields the
+ // builder declares.
+ const registerShared = (
+ spec: SharedToolSpec,
+ execute: (args: any) => Promise<{ content: { type: "text"; text: string }[] }>,
+ ) =>
+ (server.registerTool as any)(
+ spec.mcpName,
+ spec.buildShape
+ ? { description: spec.description, inputSchema: spec.buildShape(z) }
+ : { description: spec.description },
+ execute,
+ );
+
// Tool: get_workspace
- server.registerTool(
- "get_workspace",
- {
- description: "Get the current Docmost workspace",
- },
- async () => {
+ registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
const workspace = await docmostClient.getWorkspace();
return jsonContent(workspace);
- },
-);
+ });
-// Tool: list_spaces
-server.registerTool(
- "list_spaces",
- {
- description: "List all available spaces in Docmost",
- },
- async () => {
+ // Tool: list_spaces
+ registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
const spaces = await docmostClient.getSpaces();
return jsonContent(spaces);
- },
-);
+ });
// Tool: list_pages
server.registerTool(
@@ -137,63 +155,22 @@ server.registerTool(
);
// Tool: get_page_json
-server.registerTool(
- "get_page_json",
- {
- description:
- "Get page details with the raw ProseMirror JSON content (lossless: " +
- "includes block ids, callouts, tables, link/image attributes) plus the " +
- "slugId used in URLs. Use together with update_page_json for precise " +
- "structural edits, or edit_page_text for simple text fixes.",
- inputSchema: {
- pageId: z.string().min(1),
- },
- },
- async ({ pageId }) => {
- const page = await docmostClient.getPageJson(pageId);
- return jsonContent(page);
- },
-);
+registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
+ const page = await docmostClient.getPageJson(pageId);
+ return jsonContent(page);
+});
// Tool: get_outline
-server.registerTool(
- "get_outline",
- {
- description:
- "Return a COMPACT outline of a page's top-level blocks ({index, type, " +
- "id, level, firstText}; tables add rows/cols/header; lists add item " +
- "count) WITHOUT the full document body. Use it to locate sections/tables " +
- "and grab block ids cheaply before get_node / patch_node / insert_node.",
- inputSchema: {
- pageId: z.string().min(1),
- },
- },
- async ({ pageId }) => {
- const result = await docmostClient.getOutline(pageId);
- return jsonContent(result);
- },
-);
+registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
+ const result = await docmostClient.getOutline(pageId);
+ return jsonContent(result);
+});
// Tool: get_node
-server.registerTool(
- "get_node",
- {
- description:
- "Fetch a single node's full ProseMirror subtree (lossless) without " +
- "pulling the whole document. `nodeId` is a block id from get_outline/" +
- "get_page_json (works for headings/paragraphs/callouts/images), OR " +
- "`#` to fetch a top-level block by its outline index — use the " +
- "`#` 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);
- },
-);
+registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
+ const result = await docmostClient.getNode(pageId, nodeId);
+ return jsonContent(result);
+});
// Tool: table_get
server.registerTool(
@@ -387,21 +364,8 @@ server.registerTool(
);
// Tool: import_page_markdown
-server.registerTool(
- "import_page_markdown",
- {
- description:
- "Replace a page's content from a self-contained Docmost-flavoured " +
- "Markdown file produced by export_page_markdown. Restores comment " +
- "highlight anchors and diagrams from their inline HTML. NOTE: comment " +
- "thread records are NOT created/updated/deleted on the server by this " +
- "tool — only the page body + inline comment marks are written; manage " +
- "comment threads via the comment tools/UI.",
- inputSchema: {
- pageId: z.string().min(1),
- markdown: z.string().min(1),
- },
- },
+registerShared(
+ SHARED_TOOL_SPECS.importPageMarkdown,
async ({ pageId, markdown }) => {
const res = await docmostClient.importPageMarkdown(pageId, markdown);
return jsonContent(res);
@@ -409,22 +373,8 @@ server.registerTool(
);
// Tool: copy_page_content
-server.registerTool(
- "copy_page_content",
- {
- description:
- "Replace targetPageId's content with a copy of sourcePageId's content, " +
- "entirely server-side — the document is NOT sent through the model. The " +
- "target keeps its own title and slug; only its body is replaced. Ideal " +
- "for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
- inputSchema: {
- sourcePageId: z.string().min(1).describe("Page to copy content FROM"),
- targetPageId: z
- .string()
- .min(1)
- .describe("Page whose content is REPLACED (title/slug kept)"),
- },
- },
+registerShared(
+ SHARED_TOOL_SPECS.copyPageContent,
async ({ sourcePageId, targetPageId }) => {
const result = await docmostClient.copyPageContent(
sourcePageId,
@@ -453,50 +403,10 @@ server.registerTool(
);
// Tool: edit_page_text
-server.registerTool(
- "edit_page_text",
- {
- description:
- "Surgical find/replace inside a page's text. Preserves ALL structure: " +
- "block ids, marks, links, callouts, tables. A `find` MAY cross " +
- "bold/italic/link boundaries; the replacement inherits marks from the " +
- "unchanged common prefix/suffix (editing plain text next to a bold word " +
- "keeps it bold; editing inside a bold word keeps the new text bold). " +
- "Each `find` must match exactly once (or set replaceAll). The batch " +
- "applies what it can and returns applied[] + failed[]; a fully-unmatched " +
- "batch writes nothing and errors. `find` should be the literal rendered " +
- "text (no markdown). Markdown wrappers (**bold**, *italic*, `code`) and " +
- "trailing emoji are tolerated via a strip-and-retry fallback, but plain " +
- "text is preferred. Examples: edits:[{find:\"teh\"," +
- "replace:\"the\"}]; edits:[{find:\"Hello world\",replace:\"Hello there\"}] " +
- "(crosses a bold boundary). This is the preferred tool for fixing " +
- "wording, typos, numbers, names. It edits plain text only and CANNOT " +
- "change formatting marks: formatting changes (markdown markers in " +
- "find/replace) are refused — use patch_node/update_page_json to change " +
- "marks. The result includes a `verify` change-report of what actually " +
- "changed (text/block/mark deltas).",
- inputSchema: {
- pageId: z.string().describe("ID of the page to edit"),
- edits: z
- .array(
- z.object({
- find: z.string().describe("Exact text to find"),
- replace: z.string().describe("Replacement text (may be empty)"),
- replaceAll: z
- .boolean()
- .optional()
- .describe("Replace every occurrence (default: must match once)"),
- }),
- )
- .min(1)
- .describe("List of find/replace operations, applied in order"),
- },
- },
- async ({ pageId, edits }) => {
- const result = await docmostClient.editPageText(pageId, edits);
- return jsonContent(result);
- },
-);
+registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
+ const result = await docmostClient.editPageText(pageId, edits);
+ return jsonContent(result);
+});
// Tool: patch_node
server.registerTool(
@@ -579,22 +489,10 @@ server.registerTool(
);
// Tool: delete_node
-server.registerTool(
- "delete_node",
- {
- description:
- "Remove a single block by its attrs.id (from get_page_json) WITHOUT " +
- "resending the whole document.",
- inputSchema: {
- pageId: z.string().min(1),
- nodeId: z.string().min(1),
- },
- },
- async ({ pageId, nodeId }) => {
- const result = await docmostClient.deleteNode(pageId, nodeId);
- return jsonContent(result);
- },
-);
+registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
+ const result = await docmostClient.deleteNode(pageId, nodeId);
+ return jsonContent(result);
+});
// Tool: insert_image
server.registerTool(
@@ -705,32 +603,16 @@ server.registerTool(
);
// Tool: unshare_page
-server.registerTool(
- "unshare_page",
- {
- description: "Remove the public share of a page (revokes the public URL).",
- inputSchema: {
- pageId: z.string().min(1).describe("ID of the page to unshare"),
- },
- },
- async ({ pageId }) => {
- const result = await docmostClient.unsharePage(pageId);
- return jsonContent(result);
- },
-);
+registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
+ const result = await docmostClient.unsharePage(pageId);
+ return jsonContent(result);
+});
// Tool: list_shares
-server.registerTool(
- "list_shares",
- {
- description:
- "List all public shares in the workspace with page titles and public URLs.",
- },
- async () => {
- const result = await docmostClient.listShares();
- return jsonContent(result);
- },
-);
+registerShared(SHARED_TOOL_SPECS.listShares, async () => {
+ const result = await docmostClient.listShares();
+ return jsonContent(result);
+});
// Tool: move_page
server.registerTool(
@@ -1046,28 +928,8 @@ server.registerTool(
);
// Tool: diff_page_versions
-server.registerTool(
- "diff_page_versions",
- {
- description:
- "Diff two versions of a page and return a Docmost-equivalent change set " +
- "(inserted/deleted text, integrity counts for images/links/tables/" +
- "callouts/footnote markers, and a human-readable markdown summary). " +
- "`from`/`to` each accept a historyId, or null/'current' for the page's " +
- "current content (defaults: from=current, to=current — pass a historyId " +
- "from list_page_history to compare against the live page).",
- inputSchema: {
- pageId: z.string().min(1),
- from: z
- .string()
- .optional()
- .describe("historyId, or 'current'/omit for current content"),
- to: z
- .string()
- .optional()
- .describe("historyId, or 'current'/omit for current content"),
- },
- },
+registerShared(
+ SHARED_TOOL_SPECS.diffPageVersions,
async ({ pageId, from, to }) => {
const result = await docmostClient.diffPageVersions(pageId, from, to);
return jsonContent(result);
@@ -1075,22 +937,8 @@ server.registerTool(
);
// Tool: list_page_history
-server.registerTool(
- "list_page_history",
- {
- description:
- "List a page's saved versions (Docmost auto-snapshots on every save), " +
- "newest first, cursor-paginated. Returns { items, nextCursor }; each " +
- "item's id is the historyId to pass to diff_page_versions or " +
- "restore_page_version.",
- inputSchema: {
- pageId: z.string().min(1),
- cursor: z
- .string()
- .optional()
- .describe("Pagination cursor from a previous nextCursor"),
- },
- },
+registerShared(
+ SHARED_TOOL_SPECS.listPageHistory,
async ({ pageId, cursor }) => {
const result = await docmostClient.listPageHistory(pageId, cursor);
return jsonContent(result);
@@ -1098,18 +946,8 @@ server.registerTool(
);
// Tool: restore_page_version
-server.registerTool(
- "restore_page_version",
- {
- description:
- "Restore a page to a saved version: writes that version's content back " +
- "as the page's current content (Docmost has no restore endpoint, so " +
- "this creates a NEW history snapshot — the restore is itself revertible). " +
- "Get the historyId from list_page_history.",
- inputSchema: {
- historyId: z.string().min(1),
- },
- },
+registerShared(
+ SHARED_TOOL_SPECS.restorePageVersion,
async ({ historyId }) => {
const result = await docmostClient.restorePageVersion(historyId);
return jsonContent(result);
diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts
new file mode 100644
index 00000000..8f689c64
--- /dev/null
+++ b/packages/mcp/src/tool-specs.ts
@@ -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;
+}
+
+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 ' +
+ '`#` to fetch a top-level block by its outline index — use the ' +
+ '`#` 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;
diff --git a/packages/mcp/test/unit/tool-specs.test.mjs b/packages/mcp/test/unit/tool-specs.test.mjs
new file mode 100644
index 00000000..e98f18b6
--- /dev/null
+++ b/packages/mcp/test/unit/tool-specs.test.mjs
@@ -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`);
+ }
+});