Must-fix: - Move canonicalizeFootnotes OUT of parseProsemirrorContent. It now runs only on FULL writes (createPage, updatePageContent operation==='replace'), never on an append/prepend fragment (a fragment would lose definition-only footnotes or synthesize a bogus empty list). Add a server binding spec. - Match the live plugin's list PLACEMENT: a single already-canonical footnotesList is left exactly where it sits (the plugin never repositions a sole correct list), so the first write no longer reorders content that follows the list. Applied to BOTH the editor-ext copy and the MCP mirror; pinned by a shared golden corpus case with content after the list. - Fix MCP tool count 38 -> 39 (README x3, AGENTS.md) and the transformJs param help (add canonicalizeFootnotes/insertInlineFootnote). Simplifications: - Remove the dead duplicate re-id mechanism (deriveFootnoteId/suffix/occurrence) from the PURE canonicalizer in both copies — references are never renamed, so the derived ids were never requested; first-wins-drop is the real behaviour. This also makes the editor-ext footnote-util note about "no cross-package copy" true again. - Remove the sentinel round-trip in insertInlineFootnote: a generalized insertNodesAfterAnchor core inserts the footnoteReference node directly. - Drop the redundant per-definition deep clone in step 4 (shallow id-normalizing copy; out is already deep-cloned). Docs / architecture: - Correct the editor-ext copy's "It exists because…" header to its real consumers (server import, page.service create/update, client paste). - Note markdownToProseMirror reuse for create/update comment in collaboration.ts. - A: shared golden JSON corpus exercised by BOTH the editor-ext copy and the MCP mirror (footnote-corpus.ts / .mjs) so "the two copies behave identically" is checkable. - C: split the MCP canonicalizer into a pure mirror + footnote-authoring.ts. - B: import services persist via a different path, so left one-line consolidation comments at the call sites rather than folding (does not fall out cleanly). Tests: insertFootnote wrapper guards + docmost_transform dryRun auto-canonicalize (MCP mock), page.service create/update + append/prepend binding (server jest), shared corpus incl. nested-container reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1006 lines
36 KiB
TypeScript
1006 lines
36 KiB
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { z } from "zod";
|
|
import { readFileSync } from "fs";
|
|
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
|
|
// directly — for the credentials variant OR the per-user getToken variant.
|
|
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);
|
|
const packageJson = JSON.parse(
|
|
readFileSync(join(__dirname, "../package.json"), "utf-8"),
|
|
);
|
|
const VERSION = packageJson.version;
|
|
|
|
// Configuration for an MCP server instance is the DocmostMcpConfig union
|
|
// (credentials OR getToken) defined and re-exported above. The factory below is
|
|
// fully side-effect-free on import: it reads no environment variables and opens
|
|
// no transport. The standalone stdio entrypoint (stdio.ts) and the HTTP handler
|
|
// (http.ts) supply this config and own the process/transport lifecycle.
|
|
|
|
// --- Modern McpServer Implementation ---
|
|
|
|
// Editing guide surfaced to MCP clients in the initialize result so they can
|
|
// pick the right tool by intent and avoid resending whole documents.
|
|
const SERVER_INSTRUCTIONS =
|
|
"Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, delete_comment, check_new_comments. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
|
|
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
|
|
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
|
|
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
|
|
|
// Helper to format JSON responses
|
|
const jsonContent = (data: any) => ({
|
|
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
});
|
|
|
|
/**
|
|
* Create a fully configured Docmost MCP server. Side-effect-free: it does not
|
|
* read environment variables and does not connect any transport — the caller
|
|
* decides how to expose it (stdio or HTTP). The client talks to Docmost over
|
|
* REST + the collaboration WebSocket using the provided service-account
|
|
* credentials and auto-re-authenticates.
|
|
*/
|
|
export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
|
// Pass the whole config union through: the client branches internally on
|
|
// credentials vs. getToken, so both the external /mcp (creds) and the
|
|
// internal per-user (getToken) paths are wired here unchanged.
|
|
const docmostClient = new DocmostClient(config);
|
|
|
|
const server = new McpServer(
|
|
{
|
|
name: "docmost-mcp",
|
|
version: VERSION,
|
|
},
|
|
{ instructions: SERVER_INSTRUCTIONS },
|
|
);
|
|
|
|
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
|
|
// the canonical name + model-facing description + (optional) schema builder;
|
|
// only the execute body is supplied per call. buildShape is invoked with THIS
|
|
// package's zod (v3); the in-app layer passes its own zod (v4).
|
|
//
|
|
// The spec's schema builder returns a plain ZodRawShape (Record<string,
|
|
// unknown> in the shared module since it must stay zod-agnostic), so the
|
|
// McpServer.registerTool overloads cannot infer the execute arg's shape from
|
|
// it. We type `execute` loosely and cast the call through `any`; runtime
|
|
// behaviour is unchanged — each execute body destructures the same fields the
|
|
// builder declares.
|
|
const registerShared = (
|
|
spec: 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
|
|
registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
|
|
const workspace = await docmostClient.getWorkspace();
|
|
return jsonContent(workspace);
|
|
});
|
|
|
|
// Tool: list_spaces
|
|
registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
|
|
const spaces = await docmostClient.getSpaces();
|
|
return jsonContent(spaces);
|
|
});
|
|
|
|
// Tool: list_pages
|
|
server.registerTool(
|
|
"list_pages",
|
|
{
|
|
description:
|
|
"List most recent pages in a space ordered by updatedAt (descending). " +
|
|
"Returns a bounded list (default 50, max 100) — use search for lookups " +
|
|
"in large spaces. Pass tree:true (with spaceId) to instead get the " +
|
|
"space's full page hierarchy as a nested tree.",
|
|
inputSchema: {
|
|
spaceId: z.string().optional(),
|
|
limit: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.max(100)
|
|
.optional()
|
|
.describe("Max pages to return (default 50, max 100)"),
|
|
tree: z
|
|
.boolean()
|
|
.optional()
|
|
.describe(
|
|
"When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit.",
|
|
),
|
|
},
|
|
},
|
|
async ({ spaceId, limit, tree }) => {
|
|
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: get_page
|
|
server.registerTool(
|
|
"get_page",
|
|
{
|
|
description:
|
|
"Get page details with content converted to Markdown. The conversion is " +
|
|
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
|
"lossless representation use get_page_json.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
},
|
|
},
|
|
async ({ pageId }) => {
|
|
const page = await docmostClient.getPage(pageId);
|
|
return jsonContent(page);
|
|
},
|
|
);
|
|
|
|
// Tool: get_page_json
|
|
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
|
const page = await docmostClient.getPageJson(pageId);
|
|
return jsonContent(page);
|
|
});
|
|
|
|
// Tool: get_outline
|
|
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
|
|
const result = await docmostClient.getOutline(pageId);
|
|
return jsonContent(result);
|
|
});
|
|
|
|
// Tool: get_node
|
|
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
|
|
const result = await docmostClient.getNode(pageId, nodeId);
|
|
return jsonContent(result);
|
|
});
|
|
|
|
// Tool: table_get
|
|
server.registerTool(
|
|
"table_get",
|
|
{
|
|
description:
|
|
"Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
|
|
"cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
|
|
"get_outline, or any block id inside the table. Use cellIds with " +
|
|
"patch_node for rich-formatted cell edits. `cols` is the FIRST row's " +
|
|
"width; ragged tables may vary per row, so use the per-row length of " +
|
|
"`cells` for each row.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
table: z.string().min(1),
|
|
},
|
|
},
|
|
async ({ pageId, table }) => {
|
|
const result = await docmostClient.getTable(pageId, table);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: table_insert_row
|
|
server.registerTool(
|
|
"table_insert_row",
|
|
{
|
|
description:
|
|
"Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
|
|
"a block id inside it. `cells` = text per column (padded to the table's " +
|
|
"column count; error if more cells than columns). `index` = 0-based " +
|
|
"insert position (0 inserts before the header); omit to append at the end.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
table: z.string().min(1),
|
|
cells: z.array(z.string()),
|
|
index: z.number().int().optional(),
|
|
},
|
|
},
|
|
async ({ pageId, table, cells, index }) => {
|
|
const result = await docmostClient.tableInsertRow(
|
|
pageId,
|
|
table,
|
|
cells,
|
|
index,
|
|
);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: table_delete_row
|
|
server.registerTool(
|
|
"table_delete_row",
|
|
{
|
|
description:
|
|
"Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
|
|
"a block id inside it). Refuses to delete the table's only row. An " +
|
|
"out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
|
|
"and the next row becomes the new header.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
table: z.string().min(1),
|
|
index: z.number().int(),
|
|
},
|
|
},
|
|
async ({ pageId, table, index }) => {
|
|
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: table_update_cell
|
|
server.registerTool(
|
|
"table_update_cell",
|
|
{
|
|
description:
|
|
"Set the plain-text content of cell [row,col] (0-based) in a table " +
|
|
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
|
|
"content with a single text paragraph; for rich formatting use patch_node " +
|
|
"on the cell's paragraph id from table_get.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
table: z.string().min(1),
|
|
row: z.number().int(),
|
|
col: z.number().int(),
|
|
text: z.string(),
|
|
},
|
|
},
|
|
async ({ pageId, table, row, col, text }) => {
|
|
const result = await docmostClient.tableUpdateCell(
|
|
pageId,
|
|
table,
|
|
row,
|
|
col,
|
|
text,
|
|
);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: create_page
|
|
server.registerTool(
|
|
"create_page",
|
|
{
|
|
description:
|
|
"Create a new page with content (automatically moves it to the correct hierarchy).",
|
|
inputSchema: {
|
|
title: z.string().min(1).describe("Title of the page"),
|
|
content: z.string().min(1).describe("Markdown content"),
|
|
spaceId: z.string().min(1),
|
|
parentPageId: z
|
|
.string()
|
|
.optional()
|
|
.describe("Optional parent page ID to nest under"),
|
|
},
|
|
},
|
|
async ({ title, content, spaceId, parentPageId }) => {
|
|
const result = await docmostClient.createPage(
|
|
title,
|
|
content,
|
|
spaceId,
|
|
parentPageId,
|
|
);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: update_page_json
|
|
server.registerTool(
|
|
"update_page_json",
|
|
{
|
|
description:
|
|
"Replace a page's content with a raw ProseMirror JSON document " +
|
|
"(lossless write: preserves the block ids, callouts, tables and " +
|
|
"attributes you pass in). Typical flow: get_page_json -> modify the " +
|
|
"JSON -> update_page_json. Keep existing node ids intact so heading " +
|
|
"anchors and history stay stable. Minimal full-doc example: " +
|
|
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
|
'[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
|
|
"JSON string (both accepted), and is OPTIONAL: omit it to update only " +
|
|
"the title (though prefer rename_page for a title-only change). " +
|
|
"Supplying neither content nor title is an error.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1).describe("ID of the page to update"),
|
|
content: z
|
|
.any()
|
|
.optional()
|
|
.describe(
|
|
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
|
|
"JSON string). Omit to rename only.",
|
|
),
|
|
title: z.string().optional().describe("Optional new title"),
|
|
},
|
|
},
|
|
async ({ pageId, content, title }) => {
|
|
// Only parse/validate the document when it was actually supplied; when it
|
|
// is omitted, pass it straight through so the client performs a title-only
|
|
// (or no-op) update.
|
|
let doc;
|
|
if (content === undefined || content === null) {
|
|
doc = undefined;
|
|
} else {
|
|
// String -> JSON.parse (throwing on invalid); object passes through.
|
|
doc = parseNodeArg(content, "content was a string but not valid JSON");
|
|
}
|
|
const result = await docmostClient.updatePageJson(pageId, doc, title);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: export_page_markdown
|
|
server.registerTool(
|
|
"export_page_markdown",
|
|
{
|
|
description:
|
|
"Export a page to a single self-contained, lossless Docmost-flavoured " +
|
|
"Markdown file (custom extensions): YAML-free meta header, body with " +
|
|
"inline comment anchors and diagrams, and a trailing comments-thread " +
|
|
"block. Designed for a download -> edit body -> import_page_markdown " +
|
|
"round-trip that preserves everything, including comment highlights. " +
|
|
"Comment THREADS are preserved in the file but are not re-pushed to the " +
|
|
"server on import.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
},
|
|
},
|
|
async ({ pageId }) => {
|
|
const md = await docmostClient.exportPageMarkdown(pageId);
|
|
return { content: [{ type: "text" as const, text: md }] };
|
|
},
|
|
);
|
|
|
|
// Tool: import_page_markdown
|
|
registerShared(
|
|
SHARED_TOOL_SPECS.importPageMarkdown,
|
|
async ({ pageId, markdown }) => {
|
|
const res = await docmostClient.importPageMarkdown(pageId, markdown);
|
|
return jsonContent(res);
|
|
},
|
|
);
|
|
|
|
// Tool: copy_page_content
|
|
registerShared(
|
|
SHARED_TOOL_SPECS.copyPageContent,
|
|
async ({ sourcePageId, targetPageId }) => {
|
|
const result = await docmostClient.copyPageContent(
|
|
sourcePageId,
|
|
targetPageId,
|
|
);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: rename_page
|
|
server.registerTool(
|
|
"rename_page",
|
|
{
|
|
description:
|
|
"Rename a page (change its title only) without touching or resending " +
|
|
"its content.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1).describe("ID of the page to rename"),
|
|
title: z.string().min(1).describe("New title"),
|
|
},
|
|
},
|
|
async ({ pageId, title }) => {
|
|
const result = await docmostClient.renamePage(pageId, title);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: edit_page_text
|
|
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
|
const result = await docmostClient.editPageText(pageId, edits);
|
|
return jsonContent(result);
|
|
});
|
|
|
|
// Tool: patch_node
|
|
server.registerTool(
|
|
"patch_node",
|
|
{
|
|
description:
|
|
"Replaces a single block identified by its attrs.id WITHOUT resending the " +
|
|
"whole document. Get the block id from get_page_json, then pass a " +
|
|
"ProseMirror node to put in its place. Example node: a paragraph " +
|
|
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
|
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
|
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
|
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
|
"JSON object or a JSON string (both accepted). Cheaper and safer than " +
|
|
"update_page_json for one-block structural edits.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
nodeId: z.string().min(1),
|
|
node: z
|
|
.any()
|
|
.describe(
|
|
"ProseMirror node to put in place of the node with this id, e.g. " +
|
|
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
|
"JSON object or JSON string both accepted.",
|
|
),
|
|
},
|
|
},
|
|
async ({ pageId, nodeId, node }) => {
|
|
const parsedNode = parseNodeArg(node);
|
|
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: insert_node
|
|
server.registerTool(
|
|
"insert_node",
|
|
{
|
|
description:
|
|
"Insert a block before/after another block (by attrs.id or anchor text) " +
|
|
"or append at the end. Get anchor block ids from get_page_json. Avoids " +
|
|
"resending the whole document. Can also insert table structure: to add a " +
|
|
"tableRow, pass a tableRow node with position before/after and anchor " +
|
|
"INSIDE the target table — anchorNodeId of any block/cell in it, or " +
|
|
"anchorText matching the table; to add a tableCell/tableHeader, use " +
|
|
"anchorNodeId of a block inside the target row (anchorText only resolves " +
|
|
"top-level blocks, so it cannot target a row). `anchorText` is matched " +
|
|
"against the block's literal rendered plain text (no markdown); " +
|
|
"markdown/emoji are tolerated as a fallback; prefer plain text or " +
|
|
"anchorNodeId. Note: append is top-level " +
|
|
"only and rejects structural table nodes. Example node: a paragraph " +
|
|
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
|
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
|
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
|
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
|
"JSON object or a JSON string (both accepted).",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
node: z
|
|
.any()
|
|
.describe(
|
|
"ProseMirror node to insert, e.g. " +
|
|
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
|
"JSON object or JSON string both accepted.",
|
|
),
|
|
position: z.enum(["before", "after", "append"]),
|
|
anchorNodeId: z.string().optional(),
|
|
anchorText: z.string().optional(),
|
|
},
|
|
},
|
|
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
|
const parsedNode = parseNodeArg(node);
|
|
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
|
position,
|
|
anchorNodeId,
|
|
anchorText,
|
|
});
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: delete_node
|
|
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
|
const result = await docmostClient.deleteNode(pageId, nodeId);
|
|
return jsonContent(result);
|
|
});
|
|
|
|
// Tool: insert_image
|
|
server.registerTool(
|
|
"insert_image",
|
|
{
|
|
description:
|
|
"Download an image from a web (http/https) URL and insert it into " +
|
|
"a page in one step. By default " +
|
|
"appends the image at the end of the page. With replaceText, replaces the " +
|
|
"first top-level block whose text contains that string (handy for " +
|
|
'swapping a text placeholder like "[image: foo.png]" for the real image). ' +
|
|
"With afterText, inserts the image right after the first block containing " +
|
|
"that string. Preserves all other block ids.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
imageUrl: z
|
|
.string()
|
|
.min(1)
|
|
.describe("http(s) URL of the image to download and upload"),
|
|
align: z.enum(["left", "center", "right"]).optional(),
|
|
alt: z.string().optional(),
|
|
replaceText: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Replace the first top-level block whose text contains this string with the image",
|
|
),
|
|
afterText: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Insert the image right after the first top-level block whose text contains this string",
|
|
),
|
|
},
|
|
},
|
|
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
|
|
const result = await docmostClient.insertImage(pageId, imageUrl, {
|
|
align,
|
|
alt,
|
|
replaceText,
|
|
afterText,
|
|
});
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: replace_image
|
|
server.registerTool(
|
|
"replace_image",
|
|
{
|
|
description:
|
|
"Replace an existing image on a page with a new image fetched from a web " +
|
|
"(http/https) URL: uploads the new file as a NEW " +
|
|
"attachment (fresh clean URL that renders and busts browser caches), then " +
|
|
"repoints every image node referencing the old attachmentId (recursively, " +
|
|
"incl. callouts/tables) via the live document, preserving comments, " +
|
|
"alignment and alt. The old attachment is left as an unreferenced orphan " +
|
|
"(Docmost has no API to delete a single attachment; it is removed only when " +
|
|
"the page/space is deleted). In-place byte overwrite is avoided because some " +
|
|
"Docmost versions corrupt the attachment (HTTP 500) on overwrite.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
attachmentId: z
|
|
.string()
|
|
.min(1)
|
|
.describe("attachmentId of the image currently in the page to replace"),
|
|
imageUrl: z
|
|
.string()
|
|
.min(1)
|
|
.describe("http(s) URL of the new image to download"),
|
|
align: z.enum(["left", "center", "right"]).optional(),
|
|
alt: z.string().optional(),
|
|
},
|
|
},
|
|
async ({ pageId, attachmentId, imageUrl, align, alt }) => {
|
|
const result = await docmostClient.replaceImage(
|
|
pageId,
|
|
attachmentId,
|
|
imageUrl,
|
|
{
|
|
align,
|
|
alt,
|
|
},
|
|
);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: share_page
|
|
server.registerTool(
|
|
"share_page",
|
|
{
|
|
description:
|
|
"Make a page publicly accessible (idempotent) and return its public " +
|
|
"URL. The URL format is <app>/share/<key>/p/<slugId>.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1).describe("ID of the page to share"),
|
|
searchIndexing: z
|
|
.boolean()
|
|
.optional()
|
|
.describe("Allow search engines to index the page (default true)"),
|
|
},
|
|
},
|
|
async ({ pageId, searchIndexing }) => {
|
|
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: unshare_page
|
|
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
|
|
const result = await docmostClient.unsharePage(pageId);
|
|
return jsonContent(result);
|
|
});
|
|
|
|
// Tool: list_shares
|
|
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
|
const result = await docmostClient.listShares();
|
|
return jsonContent(result);
|
|
});
|
|
|
|
// Tool: move_page
|
|
server.registerTool(
|
|
"move_page",
|
|
{
|
|
description:
|
|
"Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'create_page'.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
parentPageId: z
|
|
.string()
|
|
.nullable()
|
|
.optional()
|
|
.describe(
|
|
"Target parent page ID. Pass 'null' or empty string to move to root.",
|
|
),
|
|
position: z
|
|
.string()
|
|
.min(5)
|
|
.optional()
|
|
.describe(
|
|
"fractional-index position key; min 5 chars; omit to append at the end.",
|
|
),
|
|
},
|
|
},
|
|
async ({ pageId, parentPageId, position }) => {
|
|
const finalParentId =
|
|
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
|
|
|
// Cheap cycle guard: a page cannot be moved directly under itself.
|
|
// (Deeper descendant-cycle detection is intentionally out of scope.)
|
|
if (finalParentId !== null && finalParentId === pageId) {
|
|
throw new Error("cannot move a page under itself");
|
|
}
|
|
|
|
const result = await docmostClient.movePage(
|
|
pageId,
|
|
finalParentId || null,
|
|
position,
|
|
);
|
|
|
|
// Require POSITIVE confirmation: the live /pages/move success shape is
|
|
// exactly { success: true, status: 200 }. An empty body, a 204, or any odd
|
|
// shape lacking success === true must NOT be reported as a successful move,
|
|
// so we surface the raw API result instead of declaring success.
|
|
if (!(result && typeof result === "object" && result.success === true)) {
|
|
throw new Error(
|
|
`Failed to move page ${pageId}: ${JSON.stringify(result)}`,
|
|
);
|
|
}
|
|
|
|
return jsonContent({
|
|
message: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
|
|
result,
|
|
});
|
|
},
|
|
);
|
|
|
|
// Tool: delete_page
|
|
server.registerTool(
|
|
"delete_page",
|
|
{
|
|
description: "Delete a single page by ID.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
},
|
|
},
|
|
async ({ pageId }) => {
|
|
await docmostClient.deletePage(pageId);
|
|
return {
|
|
content: [
|
|
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
|
|
],
|
|
};
|
|
},
|
|
);
|
|
|
|
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
|
|
|
// Tool: list_comments
|
|
server.registerTool(
|
|
"list_comments",
|
|
{
|
|
description:
|
|
"List all comments on a page (paginated). Content is returned as Markdown.",
|
|
inputSchema: {
|
|
pageId: z.string().describe("ID of the page"),
|
|
},
|
|
},
|
|
async ({ pageId }) => {
|
|
const comments = await docmostClient.listComments(pageId);
|
|
return jsonContent(comments);
|
|
},
|
|
);
|
|
|
|
// Tool: create_comment
|
|
server.registerTool(
|
|
"create_comment",
|
|
{
|
|
description:
|
|
"Create a new comment on a page. The comment is ALWAYS inline and is " +
|
|
"anchored to (highlights) its `selection` text — there are no page-level " +
|
|
"comments. Content is provided as Markdown and automatically converted. " +
|
|
"A top-level comment REQUIRES an exact `selection`; if the selection " +
|
|
"cannot be found in the page the call fails (no orphan comment is left). " +
|
|
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
|
|
"selection.",
|
|
inputSchema: {
|
|
pageId: z.string().describe("ID of the page to comment on"),
|
|
content: z.string().min(1).describe("Comment content in Markdown format"),
|
|
selection: z
|
|
.string()
|
|
.min(1)
|
|
// Enforce the documented 250-char cap to match the description above.
|
|
.max(250)
|
|
.optional()
|
|
.describe(
|
|
"EXACT contiguous text from a single paragraph/block to anchor the " +
|
|
"comment on (<=250 chars). Required for a top-level comment; omit " +
|
|
"only when replying via parentCommentId.",
|
|
),
|
|
parentCommentId: z
|
|
.string()
|
|
.optional()
|
|
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
|
|
},
|
|
},
|
|
async ({ pageId, content, selection, parentCommentId }) => {
|
|
if (!parentCommentId && (!selection || !selection.trim())) {
|
|
throw new Error(
|
|
"create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
|
|
);
|
|
}
|
|
const result = await docmostClient.createComment(
|
|
pageId,
|
|
content,
|
|
"inline",
|
|
selection,
|
|
parentCommentId,
|
|
);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: update_comment
|
|
server.registerTool(
|
|
"update_comment",
|
|
{
|
|
description:
|
|
"Update an existing comment's content. Only the comment creator can " +
|
|
"update it. Content is provided as Markdown.",
|
|
inputSchema: {
|
|
commentId: z.string().min(1).describe("ID of the comment to update"),
|
|
content: z
|
|
.string()
|
|
.min(1)
|
|
.describe("New comment content in Markdown format"),
|
|
},
|
|
},
|
|
async ({ commentId, content }) => {
|
|
const result = await docmostClient.updateComment(commentId, content);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: delete_comment
|
|
server.registerTool(
|
|
"delete_comment",
|
|
{
|
|
description:
|
|
"Delete a comment. Only the comment creator or space admin can delete it.",
|
|
inputSchema: {
|
|
commentId: z.string().min(1).describe("ID of the comment to delete"),
|
|
},
|
|
},
|
|
async ({ commentId }) => {
|
|
await docmostClient.deleteComment(commentId);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: `Successfully deleted comment ${commentId}`,
|
|
},
|
|
],
|
|
};
|
|
},
|
|
);
|
|
|
|
// Tool: check_new_comments
|
|
server.registerTool(
|
|
"check_new_comments",
|
|
{
|
|
description:
|
|
"Check for new comments across pages in a space since a given timestamp. " +
|
|
"Optionally scope to a page subtree (folder). Returns only comments " +
|
|
"created after the specified time.",
|
|
inputSchema: {
|
|
spaceId: z.string().describe("Space ID to check for new comments"),
|
|
since: z
|
|
.string()
|
|
.min(1)
|
|
.describe(
|
|
"ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')",
|
|
),
|
|
parentPageId: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Optional root page ID to scope the check to a subtree (folder). " +
|
|
"Only pages under this parent will be checked.",
|
|
),
|
|
},
|
|
},
|
|
async ({ spaceId, since, parentPageId }) => {
|
|
// Reject an unparseable timestamp up front: otherwise the comparison
|
|
// against NaN silently treats every comment as "not new" and the tool
|
|
// returns zero results without signalling the bad input.
|
|
if (Number.isNaN(Date.parse(since))) {
|
|
throw new Error(
|
|
`Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`,
|
|
);
|
|
}
|
|
const result = await docmostClient.checkNewComments(
|
|
spaceId,
|
|
since,
|
|
parentPageId,
|
|
);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: search
|
|
server.registerTool(
|
|
"search",
|
|
{
|
|
description:
|
|
"Search for pages and content. Results are bounded by `limit` " +
|
|
"(default applied by the client, max 100).",
|
|
inputSchema: {
|
|
query: z.string().min(1).describe("Search query"),
|
|
limit: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.max(100)
|
|
.optional()
|
|
.describe("Max results to return (max 100)"),
|
|
},
|
|
},
|
|
async ({ query, limit }) => {
|
|
// The tool exposes no spaceId filter, so pass undefined for the client's
|
|
// optional spaceId parameter and forward limit into its correct slot.
|
|
const result = await docmostClient.search(query, undefined, limit);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: docmost_transform
|
|
server.registerTool(
|
|
"docmost_transform",
|
|
{
|
|
description:
|
|
"Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
|
|
"against its LIVE ProseMirror document, with a diff preview and page " +
|
|
"history as the safety net. By default dryRun=true: returns a diff " +
|
|
"preview WITHOUT writing. Set dryRun=false to apply (atomic, won't " +
|
|
"clobber concurrent edits). `doc` is the lossless ProseMirror document " +
|
|
"({type:'doc',content:[...]}); return a new doc of the same shape. " +
|
|
"`ctx` gives you: comments (the page's comments, each {id, content " +
|
|
"(markdown), selection, type}); log (array; console.log pushes to it); " +
|
|
"consume(id) (mark a comment id as consumed — those are deleted when " +
|
|
"deleteComments=true after a successful apply); and helpers: " +
|
|
"blockText(node) (plain text), walk(node, fn) (depth-first over all " +
|
|
"nodes incl. callouts/tables/lists), getList(doc, predicate) (find a " +
|
|
"node even without attrs.id), insertMarkerAfter(doc, anchor, marker, " +
|
|
"{beforeBlock}) (insert a plain unmarked text run after anchor, " +
|
|
"mark-safe), setCalloutRange(doc, n) (sync a [1]…[K] callout range to " +
|
|
"[1]…[n]), noteItem(inlineNodes) (wrap inline nodes in a listItem with a " +
|
|
"fresh id), mdToInlineNodes(markdown) (comment markdown -> inline nodes), " +
|
|
"commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " +
|
|
"comments into numbered footnotes), canonicalizeFootnotes(doc) (derive " +
|
|
"footnote numbering + the single bottom list from reference order, drop " +
|
|
"orphans/duplicates — runs automatically after every transform too), and " +
|
|
"insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " +
|
|
"marker + dedup'd definition, list derived). Footnote convention: markers are " +
|
|
"plain '[N]' text in the body; the notes are an orderedList under a " +
|
|
"heading whose text is 'Примечания переводчика'. The transform runs " +
|
|
"sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
|
|
"{type:'doc'} node.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
transformJs: z
|
|
.string()
|
|
.min(1)
|
|
.describe(
|
|
"A JS function `(doc, ctx) => doc` (expression-arrow or " +
|
|
"parenthesized function). It receives a clone of the live doc and " +
|
|
"ctx (comments, log, consume(id), helpers: blockText/walk/getList/" +
|
|
"insertMarkerAfter/setCalloutRange/noteItem/mdToInlineNodes/" +
|
|
"commentsToFootnotes/canonicalizeFootnotes/insertInlineFootnote) " +
|
|
"and must return a {type:'doc'} node.",
|
|
),
|
|
dryRun: z
|
|
.boolean()
|
|
.optional()
|
|
.default(true)
|
|
.describe("Preview only (no write) when true (default)."),
|
|
deleteComments: z
|
|
.boolean()
|
|
.optional()
|
|
.default(false)
|
|
.describe(
|
|
"After a successful apply, delete every comment id passed to " +
|
|
"ctx.consume(id).",
|
|
),
|
|
},
|
|
},
|
|
async ({ pageId, transformJs, dryRun, deleteComments }) => {
|
|
const result = await docmostClient.transformPage(pageId, transformJs, {
|
|
dryRun,
|
|
deleteComments,
|
|
});
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: insert_footnote
|
|
server.registerTool(
|
|
"insert_footnote",
|
|
{
|
|
description:
|
|
"Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) " +
|
|
"and WHAT (text). The footnote marker is placed right after anchorText in " +
|
|
"the body, and the bottom footnotes list + the numbering are derived " +
|
|
"deterministically server-side. You do NOT assign a number, and you " +
|
|
"never see or edit the footnotes list — so footnotes cannot end up out " +
|
|
"of order, orphaned, or as a raw '[^id]' block. If a footnote with the " +
|
|
"SAME text already exists, its number is REUSED (one definition, several " +
|
|
"references). The write is atomic and won't clobber concurrent edits; if " +
|
|
"anchorText is not found, nothing is written and an error is returned.",
|
|
inputSchema: {
|
|
pageId: z.string().min(1),
|
|
anchorText: z
|
|
.string()
|
|
.min(1)
|
|
.describe(
|
|
"A snippet of existing body text; the footnote marker is inserted " +
|
|
"immediately after its first occurrence (mark-safe).",
|
|
),
|
|
text: z
|
|
.string()
|
|
.min(1)
|
|
.describe("The footnote content as markdown (becomes the definition)."),
|
|
},
|
|
},
|
|
async ({ pageId, anchorText, text }) => {
|
|
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: diff_page_versions
|
|
registerShared(
|
|
SHARED_TOOL_SPECS.diffPageVersions,
|
|
async ({ pageId, from, to }) => {
|
|
const result = await docmostClient.diffPageVersions(pageId, from, to);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: list_page_history
|
|
registerShared(
|
|
SHARED_TOOL_SPECS.listPageHistory,
|
|
async ({ pageId, cursor }) => {
|
|
const result = await docmostClient.listPageHistory(pageId, cursor);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
// Tool: restore_page_version
|
|
registerShared(
|
|
SHARED_TOOL_SPECS.restorePageVersion,
|
|
async ({ historyId }) => {
|
|
const result = await docmostClient.restorePageVersion(historyId);
|
|
return jsonContent(result);
|
|
},
|
|
);
|
|
|
|
return server;
|
|
}
|