diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8982c1ef..33436952 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,12 +36,12 @@ jobs: - name: Build editor-ext run: pnpm --filter @docmost/editor-ext build - # git-sync is no longer committed in built form (build/ is gitignored), so - # CI must compile it: the server suite imports the package via its built - # build/index.js. The server pretest also builds it, but building here keeps - # it explicit and independent of pnpm lifecycle ordering. - - name: Build git-sync - run: pnpm --filter @docmost/git-sync build + # git-sync and mcp are no longer committed in built form (build/ is + # gitignored), so CI must compile them: the server resolves both via their + # built build/index.js. The server pretest also builds them, but building + # here keeps it explicit and independent of pnpm lifecycle ordering. + - name: Build git-sync and mcp + run: pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build - name: Run tests run: pnpm -r test diff --git a/.gitignore b/.gitignore index a126d494..af487442 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ data # via `pnpm build`, never committed, so src/ and prod can never silently diverge). packages/*/node_modules/ packages/git-sync/build/ +packages/mcp/build/ # Logs logs diff --git a/apps/server/package.json b/apps/server/package.json index 3f107cab..e3b17826 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -23,7 +23,7 @@ "migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS", "migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/git-sync build", + "pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build", "test": "jest", "test:int": "jest --config test/jest-integration.json", "test:watch": "jest --watch", diff --git a/packages/mcp/build/client.js b/packages/mcp/build/client.js deleted file mode 100644 index a825dd03..00000000 --- a/packages/mcp/build/client.js +++ /dev/null @@ -1,2474 +0,0 @@ -import FormData from "form-data"; -import axios from "axios"; -import { basename, extname } from "path"; -import { filterWorkspace, filterSpace, filterPage, filterComment, filterSearchResult, } from "./lib/filters.js"; -import { HocuspocusProvider } from "@hocuspocus/provider"; -import { TiptapTransformer } from "@hocuspocus/transformer"; -import * as Y from "yjs"; -import WebSocket from "ws"; -import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js"; -import { updatePageContentRealtime, replacePageContent, markdownToProseMirror, mutatePageContent, buildCollabWsUrl, assertYjsEncodable, } from "./lib/collaboration.js"; -import { docmostExtensions } from "./lib/docmost-schema.js"; -import { buildPageTree } from "./lib/tree.js"; -import { serializeDocmostMarkdown, parseDocmostMarkdown, } from "./lib/markdown-document.js"; -import { replaceNodeById, deleteNodeById, insertNodeRelative, buildOutline, getNodeByRef, readTable, insertTableRow, deleteTableRow, updateTableCell, } from "./lib/node-ops.js"; -import { withPageLock } from "./lib/page-lock.js"; -import { applyTextEdits, } from "./lib/json-edit.js"; -import { getCollabToken, performLogin } from "./lib/auth-utils.js"; -import { diffDocs, summarizeChange } from "./lib/diff.js"; -import { applyAnchorInDoc, canAnchorInDoc, } from "./lib/comment-anchor.js"; -import { blockText, walk, getList, insertMarkerAfter, setCalloutRange, noteItem, mdToInlineNodes, commentsToFootnotes, } from "./lib/transforms.js"; -import vm from "node:vm"; -// Supported image types, kept as two lookup tables so both a local file -// extension and a remote Content-Type can be mapped to the same canonical set. -const EXT_TO_MIME = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", -}; -const MIME_TO_EXT = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/gif": ".gif", - "image/webp": ".webp", - "image/svg+xml": ".svg", -}; -export class DocmostClient { - client; - token = null; - apiUrl; - // email/password are only set on the service-account (credentials) variant; - // null on the getToken variant (where there are no credentials to log in with). - email = null; - password = null; - // Per-user token provider. When set, login() calls it to obtain a BARE access - // JWT instead of performLogin, and the 401/403 re-auth path re-calls it. - getTokenFn = null; - // Optional collab-token provider. When set, getCollabTokenWithReauth() returns - // its token instead of calling POST /auth/collab-token; on a 401/403 it is - // re-invoked once. Used by the internal agent to carry signed provenance. - getCollabTokenFn = null; - // In-flight login dedup: when the token expires, the 401 interceptor, - // ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries - // can all call login() at once. Memoizing a single promise collapses that - // thundering herd into ONE /auth/login request that everyone awaits. - loginPromise = null; - constructor(configOrBaseURL, email, password) { - // Normalize the legacy positional form into the object union. - const config = typeof configOrBaseURL === "string" - ? { apiUrl: configOrBaseURL, email: email, password: password } - : configOrBaseURL; - this.apiUrl = config.apiUrl; - if ("getToken" in config) { - // Token variant: carry the user's JWT via getToken; no credentials, so - // login() must never call performLogin (there is nothing to log in with). - this.getTokenFn = config.getToken; - } - else { - // Service-account variant: behaves exactly as before (performLogin). - this.email = config.email; - this.password = config.password; - } - // Optional, available to both variants. When present, content mutations get - // their collab token from here instead of POST /auth/collab-token. - if (config.getCollabToken) { - this.getCollabTokenFn = config.getCollabToken; - } - this.client = axios.create({ - baseURL: this.apiUrl, - // Default request timeout so a hung connection cannot wedge a per-page - // lock or block the server indefinitely. Multipart uploads override this - // with a longer per-request timeout. - timeout: 30000, - headers: { - "Content-Type": "application/json", - }, - }); - // Re-authenticate transparently on a 401/403 once: the JWT authToken can - // expire while the server is long-running, after which every cached-token - // request would otherwise fail until a manual restart. On such a response, - // clear the stale token, perform a fresh login, and replay the original - // request exactly once (guarded by config._retry to avoid infinite loops; - // the login request itself is never retried). - this.client.interceptors.response.use((response) => response, async (error) => { - const config = error.config; - const status = error.response?.status; - const isAuthError = status === 401 || status === 403; - const isLoginRequest = typeof config?.url === "string" && config.url.includes("/auth/login"); - if (config && isAuthError && !config._retry && !isLoginRequest) { - config._retry = true; - // Drop the stale token + Authorization header before re-login. - this.token = null; - delete this.client.defaults.headers.common["Authorization"]; - try { - await this.login(); - } - catch (loginError) { - // Re-login failed: surface the original error to the caller. - return Promise.reject(error); - } - // Re-issue the original request with the freshly minted Bearer token. - // Read it from the default header that login() just set, not from - // this.token, to avoid a theoretical "Bearer null" if this.token was - // cleared between login() resolving and this point. - config.headers = config.headers || {}; - config.headers["Authorization"] = - this.client.defaults.headers.common["Authorization"]; - return this.client.request(config); - } - return Promise.reject(error); - }); - } - /** Application base URL (API URL without the /api suffix). */ - get appUrl() { - return this.apiUrl.replace(/\/api\/?$/, ""); - } - async login() { - // Reuse an in-flight login if one is already running so concurrent callers - // share a single token fetch instead of each issuing their own. - if (!this.loginPromise) { - // Token variant: re-fetch a BARE JWT via getToken() (there are no - // credentials to log in with — on a 401/403 the interceptor below calls - // login() again, which re-invokes getToken()). Credentials variant: - // performLogin against /auth/login exactly as before. - const fetchToken = this.getTokenFn - ? this.getTokenFn() - : performLogin(this.apiUrl, this.email, this.password); - this.loginPromise = fetchToken - .then((token) => { - // Guard against an empty/invalid token (e.g. a getToken provider that - // resolves to "" or null): without this an empty token would set a - // literal "Authorization: Bearer null"/"Bearer " header and every - // request would 401 with a confusing error. Fail loudly instead. - if (typeof token !== "string" || token.length === 0) { - throw new Error("getToken returned an empty token"); - } - this.token = token; - this.client.defaults.headers.common["Authorization"] = - `Bearer ${token}`; - }) - .finally(() => { - this.loginPromise = null; - }); - } - return this.loginPromise; - } - async ensureAuthenticated() { - if (!this.token) { - await this.login(); - } - } - /** - * Fetch a collaboration token, transparently re-authenticating once on a - * 401/403. getCollabToken() uses bare axios internally, so it is NOT covered - * by this.client's response interceptor; this helper replicates that - * behaviour for collab-token requests: ensure a token, try once, and on an - * expired-token auth error perform a fresh login and retry exactly once. - */ - async getCollabTokenWithReauth() { - // Collab-token PROVIDER path: when a getCollabToken provider was supplied - // (the internal agent's provenance collab token), use it instead of the - // REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the - // signed token expired between content mutations in a long agent turn). - if (this.getCollabTokenFn) { - try { - const token = await this.getCollabTokenFn(); - if (typeof token !== "string" || token.length === 0) { - throw new Error("getCollabToken returned an empty token"); - } - return token; - } - catch (e) { - const axiosStatus = axios.isAxiosError(e) - ? e.response?.status - : undefined; - const attachedStatus = e?.status; - const isAuthError = axiosStatus === 401 || - axiosStatus === 403 || - attachedStatus === 401 || - attachedStatus === 403; - if (isAuthError) { - const token = await this.getCollabTokenFn(); - if (typeof token !== "string" || token.length === 0) { - throw new Error("getCollabToken returned an empty token"); - } - return token; - } - throw e; - } - } - await this.ensureAuthenticated(); - try { - return await getCollabToken(this.apiUrl, this.token); - } - catch (e) { - // getCollabToken wraps the AxiosError in a plain Error but attaches the - // HTTP status as `.status`, so detect an auth failure via either the raw - // AxiosError shape OR the attached status. - const axiosStatus = axios.isAxiosError(e) ? e.response?.status : undefined; - const attachedStatus = e?.status; - const isAuthError = axiosStatus === 401 || - axiosStatus === 403 || - attachedStatus === 401 || - attachedStatus === 403; - if (isAuthError) { - await this.login(); - return await getCollabToken(this.apiUrl, this.token); - } - throw e; - } - } - /** - * Connect to the collaboration websocket, read the live doc, apply - * `transform`, write the result, and wait for the server to persist it — - * WITHOUT acquiring the per-page lock. - * - * This mirrors collaboration.mutatePageContent EXCEPT that it does not call - * withPageLock. It exists solely so replaceImage can hold ONE withPageLock - * across its scan -> upload -> write sequence: the per-page mutex is NOT - * reentrant, so calling the normal (self-locking) mutatePageContent inside an - * outer withPageLock for the same pageId would deadlock. The caller MUST hold - * the page lock for the whole operation; this helper assumes that invariant. - * - * `transform` receives the live ProseMirror doc and returns the NEW full doc - * to write, or `null` to abort with no write. Errors thrown by `transform` - * propagate to the caller. - * - * Resolves a `MutationResult { doc, verify }` mirroring mutatePageContent, so - * every content mutator (including replaceImage) can return a verifiable - * change report. The report is computed AFTER the atomic read->write and - * never throws. - */ - mutateLiveContentUnlocked(pageId, collabToken, transform) { - const CONNECT_TIMEOUT_MS = 25000; - const PERSIST_TIMEOUT_MS = 20000; - const ydoc = new Y.Doc(); - const wsUrl = buildCollabWsUrl(this.apiUrl); - return new Promise((resolve, reject) => { - let provider; - let applied = false; // onSynced may fire again on reconnect — apply once. - let settled = false; - let connectionLost = false; - let connectTimer; - let persistTimer; - let unsyncedHandler; - // The verifiable result resolved on every success/abort path. Set on abort - // (no-op report) and after a real write (computed change report). - let mutationResult; - const cleanup = () => { - if (connectTimer) - clearTimeout(connectTimer); - if (persistTimer) - clearTimeout(persistTimer); - if (provider) { - if (unsyncedHandler) { - try { - provider.off("unsyncedChanges", unsyncedHandler); - } - catch (err) { } - } - try { - provider.destroy(); - } - catch (err) { } - } - }; - const finish = (err, value) => { - if (settled) - return; - settled = true; - cleanup(); - if (err) - reject(err); - else - resolve(value); - }; - connectTimer = setTimeout(() => { - finish(new Error("Connection timeout to collaboration server")); - }, CONNECT_TIMEOUT_MS); - const waitForPersistence = () => { - if (settled) - return; - if (!provider) { - finish(new Error("collab provider gone before persistence")); - return; - } - if (provider.unsyncedChanges === 0) { - finish(null, mutationResult); - return; - } - persistTimer = setTimeout(() => { - finish(new Error("Timeout waiting for collaboration server to persist the update")); - }, PERSIST_TIMEOUT_MS); - unsyncedHandler = (data) => { - if (data.number === 0 && !connectionLost) { - finish(null, mutationResult); - } - }; - provider.on("unsyncedChanges", unsyncedHandler); - }; - provider = new HocuspocusProvider({ - url: wsUrl, - name: `page.${pageId}`, - document: ydoc, - token: collabToken, - // @ts-ignore - Required for Node.js environment - WebSocketPolyfill: WebSocket, - onDisconnect: () => { - connectionLost = true; - finish(new Error("Collaboration connection closed before the update was persisted/synced")); - }, - onClose: () => { - connectionLost = true; - finish(new Error("Collaboration connection closed before the update was persisted/synced")); - }, - onSynced: () => { - if (applied || settled) - return; - applied = true; - // CRITICAL: keep everything between reading and writing the live doc - // synchronous (no await) so no remote update can interleave. - let newDoc; - let beforeDoc; - try { - let liveDoc = TiptapTransformer.fromYdoc(ydoc, "default"); - if (!liveDoc || - typeof liveDoc !== "object" || - !Array.isArray(liveDoc.content)) { - liveDoc = { type: "doc", content: [] }; - } - // Snapshot the before-doc for the change report (safe deep clone). - beforeDoc = JSON.parse(JSON.stringify(liveDoc)); - newDoc = transform(liveDoc); - if (newDoc == null) { - // Transform aborted — write nothing, return the live doc with a - // no-op change report. - mutationResult = { - doc: liveDoc, - verify: { - changed: false, - textInserted: 0, - textDeleted: 0, - blocksChanged: 0, - marks: {}, - summary: "no changes (transform aborted)", - }, - }; - finish(null, mutationResult); - return; - } - const tempDoc = TiptapTransformer.toYdoc(newDoc, "default", docmostExtensions); - const fragment = ydoc.getXmlFragment("default"); - ydoc.transact(() => { - if (fragment.length > 0) { - fragment.delete(0, fragment.length); - } - Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(tempDoc)); - }); - } - catch (e) { - finish(e instanceof Error ? e : new Error(String(e))); - return; - } - // Compute the verifiable change report AFTER the transact write: it - // only needs the JSON before/after, so it cannot affect the atomic - // read->write window, and summarizeChange never throws. - mutationResult = { - doc: newDoc, - verify: summarizeChange(beforeDoc, newDoc), - }; - waitForPersistence(); - }, - onAuthenticationFailed: () => { - finish(new Error("Authentication failed for collaboration connection")); - }, - }); - }); - } - /** - * Generic pagination handler for Docmost API endpoints - */ - async paginateAll(endpoint, basePayload = {}, limit = 100) { - await this.ensureAuthenticated(); - const clampedLimit = Math.max(1, Math.min(100, limit)); - // Hard ceiling on the number of pages to fetch: guards against a server - // that returns a perpetually-true hasNextPage (which would otherwise loop - // forever and accumulate duplicates). - const MAX_PAGES = 50; - let page = 1; - let allItems = []; - let hasNextPage = true; - while (hasNextPage && page <= MAX_PAGES) { - const response = await this.client.post(endpoint, { - ...basePayload, - limit: clampedLimit, - page, - }); - const data = response.data; - const items = data.data?.items || data.items || []; - const meta = data.data?.meta || data.meta; - allItems = allItems.concat(items); - // Stop if the page is empty or shorter than the requested size: a full - // page worth of items is the only situation where another page can exist, - // so this defends against a stuck hasNextPage flag in addition to it. - if (items.length === 0 || items.length < clampedLimit) { - break; - } - hasNextPage = meta?.hasNextPage || false; - page++; - } - // If the loop stopped because it hit the MAX_PAGES ceiling while the server - // still reported more results (hasNextPage true and the last page was - // full), the result set is truncated — warn so the caller is not silently - // handed an incomplete list. - if (hasNextPage && page > MAX_PAGES) { - console.warn(`paginateAll: results from "${endpoint}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`); - } - return allItems; - } - async getWorkspace() { - await this.ensureAuthenticated(); - const response = await this.client.post("/workspace/info", {}); - return { - data: filterWorkspace(response.data?.data ?? response.data), - success: response.data.success, - }; - } - async getSpaces() { - const spaces = await this.paginateAll("/spaces", {}); - return spaces.map((space) => filterSpace(space)); - } - /** - * List pages in one of two modes. - * - * Default (`tree` false): most recent pages by updatedAt (descending), - * bounded. Fetching the whole space can exceed MCP response/time limits on - * large instances, so a single bounded page of results is returned (default - * 50, max 100) via the `/pages/recent` feed. - * - * Tree (`tree` true): the space's FULL page hierarchy as a nested tree (each - * node has a `children` array). This mode REQUIRES `spaceId` (a page tree is - * scoped to one space) and IGNORES `limit` — the whole hierarchy is returned. - * It walks the sidebar tree via `enumerateSpacePages`, which performs N - * sidebar requests and is bounded by that method's 10000-node cap (and skips - * soft-deleted pages server-side). - */ - async listPages(spaceId, limit = 50, tree = false) { - await this.ensureAuthenticated(); - if (tree) { - if (!spaceId) { - throw new Error("list_pages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list."); - } - const nodes = await this.enumerateSpacePages(spaceId); - return buildPageTree(nodes); - } - const clampedLimit = Math.max(1, Math.min(100, limit)); - const payload = { limit: clampedLimit, page: 1 }; - if (spaceId) - payload.spaceId = spaceId; - const response = await this.client.post("/pages/recent", payload); - const data = response.data; - const items = data.data?.items || data.items || []; - return items.map((page) => filterPage(page)); - } - /** - * List sidebar pages for a space. With no pageId the request returns the - * space ROOT pages; with a pageId it returns the direct CHILDREN of that - * page. pageId is therefore optional and is only included in the POST body - * when provided (an empty/undefined pageId would otherwise change the - * semantics on the server). - */ - async listSidebarPages(spaceId, pageId) { - await this.ensureAuthenticated(); - // Paginate: the endpoint returns server-paged children, so posting only - // { page: 1 } silently dropped every child beyond the first page. Loop on - // meta.hasNextPage (with a MAX_PAGES ceiling like paginateAll, guarding - // against a stuck hasNextPage flag) and accumulate all children. - const MAX_PAGES = 50; - let page = 1; - let allItems = []; - let hasNextPage = true; - while (hasNextPage && page <= MAX_PAGES) { - // Only send pageId when scoping to a page's children; omit it for roots. - const payload = { spaceId, page }; - if (pageId) - payload.pageId = pageId; - const response = await this.client.post("/pages/sidebar-pages", payload); - const data = response.data?.data ?? response.data; - const items = data?.items || []; - allItems = allItems.concat(items); - hasNextPage = data?.meta?.hasNextPage || false; - page++; - } - return allItems; - } - /** - * Enumerate EVERY page in a space (or in a subtree, when rootPageId is given) - * by walking the sidebar-pages tree. - * - * Starting set: the children of rootPageId when provided, otherwise the - * space root pages. From there it does an iterative breadth-first walk: each - * node is collected, and when node.hasChildren is true its direct children - * are fetched via listSidebarPages(spaceId, node.id) and enqueued. - * - * This replaces the old "/pages/recent" enumeration, which is a bounded - * recent-activity feed (~5000 cap) and therefore misses comments on older - * pages that were never recently touched. - * - * Safeguards: a `visited` Set of page ids prevents re-processing a node - * (cycles / duplicate references), and a hard node cap bounds pathological - * trees so the walk always terminates. - */ - async enumerateSpacePages(spaceId, rootPageId) { - const MAX_NODES = 10000; - const result = []; - const visited = new Set(); - // Seed the queue with the starting level (subtree children or roots). - const queue = await this.listSidebarPages(spaceId, rootPageId); - while (queue.length > 0 && result.length < MAX_NODES) { - const node = queue.shift(); - if (!node || typeof node !== "object" || !node.id) - continue; - // Skip already-seen ids to guard against cycles / duplicate references. - if (visited.has(node.id)) - continue; - visited.add(node.id); - result.push(node); - if (node.hasChildren) { - try { - const children = await this.listSidebarPages(spaceId, node.id); - for (const child of children) - queue.push(child); - } - catch (e) { - // A failure fetching one node's children must not abort the whole - // walk: skip this branch and keep enumerating the rest. - } - } - } - return result; - } - /** Raw page info including the ProseMirror JSON content and slugId. */ - async getPageRaw(pageId) { - await this.ensureAuthenticated(); - const response = await this.client.post("/pages/info", { pageId }); - return response.data?.data ?? response.data; - } - async getPage(pageId) { - await this.ensureAuthenticated(); - const resultData = await this.getPageRaw(pageId); - let content = resultData.content - ? convertProseMirrorToMarkdown(resultData.content) - : ""; - // Always fetch subpages to provide context to the agent - let subpages = []; - try { - subpages = await this.listSidebarPages(resultData.spaceId, pageId); - } - catch (e) { - console.warn("Failed to fetch subpages:", e); - } - // Resolve subpages if the placeholder exists - if (content && content.includes("{{SUBPAGES}}")) { - if (subpages && subpages.length > 0) { - const list = subpages - .map((p) => `- [${p.title}](page:${p.id})`) - .join("\n"); - content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`); - } - else { - content = content.replace("{{SUBPAGES}}", ""); - } - } - return { - data: filterPage(resultData, content, subpages), - success: true, - }; - } - /** Page info + raw ProseMirror JSON content (lossless representation). */ - async getPageJson(pageId) { - const data = await this.getPageRaw(pageId); - return { - id: data.id, - slugId: data.slugId, - title: data.title, - parentPageId: data.parentPageId, - spaceId: data.spaceId, - updatedAt: data.updatedAt, - content: data.content || { type: "doc", content: [] }, - }; - } - /** - * Compact outline of a page's top-level blocks (no full document body). - * Cheap way to locate sections/tables and grab block ids before drilling in - * with get_node / patch_node / insert_node. - */ - async getOutline(pageId) { - await this.ensureAuthenticated(); - const data = await this.getPageRaw(pageId); - return { - pageId, - slugId: data.slugId, - title: data.title, - outline: buildOutline(data.content ?? { type: "doc", content: [] }), - }; - } - /** - * Fetch a single node's full ProseMirror subtree (lossless) by reference: - * a block id (headings/paragraphs/callouts/images), or `#` to select - * a top-level block by its outline index (the only way to reach tables/rows/ - * cells, which carry no id). - */ - async getNode(pageId, nodeId) { - await this.ensureAuthenticated(); - const data = await this.getPageRaw(pageId); - const hit = getNodeByRef(data.content ?? { type: "doc", content: [] }, nodeId); - if (!hit) { - throw new Error(`get_node: no node found for "${nodeId}" on page ${pageId} (use a block id from get_outline, or "#" for a top-level block such as a table)`); - } - return { - pageId, - ref: nodeId, - path: hit.path, - type: hit.type, - node: hit.node, - }; - } - /** - * Read a table as a matrix. `tableRef` is `#` (from get_outline) or a - * block id of any node inside the table. Returns the cell texts plus a - * parallel cellIds matrix (each cell's first paragraph id, or null) so a - * caller can patch_node a cell for rich-formatted edits. Throws when no table - * resolves for the reference. - */ - async getTable(pageId, tableRef) { - await this.ensureAuthenticated(); - const data = await this.getPageRaw(pageId); - const t = readTable(data.content ?? { type: "doc", content: [] }, tableRef); - if (!t) { - throw new Error(`table_get: no table found for "${tableRef}" on page ${pageId} (use "#" from get_outline, or a block id inside the table)`); - } - return { - pageId, - table: tableRef, - rows: t.rows, - cols: t.cols, - path: t.path, - cells: t.cells, - cellIds: t.cellIds, - }; - } - /** - * Insert a row of plain-text cells into a table on the LIVE collab document. - * `tableRef` is `#` or a block id inside the target table. `cells` is - * padded to the table's column count (more cells than columns throws); `index` - * is a 0-based insert position (omit/out-of-range to append). Throws when no - * table resolves for the reference. - */ - async tableInsertRow(pageId, tableRef, cells, index) { - await this.ensureAuthenticated(); - const collabToken = await this.getCollabTokenWithReauth(); - // Track insertion in an outer var, reset per-transform, so a collab retry - // recomputes it cleanly (mirrors insertNode's pattern). - let inserted = false; - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { - inserted = false; - const { doc: nd, inserted: ins } = insertTableRow(liveDoc, tableRef, cells, index); - inserted = ins; - if (!inserted) - return null; // table not found -> skip the write entirely - return nd; - }); - if (!inserted) { - throw new Error(`table_insert_row: no table found for "${tableRef}" on page ${pageId} (use "#" from get_outline, or a block id inside the table)`); - } - return { success: true, table: tableRef, inserted: true, verify: mutation.verify }; - } - /** - * Delete the row at 0-based `index` from a table on the LIVE collab document. - * `tableRef` is `#` or a block id inside the target table. The helper's - * out-of-range and last-row errors propagate; a missing table throws here. - */ - async tableDeleteRow(pageId, tableRef, index) { - await this.ensureAuthenticated(); - const collabToken = await this.getCollabTokenWithReauth(); - let deleted = false; - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { - deleted = false; - const { doc: nd, deleted: del } = deleteTableRow(liveDoc, tableRef, index); - deleted = del; - if (!deleted) - return null; // table not found -> skip the write entirely - return nd; - }); - if (!deleted) { - throw new Error(`table_delete_row: no table found for "${tableRef}" on page ${pageId} (use "#" from get_outline, or a block id inside the table)`); - } - return { success: true, table: tableRef, deleted: true, verify: mutation.verify }; - } - /** - * Set the plain-text content of cell `[row, col]` (0-based) in a table on the - * LIVE collab document, replacing the cell's content with a single text - * paragraph (the cell's first-paragraph id is preserved). `tableRef` is - * `#` or a block id inside the target table. The helper's out-of-range - * error propagates; a missing table throws here. - */ - async tableUpdateCell(pageId, tableRef, row, col, text) { - await this.ensureAuthenticated(); - const collabToken = await this.getCollabTokenWithReauth(); - let updated = false; - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { - updated = false; - const { doc: nd, updated: upd } = updateTableCell(liveDoc, tableRef, row, col, text); - updated = upd; - if (!updated) - return null; // table not found -> skip the write entirely - return nd; - }); - if (!updated) { - throw new Error(`table_update_cell: no table found for "${tableRef}" on page ${pageId} (use "#" from get_outline, or a block id inside the table)`); - } - return { success: true, table: tableRef, row, col, verify: mutation.verify }; - } - /** - * Create a new page with title and content. - * Uses the /pages/import workaround (the only endpoint accepting content), - * then moves the page and restores the exact title: the import endpoint - * derives the title from the FILENAME and replaces spaces with - * underscores, so we explicitly re-set it via /pages/update afterwards. - */ - async createPage(title, content, spaceId, parentPageId) { - await this.ensureAuthenticated(); - if (parentPageId) { - try { - await this.getPage(parentPageId); - } - catch (e) { - throw new Error(`Parent page with ID ${parentPageId} not found.`); - } - } - // 1. Create content via Import (using multipart/form-data). - // Build a FRESH FormData per send attempt: a FormData body is a single-use - // stream consumed on the first send, so it cannot be replayed by - // this.client's response interceptor (replay fails with 'socket hang up'). - // Multipart re-auth is therefore done here with bare axios and an explicit - // one-shot 401/403 retry that rebuilds the body. - const fileContent = Buffer.from(content, "utf-8"); - const buildForm = () => { - const form = new FormData(); - form.append("spaceId", spaceId); - form.append("file", fileContent, { - filename: `${title || "import"}.md`, - contentType: "text/markdown", - }); - return form; - }; - const importUrl = `${this.apiUrl}/pages/import`; - let response; - try { - // Call buildForm() ONCE per attempt and reuse the instance for both - // getHeaders() and the body so the Content-Type boundary matches the body. - const form = buildForm(); - // Read the Authorization header from this.client's defaults (set by - // login(), only ever deleted — never set to null) instead of building - // `Bearer ${this.token}`: a concurrent JSON 401 can null this.token - // mid-flight, which would otherwise produce a literal "Bearer null". - // ensureAuthenticated() above guarantees login() ran, so the default - // header exists here. - response = await axios.post(importUrl, form, { - headers: { - ...form.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } - catch (error) { - // On an expired-token auth error, re-login and retry exactly once with a - // freshly-rebuilt FormData (the previous one was already consumed). - if (axios.isAxiosError(error) && - (error.response?.status === 401 || error.response?.status === 403)) { - await this.login(); - const form2 = buildForm(); - response = await axios.post(importUrl, form2, { - headers: { - ...form2.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } - else { - throw error; - } - } - const newPageId = (response.data?.data ?? response.data).id; - // 2. Move to parent if needed - if (parentPageId) { - await this.movePage(newPageId, parentPageId); - } - // 3. Restore the exact title (import mangles spaces into underscores) - if (title) { - await this.client.post("/pages/update", { pageId: newPageId, title }); - } - return this.getPage(newPageId); - } - /** - * Update a page's content from markdown and optionally its title. - * NOTE: full re-import — block ids regenerate. For surgical changes - * use editPageText / updatePageJson instead. - */ - async updatePage(pageId, content, title) { - await this.ensureAuthenticated(); - if (title) { - await this.client.post("/pages/update", { pageId, title }); - } - let collabToken = ""; - let mutation; - try { - collabToken = await this.getCollabTokenWithReauth(); - mutation = await updatePageContentRealtime(pageId, content, collabToken, this.apiUrl); - } - catch (error) { - // Verbose diagnostics (incl. anything that could expose a token prefix) - // are gated behind DEBUG; the thrown Error below carries no token data. - if (process.env.DEBUG) { - console.error("Failed to update page content via realtime collaboration:", error); - const tokenPreview = collabToken - ? collabToken.substring(0, 15) + "..." - : "null"; - console.error(`Collab token preview: ${tokenPreview}`); - } - throw new Error(`Failed to update page content: ${error.message}`); - } - return { - success: true, - modified: true, - message: "Page updated successfully.", - pageId: pageId, - verify: mutation.verify, - }; - } - /** - * Validate a URL string against a scheme allowlist for a given context. - * - * The markdown link path enforces safe schemes via TipTap, but the raw - * JSON path (updatePageJson) bypasses that — so this is the sanitization - * choke point for ProseMirror JSON written directly by the caller. - * - * - "link": reject javascript:, vbscript:, data: (any scheme that can - * execute or smuggle script when the href is clicked). - * - "src": allow only http(s):, mailto:, /api/files paths, or a - * scheme-less relative/absolute path; reject - * javascript:/vbscript:/data:/file:. - */ - isSafeUrl(url, context) { - if (typeof url !== "string") - return false; - const trimmed = url.trim(); - if (trimmed === "") - return true; // empty href/src is harmless - // Extract a leading "scheme:" if present. A scheme must start with a - // letter and contain only letters/digits/+/-/. before the colon. Strip - // whitespace and ASCII control chars first so a tab/newline embedded in - // the scheme cannot smuggle a dangerous scheme past the check. - const cleaned = trimmed.replace(/[\s\x00-\x1f]+/g, ""); - const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned); - const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; - const dangerous = new Set(["javascript", "vbscript", "data", "file"]); - if (context === "link") { - if (scheme === null) - return true; // relative/anchor link is fine - // For links, data: is also blocked (can carry script payloads). - return !new Set(["javascript", "vbscript", "data"]).has(scheme); - } - // context === "src" - if (scheme === null) - return true; // relative/absolute path (incl. /api/files) - if (dangerous.has(scheme)) - return false; - return scheme === "http" || scheme === "https" || scheme === "mailto"; - } - /** - * Recursively walk a ProseMirror doc and reject any unsafe URL on a link - * mark href or on a media node's src/url. Media nodes covered: image, - * attachment, video, plus embed (rendered as an iframe), youtube, drawio - * and excalidraw — all of which carry a user-controlled URL that Docmost - * renders. Throws a clear error on the first violation. A max-depth guard - * turns an over-deep document into a clean error instead of a RangeError - * stack overflow. - */ - validateDocUrls(node, depth = 0) { - const MAX_DEPTH = 200; - if (depth > MAX_DEPTH) { - throw new Error(`document nesting exceeds the maximum depth of ${MAX_DEPTH}`); - } - if (!node || typeof node !== "object") - return; - // Link marks on text nodes: validate the href. - if (Array.isArray(node.marks)) { - for (const mark of node.marks) { - if (mark && mark.type === "link" && mark.attrs) { - if (!this.isSafeUrl(mark.attrs.href, "link")) { - throw new Error(`unsafe link href rejected: "${mark.attrs.href}"`); - } - } - } - } - // Media nodes: validate src/url against the stricter src allowlist. - // embed renders as an iframe (highest risk); youtube/drawio/excalidraw - // likewise carry a user-controlled URL Docmost renders, so they get the - // same scheme check as image/attachment/video. - if (node.type === "image" || - node.type === "attachment" || - node.type === "video" || - node.type === "embed" || - node.type === "youtube" || - node.type === "drawio" || - node.type === "excalidraw" || - node.type === "audio" || - node.type === "pdf") { - const attrs = node.attrs || {}; - for (const key of ["src", "url"]) { - if (attrs[key] != null && !this.isSafeUrl(attrs[key], "src")) { - throw new Error(`unsafe ${node.type} ${key} rejected: "${attrs[key]}"`); - } - } - } - if (Array.isArray(node.content)) { - for (const child of node.content) { - this.validateDocUrls(child, depth + 1); - } - } - } - /** - * Recursively validate the STRUCTURE of a ProseMirror node (reuses the - * recursion shape of validateDocUrls). Every node must be an object with a - * string `type`; when present, `content` must be an array, `marks` must be - * an array of objects each with a string `type`, and a text node's `text` - * must be a string. Throws a clear "invalid ProseMirror document" error on - * the first violation. A max-depth guard turns an over-deep document into a - * clean error instead of a RangeError stack overflow. - */ - validateDocStructure(node, depth = 0) { - const MAX_DEPTH = 200; - if (depth > MAX_DEPTH) { - throw new Error(`invalid ProseMirror document: nesting exceeds the maximum depth of ${MAX_DEPTH}`); - } - if (!node || typeof node !== "object" || typeof node.type !== "string") { - throw new Error("invalid ProseMirror document: every node must be an object with a string `type`"); - } - if ("text" in node && node.type === "text" && typeof node.text !== "string") { - throw new Error("invalid ProseMirror document: a text node must have a string `text`"); - } - if (node.marks !== undefined) { - if (!Array.isArray(node.marks)) { - throw new Error("invalid ProseMirror document: `marks` must be an array"); - } - for (const mark of node.marks) { - if (!mark || typeof mark !== "object" || typeof mark.type !== "string") { - throw new Error("invalid ProseMirror document: every mark must be an object with a string `type`"); - } - } - } - if (node.content !== undefined) { - if (!Array.isArray(node.content)) { - throw new Error("invalid ProseMirror document: `content` must be an array when present"); - } - for (const child of node.content) { - this.validateDocStructure(child, depth + 1); - } - } - } - /** - * Replace page content with a raw ProseMirror JSON document (lossless) and/or - * update its title. Both `doc` and `title` are optional, but at least one must - * be supplied: - * - `doc` provided -> validate + full-overwrite the body (and update the - * title too when `title` is also given). - * - `doc` omitted, `title` given -> title-only update; the body is NOT - * touched/resent (no collab write happens). - * - neither given -> throws (nothing to update). - */ - async updatePageJson(pageId, doc, title) { - await this.ensureAuthenticated(); - // Title-only / no-op handling: when no document is supplied, do NOT write - // the body. Update the title if one was given; otherwise there is nothing - // to do, so fail loudly rather than silently no-op. - if (doc == null) { - if (!title) { - throw new Error("update_page_json: nothing to update (provide content and/or title)"); - } - await this.client.post("/pages/update", { pageId, title }); - return { - success: true, - modified: true, - message: "Page title updated (content left unchanged).", - pageId, - }; - } - // Validate the document shape before a full overwrite: a malformed doc - // would otherwise silently corrupt the page (full-overwrite is the - // documented behaviour; no optimistic-concurrency is applied here). - if (typeof doc !== "object" || - doc.type !== "doc" || - !Array.isArray(doc.content)) { - throw new Error('content must be a ProseMirror document ({"type":"doc","content":[...]}) ' + - "where content is an array of nodes each having a string `type`"); - } - // Recurse the WHOLE document so a malformed nested node (e.g. a node with a - // non-string type, a non-array content/marks, or a text node missing its - // string text) is rejected up front rather than silently corrupting the - // page on overwrite. - this.validateDocStructure(doc); - // Sanitize URLs before writing. This closes the JSON-path bypass: unlike - // the markdown link path (which TipTap sanitizes), raw JSON could otherwise - // inject javascript:/data: link hrefs or media srcs straight into the doc. - this.validateDocUrls(doc); - if (title) { - await this.client.post("/pages/update", { pageId, title }); - } - const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await replacePageContent(pageId, doc, collabToken, this.apiUrl); - return { - success: true, - modified: true, - message: "Page content replaced from ProseMirror JSON.", - pageId, - verify: mutation.verify, - }; - } - /** - * Export a page to a single self-contained Docmost-flavoured markdown file: - * meta block + body (with inline comment anchors + diagrams) + comment - * threads. Lossless round-trip target; see importPageMarkdown for the inverse. - */ - async exportPageMarkdown(pageId) { - await this.ensureAuthenticated(); - const page = await this.getPageRaw(pageId); - const body = page.content - ? convertProseMirrorToMarkdown(page.content) - : ""; - let comments = []; - try { - comments = await this.listComments(pageId); - } - catch (e) { - // A comments fetch failure must not lose the body; export with [] and let - // the caller see the (empty) comments block. Log under DEBUG only. - if (process.env.DEBUG) - console.error("export: listComments failed", e); - } - const meta = { - version: 1, - pageId: page.id, - slugId: page.slugId, - title: page.title, - spaceId: page.spaceId, - parentPageId: page.parentPageId ?? null, - }; - return serializeDocmostMarkdown(meta, body, comments); - } - /** - * Import a self-contained Docmost markdown file back into a page. Parses out - * the meta + comments metadata blocks, converts the body to ProseMirror - * (restoring comment marks + diagrams from their inline HTML), and replaces - * the page content. Comment THREAD records are NOT written to the server in - * this version — they are preserved in the file and the inline marks are - * re-applied so the highlights survive; managing comment records stays with - * the comment tools/UI. - */ - async importPageMarkdown(pageId, fullMarkdown) { - await this.ensureAuthenticated(); - const { meta, body, comments } = parseDocmostMarkdown(fullMarkdown); - const doc = await markdownToProseMirror(body); - const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await replacePageContent(pageId, doc, collabToken, this.apiUrl); - // Collect distinct comment ids that actually became comment marks in the doc. - const collectCommentIds = (node, acc) => { - if (!node || typeof node !== "object") - return acc; - if (Array.isArray(node.marks)) { - for (const mk of node.marks) { - if (mk && mk.type === "comment" && mk.attrs?.commentId) { - acc.add(mk.attrs.commentId); - } - } - } - if (Array.isArray(node.content)) { - for (const child of node.content) - collectCommentIds(child, acc); - } - return acc; - }; - // Count reflects the comment marks present in the written document, so an id - // that only appears as inert text (e.g. inside a fenced code block) is not - // counted because it never becomes a comment mark. - const anchoredIds = collectCommentIds(doc, new Set()); - const result = { - success: true, - pageId, - anchoredCommentCount: anchoredIds.size, - commentsInFile: Array.isArray(comments) ? comments.length : 0, - verify: mutation.verify, - }; - // Warn (non-fatal) if the file was exported from a DIFFERENT page. - if (meta?.pageId && meta.pageId !== pageId) { - result.warning = `File was exported from page ${meta.pageId} but is being imported into ${pageId}.`; - } - return result; - } - /** - * Rename a page (change its title only) without touching or resending its - * content. The slug is derived from the page record, not the body, so it is - * left intact too. - */ - async renamePage(pageId, title) { - await this.ensureAuthenticated(); - await this.client.post("/pages/update", { pageId, title }); - return { success: true, pageId, title }; - } - /** - * Copy the WHOLE content of one page onto another, entirely server-side: the - * source's ProseMirror document is read and written verbatim onto the target - * via the live collab path, so the document never passes through the model. - * - * Only the target's BODY is replaced — its title and slug live on the page - * record (not in the content), so they are untouched. The source page is not - * modified at all. - */ - async copyPageContent(sourcePageId, targetPageId) { - await this.ensureAuthenticated(); - // A self-copy would be a no-op overwrite; reject it explicitly so a caller - // mistake surfaces as a clear error rather than a silent round-trip. - if (sourcePageId === targetPageId) { - throw new Error("copy_page_content: sourcePageId and targetPageId are the same page (no-op copy)"); - } - const source = await this.getPageRaw(sourcePageId); - const content = source?.content; - if (!content || - typeof content !== "object" || - content.type !== "doc" || - !Array.isArray(content.content)) { - throw new Error(`copy_page_content: source page ${sourcePageId} has no usable ProseMirror content to copy`); - } - // Defense-in-depth: run the same URL-scheme sanitizer the JSON write path - // uses, so copying never lands a javascript:/data: href/src on the target - // (parity with updatePageJson; harmless for already-stored source content). - this.validateDocUrls(content); - const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await replacePageContent(targetPageId, content, collabToken, this.apiUrl); - return { - success: true, - sourcePageId, - targetPageId, - copiedNodes: content.content.length, - verify: mutation.verify, - }; - } - /** - * Surgical text edits: find/replace inside text nodes of the live - * document. Preserves all block ids, marks, callouts and tables. - */ - async editPageText(pageId, edits) { - await this.ensureAuthenticated(); - const collabToken = await this.getCollabTokenWithReauth(); - // Apply the edits against the LIVE synced document, not the debounced REST - // snapshot, so concurrent human edits/comments are preserved. applyTextEdits - // records per-edit match problems in `failed` instead of throwing, and - // applies whatever it can; we abort the write only when nothing applied. - let results; - let failed; - // Whether we actually wrote new content. Set inside the transform: a - // degenerate edit (e.g. find === replace, or a batch that nets to no change) - // can "apply" yet leave the document byte-for-byte identical, in which case - // we must NOT write (no spurious history version) and must not claim a write - // happened. - let wrote = false; - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { - wrote = false; - const r = applyTextEdits(liveDoc, edits); - results = r.results; - failed = r.failed; - // Nothing applied -> abort the write (mutatePageContent treats a null - // return from the transform as "write nothing"). - if (r.results.length === 0) - return null; - // Edits "applied" but produced an identical document: skip the write so - // no new history version is created. Stable structural comparison via - // JSON.stringify (both docs come from the same deep-copied source, so - // key order is stable). - if (JSON.stringify(r.doc) === JSON.stringify(liveDoc)) - return null; - wrote = true; - return r.doc; - }); - if ((results?.length ?? 0) === 0 && (failed?.length ?? 0) > 0) { - // No edit applied: surface an aggregated, actionable error so the caller - // does not mistake a no-op for a partial success. - throw new Error("edit_page_text: no edits were applied (nothing written). " + - failed.map((f) => `"${f.find}": ${f.reason}`).join("; ")); - } - // Edits matched but produced no content change (identical document): report - // a successful no-op — NOT a failure — and do not falsely claim a write. - if (!wrote) { - return { - success: true, - pageId, - applied: results, - failed, - message: "No changes written (edits produced identical content).", - verify: mutation.verify, - }; - } - const result = { - success: true, - pageId, - applied: results, - failed, - message: (failed?.length ?? 0) - ? `Applied ${results?.length ?? 0} edit(s); ${failed.length} failed (see failed[]). Node ids and formatting preserved.` - : "Text edits applied (node ids and formatting preserved).", - verify: mutation.verify, - }; - // If any applied edit matched only after stripping markdown (the - // normalized fallback), warn that edit_page_text preserved existing marks - // and did NOT change formatting — so a caller who intended a formatting - // change is pointed at patch_node. - if (results?.some((r) => r.normalized === true)) { - result.warning = - "Some edits matched only after stripping markdown from your find string; " + - "edit_page_text preserved existing marks (it did not change bold/strike/etc.). " + - "If you intended a formatting change, use patch_node."; - } - return result; - } - /** - * Replace EVERY node whose attrs.id === nodeId (recursively, including nodes - * nested in callouts/tables) with the supplied node. Operates on the LIVE - * collab document so comments and concurrent edits are preserved. - * - * The replacement node's block id is preserved: if node.attrs is missing it - * is created, and if node.attrs.id is missing it is set to nodeId so the - * replacement keeps the same id it replaced. Throws if no node matches. - */ - async patchNode(pageId, nodeId, node) { - await this.ensureAuthenticated(); - if (!node || typeof node !== "object" || typeof node.type !== "string") { - throw new Error("patch_node: `node` must be an object with a string `type`"); - } - // Preserve the block id WITHOUT mutating the caller's object: build a local - // copy whose attrs.id === nodeId (so the swapped-in node keeps the id of the - // node it replaces). - const target = { - ...node, - attrs: { - ...(node.attrs && typeof node.attrs === "object" ? node.attrs : {}), - }, - }; - if (target.attrs.id == null) { - target.attrs.id = nodeId; - } - const collabToken = await this.getCollabTokenWithReauth(); - // Track the replacement count in an outer var, reset per-transform, so a - // collab retry recomputes it cleanly (mirrors replaceImage's pattern). - let replaced = 0; - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { - replaced = 0; - const { doc: nd, replaced: r } = replaceNodeById(liveDoc, nodeId, target); - replaced = r; - if (replaced === 0) - return null; // no match -> skip the write entirely - return nd; - }); - if (replaced === 0) { - throw new Error(`patch_node: no node with id "${nodeId}" found on page ${pageId}`); - } - return { success: true, replaced, nodeId, verify: mutation.verify }; - } - /** - * Insert a node relative to an anchor (or append it at the top level). - * Operates on the LIVE collab document so comments and concurrent edits are - * preserved. - * - * opts.position: - * - "append": push the node at the end of the top-level content. - * - "before"/"after": insert the node as a sibling of the anchor, just - * before/after it. Exactly one of anchorNodeId / anchorText must be given; - * anchorNodeId locates a node anywhere by attrs.id, anchorText matches the - * first top-level block whose plain text includes it. - * - * Throws if the anchor cannot be found. - */ - async insertNode(pageId, node, opts) { - await this.ensureAuthenticated(); - if (!node || typeof node !== "object" || typeof node.type !== "string") { - throw new Error("insert_node: `node` must be an object with a string `type`"); - } - if (!opts || - (opts.position !== "before" && - opts.position !== "after" && - opts.position !== "append")) { - throw new Error('insert_node: `position` must be one of "before", "after", "append"'); - } - if (opts.position === "before" || opts.position === "after") { - // before/after require EXACTLY ONE anchor (an id or a text fragment). - const hasId = typeof opts.anchorNodeId === "string" && opts.anchorNodeId.length > 0; - const hasText = typeof opts.anchorText === "string" && opts.anchorText.length > 0; - if (hasId === hasText) { - throw new Error(`insert_node: position "${opts.position}" requires exactly one of anchorNodeId or anchorText`); - } - } - const collabToken = await this.getCollabTokenWithReauth(); - // Track insertion in an outer var, reset per-transform, so a collab retry - // recomputes it cleanly (mirrors replaceImage's pattern). - let inserted = false; - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { - inserted = false; - const { doc: nd, inserted: ins } = insertNodeRelative(liveDoc, node, opts); - inserted = ins; - if (!inserted) - return null; // anchor not found -> skip the write entirely - return nd; - }); - if (!inserted) { - const anchorDesc = opts.anchorNodeId - ? `anchorNodeId "${opts.anchorNodeId}"` - : `anchorText "${opts.anchorText}"`; - // anchorText is matched against the block's literal RENDERED plain text; - // markdown/emoji are tolerated only as a strip-and-retry fallback, so a - // miss usually means the text differs from what's on the page. - const hint = opts.anchorText - ? ' anchorText must be the block\'s literal rendered plain text (no markdown wrappers or emoji); anchorNodeId from get_page_json is more reliable.' - : ""; - throw new Error(`insert_node: anchor not found (${anchorDesc}) on page ${pageId}.${hint}`); - } - return { - success: true, - inserted: true, - position: opts.position, - verify: mutation.verify, - }; - } - /** - * Remove EVERY node whose attrs.id === nodeId (recursively, including nodes - * nested in callouts/tables) from its parent content array. Operates on the - * LIVE collab document so comments and concurrent edits are preserved. - * Throws if no node matches. - */ - async deleteNode(pageId, nodeId) { - await this.ensureAuthenticated(); - const collabToken = await this.getCollabTokenWithReauth(); - // Track the deletion count in an outer var, reset per-transform, so a - // collab retry recomputes it cleanly (mirrors replaceImage's pattern). - let deleted = 0; - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { - deleted = 0; - const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId); - deleted = d; - if (deleted === 0) - return null; // no match -> skip the write entirely - return nd; - }); - if (deleted === 0) { - throw new Error(`delete_node: no node with id "${nodeId}" found on page ${pageId}`); - } - return { success: true, deleted, nodeId, verify: mutation.verify }; - } - /** Build the public share URL for a page. */ - shareUrl(shareKey, slugId) { - return `${this.appUrl}/share/${shareKey}/p/${slugId}`; - } - /** Share a page publicly (idempotent) and return the public URL. */ - async sharePage(pageId, searchIndexing = true) { - await this.ensureAuthenticated(); - const response = await this.client.post("/shares/create", { - pageId, - includeSubPages: false, - searchIndexing, - }); - const share = response.data?.data ?? response.data; - const slugId = share.page?.slugId || (await this.getPageRaw(pageId)).slugId; - return { - shareId: share.id, - key: share.key, - pageId: share.pageId, - publicUrl: this.shareUrl(share.key, slugId), - searchIndexing: share.searchIndexing, - }; - } - /** List all public shares in the workspace with their URLs. */ - async listShares() { - const shares = await this.paginateAll("/shares", {}); - return shares.map((s) => ({ - shareId: s.id, - key: s.key, - pageId: s.pageId, - pageTitle: s.page?.title, - publicUrl: s.page?.slugId ? this.shareUrl(s.key, s.page.slugId) : null, - searchIndexing: s.searchIndexing, - createdAt: s.createdAt, - })); - } - /** Remove the public share of a page. */ - async unsharePage(pageId) { - await this.ensureAuthenticated(); - const shares = await this.listShares(); - const share = shares.find((s) => s.pageId === pageId); - if (!share) { - throw new Error(`Page ${pageId} is not shared.`); - } - await this.client.post("/shares/delete", { shareId: share.shareId }); - return { success: true, removedShareId: share.shareId, pageId }; - } - async search(query, spaceId, limit) { - await this.ensureAuthenticated(); - const payload = { query, spaceId }; - // Clamp an optional caller-supplied limit into a sane 1..100 range before - // forwarding it to the server; omit it entirely when not provided so the - // server applies its own default. - if (limit !== undefined) { - payload.limit = Math.max(1, Math.min(100, limit)); - } - const response = await this.client.post("/search", payload); - // Normalize both response shapes: bare array and paginated { items: [...] } - const data = response.data?.data; - const items = Array.isArray(data) ? data : data?.items || []; - const filteredItems = items.map((item) => filterSearchResult(item)); - return { - items: filteredItems, - success: response.data?.success || false, - }; - } - async movePage(pageId, parentPageId, position) { - await this.ensureAuthenticated(); - // Docmost requires position >= 5 chars. - const validPosition = position || "a00000"; - return this.client - .post("/pages/move", { - pageId, - parentPageId, - position: validPosition, - }) - .then((res) => res.data); - } - async deletePage(pageId) { - await this.ensureAuthenticated(); - return this.client - .post("/pages/delete", { pageId }) - .then((res) => res.data); - } - // --- Comment methods (ported from upstream PR #3 by Max Nikitin) --- - /** - * Normalize a comment's `content` into a ProseMirror doc object before - * markdown conversion. createComment/updateComment send content as a - * JSON.stringify(...) STRING, and the server stores it as-is, so on read it - * comes back as a string. convertProseMirrorToMarkdown returns "" for a - * string, so parse it first (guarded — fall back to the raw value on any - * parse failure so a non-JSON legacy value is still handled gracefully). - */ - parseCommentContent(content) { - if (typeof content !== "string") - return content; - try { - return JSON.parse(content); - } - catch { - return content; - } - } - /** List all comments on a page (cursor-paginated), content as markdown. */ - async listComments(pageId) { - await this.ensureAuthenticated(); - let allComments = []; - let cursor = null; - do { - const payload = { pageId, limit: 100 }; - if (cursor) - payload.cursor = cursor; - const response = await this.client.post("/comments", payload); - const data = response.data.data || response.data; - const items = data.items || []; - allComments = allComments.concat(items); - cursor = data.meta?.nextCursor || null; - } while (cursor); - return allComments.map((comment) => { - const markdown = comment.content - ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content)) - : ""; - return filterComment(comment, markdown); - }); - } - async getComment(commentId) { - await this.ensureAuthenticated(); - const response = await this.client.post("/comments/info", { commentId }); - const comment = response.data.data || response.data; - const markdown = comment.content - ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content)) - : ""; - return { - data: filterComment(comment, markdown), - success: true, - }; - } - /** - * Create an inline comment anchored to its `selection` text, or a reply. - * - * Top-level comments (no `parentCommentId`) are ALWAYS inline and MUST carry a - * `selection`: the `type` argument is kept for interface compatibility but the - * effective type is coerced to "inline". The selection has to anchor in the - * document; if it cannot, the comment is rolled back and an error is thrown so - * the caller is forced to supply a proper inline selection rather than leaving - * an orphan, unanchored comment behind. Replies (parentCommentId set) inherit - * their parent's anchor: they take NO selection and are not anchored. - */ - async createComment(pageId, content, type = "page", selection, parentCommentId) { - await this.ensureAuthenticated(); - const isReply = !!parentCommentId; - // Only top-level comments are inline-anchored, so they are stored as - // "inline". Replies carry no inline selection, so they keep the historical - // general ("page") type — both backward-compatible and semantically correct. - // The `type` argument is kept for interface compatibility; createComment - // normalizes the effective type internally, so callers may pass "inline". - const effectiveType = isReply ? "page" : "inline"; - if (!isReply && (!selection || !selection.trim())) { - throw new Error("create_comment: an inline 'selection' (exact text to anchor on) is required for a top-level comment"); - } - // For a top-level comment, fail BEFORE creating anything when the selection - // is not present in the persisted document — this avoids leaving an orphan - // comment + notification behind. A read failure (network) is non-fatal: the - // live anchor step below still enforces the anchoring invariant. - if (!isReply && selection) { - try { - const page = await this.getPageJson(pageId); - if (!canAnchorInDoc(page.content, selection)) { - throw new Error("create_comment: could not find the selection text in the page to anchor the comment. " + - "Provide the EXACT contiguous text from a single paragraph/block (<=250 chars)."); - } - } - catch (e) { - // Rethrow our own "not found" error; swallow read/network errors so the - // live anchor step can still try (and enforce) the anchoring. - if (e instanceof Error && - e.message.startsWith("create_comment: could not find the selection")) { - throw e; - } - if (process.env.DEBUG) { - console.error("Pre-check getPageJson failed; deferring to live anchor step:", e); - } - } - } - // Convert through the full Docmost schema (consistent with page paths) - const jsonContent = await markdownToProseMirror(content); - const payload = { - pageId, - content: JSON.stringify(jsonContent), - type: effectiveType, - }; - if (!isReply && selection) - payload.selection = selection; - if (parentCommentId) - payload.parentCommentId = parentCommentId; - const response = await this.client.post("/comments/create", payload); - const comment = response.data.data || response.data; - const markdown = comment.content - ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content)) - : content; - const result = { - data: filterComment(comment, markdown), - success: true, - }; - // Replies inherit the parent's anchor: no selection, no anchoring. - if (isReply) { - return result; - } - // Anchor the comment in the document. The /comments/create API records the - // comment + its `selection` text, but it does NOT insert the comment MARK - // into the page content, so without this the inline comment has no - // highlight/anchor and is not clickable. If anchoring fails the comment is - // rolled back (deleted) and an error is thrown — never an orphan comment. - const newCommentId = comment.id; - // Guard: a create response without an id would mean writing a comment mark - // with commentId: undefined and a later delete of a falsy id. We have no id - // to roll back here (nothing was created with an id), so just fail loudly. - if (!newCommentId) { - throw new Error("create_comment: the server returned no comment id, so the comment could not be anchored"); - } - let anchored = false; - try { - const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { - const doc = liveDoc && liveDoc.type === "doc" - ? liveDoc - : { type: "doc", content: [] }; - if (applyAnchorInDoc(doc, selection, newCommentId)) { - anchored = true; - return doc; - } - // Selection text not found in the LIVE document: abort the write. The - // rollback + throw below turns this into a hard error. - return null; - }); - result.verify = mutation.verify; - } - catch (e) { - // The comment record already exists; roll it back so we never leave an - // orphan, then rethrow the original anchoring error. - await this.safeDeleteComment(newCommentId); - throw e; - } - if (!anchored) { - // Mutation aborted because the selection was not found in the live - // document. Roll back the comment and surface a hard error. - await this.safeDeleteComment(newCommentId); - throw new Error("create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back"); - } - result.anchored = true; - return result; - } - /** - * Best-effort rollback of a just-created comment. Swallows any delete failure - * (logging under DEBUG) so a failed cleanup never masks the original error. - */ - async safeDeleteComment(commentId) { - // Defense in depth: never call the delete API with a falsy id — there is - // nothing to roll back, and deleteComment(undefined) would hit a bad route. - if (!commentId) - return; - try { - await this.deleteComment(commentId); - } - catch (delErr) { - if (process.env.DEBUG) { - console.error("Failed to roll back comment after anchoring error:", delErr); - } - } - } - async updateComment(commentId, content) { - await this.ensureAuthenticated(); - const jsonContent = await markdownToProseMirror(content); - await this.client.post("/comments/update", { - commentId, - content: JSON.stringify(jsonContent), - }); - return { - success: true, - commentId, - message: "Comment updated successfully.", - }; - } - async deleteComment(commentId) { - await this.ensureAuthenticated(); - return this.client - .post("/comments/delete", { commentId }) - .then((res) => res.data); - } - /** - * Resolve or reopen a top-level comment thread (reversible — `resolved` - * toggles the state). Only top-level comments can be resolved; the server - * rejects resolving a reply. Hits POST /comments/resolve. - */ - async resolveComment(commentId, resolved) { - await this.ensureAuthenticated(); - const response = await this.client.post("/comments/resolve", { - commentId, - resolved, - }); - const comment = response.data?.data ?? response.data; - return { - success: true, - commentId, - resolved, - comment, - }; - } - /** - * Check for new comments across pages in a space (optionally scoped to a - * subtree): pages updated after `since` are scanned and their comments - * filtered by createdAt > since. - */ - async checkNewComments(spaceId, since, parentPageId) { - await this.ensureAuthenticated(); - const sinceDate = new Date(since); - // Reject an unparseable `since`: comparing against an Invalid Date silently - // yields zero new comments (every `>` against NaN is false), which would - // mask a malformed input as "nothing new" instead of erroring. - if (Number.isNaN(sinceDate.getTime())) { - throw new Error(`checkNewComments: invalid "since" date "${since}"; expected an ISO-8601 timestamp`); - } - // 1. Enumerate the FULL set of pages in scope by walking the sidebar-pages - // tree (a complete page index), NOT the bounded "/pages/recent" feed which - // caps at ~5000 recent items and silently misses comments on older pages. - // - // Subtree scope: when parentPageId is given, the scope is that page ITSELF - // plus every descendant (enumerateSpacePages walks its children). Otherwise - // the scope is the whole space (all roots and their descendants). - // - // NOTE: do NOT pre-filter by page.updatedAt — creating a comment does not - // bump it (verified on a live server), so such a filter silently misses - // comments on pages that were not otherwise edited. The complete tree walk - // already restricts the scope correctly, so no recent-feed allow-list is - // needed any more. - let pagesInScope; - if (parentPageId) { - const subtree = await this.enumerateSpacePages(spaceId, parentPageId); - // Include the parent page node itself alongside its descendants. Fetch it - // so its title/id are available even though it is not returned by its own - // children listing. - let parentNode = { id: parentPageId }; - try { - parentNode = await this.getPageRaw(parentPageId); - } - catch (e) { - // Fall back to a minimal node if the parent can't be fetched; its - // comments are still attempted below (the fetch there is non-fatal). - } - pagesInScope = [parentNode, ...subtree]; - } - else { - pagesInScope = await this.enumerateSpacePages(spaceId); - } - // 2. Fetch comments for each page, keep ones created after since - const results = []; - for (const page of pagesInScope) { - try { - const comments = await this.listComments(page.id); - const newComments = comments.filter((c) => new Date(c.createdAt) > sinceDate); - if (newComments.length > 0) { - results.push({ - pageId: page.id, - pageTitle: page.title, - comments: newComments, - }); - } - } - catch (e) { - // Skip pages with errors (e.g. deleted between calls) - } - } - const totalNewComments = results.reduce((sum, r) => sum + r.comments.length, 0); - // enumerateSpacePages caps traversal at 10000 nodes; flag when that cap was - // hit so the caller knows the scan may be incomplete (some pages skipped). - const truncated = pagesInScope.length >= 10000; - return { - since, - scope: parentPageId ? `subtree of ${parentPageId}` : `space ${spaceId}`, - checkedPages: pagesInScope.length, - pagesWithNewComments: results.length, - totalNewComments, - truncated, - comments: results, - }; - } - // --- Image upload / embedding --- - /** Map a Content-Type string to a supported MIME type, or null if unsupported. */ - supportedImageMime(ct) { - return MIME_TO_EXT[ct] ? ct : null; - } - /** - * Download a remote image from a caller-supplied URL and resolve its bytes, - * MIME and a filename. - * - * SSRF / RESOURCE TRUST BOUNDARY: the URL comes from the MCP caller and is - * fetched BY THE SERVER, so it must be guarded before and after the request. - * The guards mirror the local-file trust boundary in uploadImage: - * - scheme allowlist (http/https only) — rejects file:, data:, ftp:, etc., - * so the caller cannot use this path to read local files or other schemes; - * - a size cap enforced both via axios maxContentLength/maxBodyLength AND a - * post-download buffer.length re-check (defends against a missing/lying - * Content-Length), so a huge response cannot exhaust memory; - * - a 30s timeout. The timeout matters because replaceImage holds the - * per-page lock across this upload, so a hung download would wedge the - * lock for that page. - * We deliberately do NOT block private IP ranges: the MCP caller is already - * trusted to read arbitrary host files via the filePath path, so the marginal - * trust granted by fetching internal URLs is comparable, and blocking would - * break legitimate internal-image use. - */ - async fetchRemoteImage(url, maxBytes) { - // Scheme allowlist first — cheapest guard, and rejects non-http(s) schemes - // (file:, data:, ftp:, ...) before any network request is made. - let parsed; - try { - parsed = new URL(url); - } - catch (e) { - throw new Error(`Invalid image URL "${url}": ${e.message}`); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`unsupported image URL scheme "${parsed.protocol}"; only http and https are allowed`); - } - let response; - try { - response = await axios.get(url, { - responseType: "arraybuffer", - timeout: 30000, - maxContentLength: maxBytes, - maxBodyLength: maxBytes, - headers: { Accept: "image/*" }, - }); - } - catch (error) { - // Keep the thrown message free of the raw response body (it may echo - // server internals); surface only status/statusText. The full body is - // logged under DEBUG for diagnostics. - if (axios.isAxiosError(error)) { - if (process.env.DEBUG) { - console.error("Image download failed; response body:", JSON.stringify(error.response?.data)); - } - throw new Error(`Image download failed for "${url}": ${error.response?.status ?? ""} ${error.response?.statusText ?? error.message}`.trim()); - } - throw error; - } - // axios returns an ArrayBuffer for responseType: "arraybuffer". - const buffer = Buffer.from(response.data); - // Re-check the size: maxContentLength relies on Content-Length, which may be - // absent or lie, so guard against the actual byte count too. - if (buffer.length === 0) { - throw new Error(`Empty image response from "${url}"`); - } - if (buffer.length > maxBytes) { - throw new Error(`Image too large: ${buffer.length} bytes exceeds the ${maxBytes}-byte cap`); - } - // Resolve MIME: prefer the response Content-Type (strip any "; charset=..." - // parameter, lowercase, trim) mapped through the supported set; if the - // header is generic/missing/unsupported, fall back to the URL path - // extension via the existing extension->MIME logic. - const rawCt = response.headers?.["content-type"]; - let mime = null; - if (typeof rawCt === "string" && rawCt.length > 0) { - const ct = rawCt.split(";")[0].trim().toLowerCase(); - mime = this.supportedImageMime(ct); - } - if (!mime) { - // Fall back to the URL path extension. Use the pathname so the query - // string never contaminates the extension lookup. - const ext = extname(parsed.pathname).toLowerCase(); - mime = EXT_TO_MIME[ext] ?? null; - } - if (!mime) { - throw new Error(`cannot determine supported image type for "${url}"; supported: png, jpg, jpeg, gif, webp, svg`); - } - // Build a filename from the URL path basename (ignore the query string), - // defaulting to "image" when empty, and ensure it ends with the canonical - // extension for the resolved MIME (append it when missing/mismatched). - const canonicalExt = MIME_TO_EXT[mime]; - let fileName = basename(parsed.pathname) || "image"; - if (extname(fileName).toLowerCase() !== canonicalExt) { - fileName += canonicalExt; - } - return { buffer, mime, fileName }; - } - /** Build a Docmost ProseMirror image node from an uploaded attachment. */ - buildImageNode(att, align, alt) { - // Clean file URL, matching Docmost's native behaviour. No cache-busting - // query: the server serves the bare URL correctly, and replacement creates - // a new attachment id (a new URL) which busts caches naturally. - const src = `/api/files/${att.id}/${att.fileName}`; - const node = { - type: "image", - attrs: { - src, - attachmentId: att.id, - // Default to null when the server omits fileSize so the attr is never - // undefined (undefined would be dropped on serialization / break the - // ProseMirror image schema which expects size present). - size: att.fileSize ?? null, - align: align || "center", - width: null, - }, - }; - if (alt) - node.attrs.alt = alt; - return node; - } - /** - * Download a remote image from an http(s) URL and upload it as an attachment - * of a page, returning the attachment metadata plus a ready-to-insert - * ProseMirror image node. Local file paths are intentionally not supported: - * the MCP caller is a remote AI with no access to this server's filesystem. - */ - async uploadImage(pageId, url) { - await this.ensureAuthenticated(); - const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MiB - // Fetch + validate the remote image (scheme allowlist, size cap, timeout). - // See fetchRemoteImage for the SSRF / resource trust boundary. - const fetched = await this.fetchRemoteImage(url, MAX_IMAGE_BYTES); - const fileBuffer = fetched.buffer; - const mime = fetched.mime; - const fileName = fetched.fileName; - // Build a FRESH FormData for every send attempt. A FormData body is a - // single-use stream that is CONSUMED on the first send, so it cannot be - // replayed by this.client's response interceptor (replaying a consumed - // stream fails with 'socket hang up'). Multipart re-auth is therefore done - // here with bare axios and an explicit one-shot 401/403 retry that rebuilds - // the body. Field order matters: text fields must precede the file part so - // the server reads them; the server always generates a fresh attachment id. - const buildForm = () => { - const form = new FormData(); - form.append("pageId", pageId); - form.append("file", fileBuffer, { - filename: fileName, - contentType: mime, - }); - return form; - }; - // Local name distinct from the `url` parameter (the source image URL): this - // is the /files/upload endpoint we POST the multipart body to. - const uploadUrl = `${this.apiUrl}/files/upload`; - let response; - try { - // Call buildForm() ONCE per attempt and reuse the instance for both - // getHeaders() and the body so the Content-Type boundary matches the body. - const form = buildForm(); - // Read the Authorization header from this.client's defaults (set by - // login(), only ever deleted — never set to null) instead of building - // `Bearer ${this.token}`: a concurrent JSON 401 can null this.token - // mid-flight, which would otherwise produce a literal "Bearer null". - // ensureAuthenticated() above guarantees login() ran, so the default - // header exists here. A 60s timeout keeps a hung upload from wedging the - // per-page lock (replaceImage holds withPageLock across this call). - response = await axios.post(uploadUrl, form, { - headers: { - ...form.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } - catch (error) { - // On an expired-token auth error, re-login and retry exactly once with a - // freshly-rebuilt FormData (the previous one was already consumed). - if (axios.isAxiosError(error) && - (error.response?.status === 401 || error.response?.status === 403)) { - await this.login(); - const form2 = buildForm(); - response = await axios.post(uploadUrl, form2, { - headers: { - ...form2.getHeaders(), - Authorization: this.client.defaults.headers.common["Authorization"], - }, - timeout: 60000, - }); - } - else if (axios.isAxiosError(error)) { - // Keep the thrown message free of the raw response body (it may echo - // request data or server internals); surface only status/statusText. - // The full body is logged under DEBUG for diagnostics. - if (process.env.DEBUG) { - console.error("Image upload failed; response body:", JSON.stringify(error.response?.data)); - } - throw new Error(`Image upload failed: ${error.response?.status} ${error.response?.statusText}`); - } - else { - throw error; - } - } - // The attachment may arrive bare or wrapped in a { data } envelope. - const att = response.data?.data ?? response.data; - if (!att?.id || !att?.fileName) { - throw new Error("Unexpected /files/upload response: " + JSON.stringify(response.data)); - } - // Some Docmost versions omit fileSize from the upload response. Fall back - // to the fetched byte length (the bytes we just uploaded) so callers never - // get an undefined size. - const resolvedSize = att.fileSize ?? fileBuffer.length; - return { - attachmentId: att.id, - fileName: att.fileName, - fileSize: resolvedSize, - src: `/api/files/${att.id}/${att.fileName}`, - imageNode: this.buildImageNode({ ...att, fileSize: resolvedSize }), - }; - } - /** - * Upload an image from a web (http/https) URL and insert it into a page in - * one step. - * By default the image is appended at the end. With replaceText, the first - * top-level block whose text contains the string is replaced; with afterText, - * the image is inserted right after the first matching block. All other - * block ids are preserved (only one top-level block is added or swapped). - */ - async insertImage(pageId, url, opts = {}) { - const up = await this.uploadImage(pageId, url); - // Reuse the node from uploadImage (clean /api/files// src), then - // apply align/alt onto a shallow attrs copy. - const node = { ...up.imageNode, attrs: { ...up.imageNode.attrs } }; - if (opts.align) - node.attrs.align = opts.align; - if (opts.alt) - node.attrs.alt = opts.alt; - const collabToken = await this.getCollabTokenWithReauth(); - // Recursively collect the plain text of a top-level block. - const blockText = (n) => { - let out = ""; - if (n.type === "text") - out += n.text || ""; - for (const child of n.content || []) - out += blockText(child); - return out; - }; - // Insert into the LIVE synced document, not the debounced REST snapshot, so - // concurrent edits/comments/images are preserved and parallel insert_image - // calls (serialized by the per-page lock) each see the previous insertion. - let placement; - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { - const doc = liveDoc && liveDoc.type === "doc" - ? liveDoc - : { type: "doc", content: [] }; - if (!Array.isArray(doc.content)) - doc.content = []; - if (opts.replaceText) { - // Ambiguity guard (mirrors editPageText): count matching top-level - // blocks first, so a non-unique fragment cannot silently replace the - // wrong block (e.g. text that also appears inside a callout/table). - const matches = doc.content.filter((b) => blockText(b).includes(opts.replaceText)); - if (matches.length === 0) { - throw new Error(`replaceText not found: "${opts.replaceText}"`); - } - if (matches.length > 1) { - throw new Error(`replaceText "${opts.replaceText}" matches ${matches.length} blocks; use a longer unique fragment`); - } - const idx = doc.content.findIndex((b) => blockText(b).includes(opts.replaceText)); - // Data-loss guard: replaceText swaps the WHOLE top-level block, so if - // the fragment only appears nested inside a container (table, callout, - // list, blockquote) the entire structure would be destroyed. Refuse - // when the matched block is a container rather than a leaf - // paragraph/heading and point the caller at a safer tool. - const CONTAINER_TYPES = new Set([ - "table", - "callout", - "bulletList", - "orderedList", - "taskList", - "blockquote", - ]); - const matchedBlock = doc.content[idx]; - if (matchedBlock && CONTAINER_TYPES.has(matchedBlock.type)) { - throw new Error(`replaceText matched a ${matchedBlock.type} container block; replacing it would destroy the whole structure. ` + - `Use afterText to insert near it, or update_page_json for surgical edits.`); - } - doc.content.splice(idx, 1, node); - placement = "replaced"; - } - else if (opts.afterText) { - // Ambiguity guard (mirrors editPageText): refuse a non-unique fragment. - const matches = doc.content.filter((b) => blockText(b).includes(opts.afterText)); - if (matches.length === 0) { - throw new Error(`afterText not found: "${opts.afterText}"`); - } - if (matches.length > 1) { - throw new Error(`afterText "${opts.afterText}" matches ${matches.length} blocks; use a longer unique fragment`); - } - const idx = doc.content.findIndex((b) => blockText(b).includes(opts.afterText)); - doc.content.splice(idx + 1, 0, node); - placement = "after"; - } - else { - doc.content.push(node); - placement = "appended"; - } - return doc; - }); - return { - success: true, - pageId, - attachmentId: up.attachmentId, - src: up.src, - placement, - verify: mutation.verify, - }; - } - /** - * Replace an existing image in a page with a new image fetched from a web - * (http/https) URL. Uploads the new file as a brand-new attachment, which - * yields a fresh clean URL that both renders correctly and busts browser - * caches (the URL changed). Finds every image node - * whose attrs.attachmentId === oldAttachmentId (recursively, incl. nodes nested - * in callouts/tables) and repoints its src/attachmentId/size, preserving - * comments, alignment and alt. Operates on the live collab document so comments - * and concurrent edits are preserved. Throws if no matching image is found. - * - * The OLD attachment is left in place as an unreferenced orphan: Docmost - * exposes NO HTTP API to delete a single content attachment (verified against - * the attachment controller/service and by probing the live API — deletion - * happens only by cascade when the page, space or user is removed). This is the - * same outcome as Docmost's own editor when an image is removed/replaced. - * In-place byte overwrite is deliberately NOT used because some Docmost - * versions corrupt the attachment (HTTP 500) when its bytes are overwritten. - */ - async replaceImage(pageId, oldAttachmentId, url, opts = {}) { - const collabToken = await this.getCollabTokenWithReauth(); - // Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write). - // Previously the scan and the write were two separate mutatePageContent - // calls, each acquiring + releasing the lock, with the upload happening in - // the UNLOCKED gap between them. A concurrent op could interleave there: it - // could remove the target image so the write pass matches nothing, leaving - // the freshly-uploaded attachment as an un-deletable orphan (Docmost has no - // API to delete a single content attachment). Acquiring the lock once and - // using the non-locking collab helper inside (the per-page mutex is NOT - // reentrant, so the self-locking mutatePageContent would deadlock here) - // closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP - // and does not touch the page lock, so it is safe to call while held. - return withPageLock(pageId, async () => { - // STEP 1: read-only live check. Scan the live document for any image node - // matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id - // throws without ever creating an orphan attachment. - let matchFound = false; - const scan = (nodes) => { - for (const node of nodes) { - if (!node) - continue; - if (node.type === "image" && - node.attrs && - node.attrs.attachmentId === oldAttachmentId) { - matchFound = true; - } - if (Array.isArray(node.content)) - scan(node.content); - } - }; - await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => { - matchFound = false; // reset per-transform (collab may retry the read). - const doc = liveDoc && liveDoc.type === "doc" - ? liveDoc - : { type: "doc", content: [] }; - if (Array.isArray(doc.content)) - scan(doc.content); - return null; // read-only: never write on the check pass. - }); - if (!matchFound) { - throw new Error(`replace_image: no image with attachmentId "${oldAttachmentId}" found on page ${pageId}`); - } - // STEP 2: a match exists — upload the new file as a FRESH attachment (new - // id, new clean URL) and repoint every matching node in a second pass. - // Still inside the SAME lock, so no other op can have changed the page - // since the scan. - const up = await this.uploadImage(pageId, url); - let replaced = 0; - // Swap the source of one image node, preserving align/alt/title/geometry. - const repoint = (node) => { - node.attrs = { - ...node.attrs, - src: up.src, - attachmentId: up.attachmentId, - // Default to null when fileSize is unknown so the attr is never - // undefined. - size: up.fileSize ?? null, - }; - if (opts.align) - node.attrs.align = opts.align; - if (opts.alt !== undefined) - node.attrs.alt = opts.alt; - replaced++; - }; - // Recursively repoint every image node (incl. ones nested in callouts/tables). - const walk = (nodes) => { - for (const node of nodes) { - if (!node) - continue; - if (node.type === "image" && - node.attrs && - node.attrs.attachmentId === oldAttachmentId) { - repoint(node); - } - if (Array.isArray(node.content)) - walk(node.content); - } - }; - const mutation = await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => { - // Reset per-transform so collab retries recompute cleanly (no double-count). - replaced = 0; - const doc = liveDoc && liveDoc.type === "doc" - ? liveDoc - : { type: "doc", content: [] }; - if (!Array.isArray(doc.content)) - doc.content = []; - walk(doc.content); - if (replaced === 0) - return null; // no match -> skip the write entirely - return doc; - }); - // KNOWN LIMITATION: a same-count image SRC swap (image count unchanged, no - // text/mark change) may still report verify.changed === false, because the - // text+marks+integrity-count model in summarizeChange does not inspect - // image `src`/attachmentId attributes. That is acceptable here — the - // replace is confirmed by `replaced` below, and verify is supplementary. - if (replaced === 0) { - // The pass-1 SCAN found the target (matchFound was true) and we already - // uploaded the new attachment, but pass-2 matched nothing — a concurrent - // editor must have removed the node between the two passes. Do NOT throw - // here (that would leak the just-uploaded attachment AND report failure); - // instead report success with the upload flagged as an unreferenced - // orphan so the caller knows. (The early throw above still covers the - // case where pass-1 finds nothing, before any upload happens.) - return { - success: true, - replaced: 0, - pageId, - oldAttachmentId, - newAttachmentId: up.attachmentId, - src: up.src, - orphanedAttachmentId: up.attachmentId, - warning: "target image was removed concurrently; uploaded attachment is unreferenced", - verify: mutation.verify, - }; - } - return { - success: true, - pageId, - replaced, - oldAttachmentId, - newAttachmentId: up.attachmentId, - src: up.src, - verify: mutation.verify, - }; - }); - } - // --- Page history / diff / transform --- - /** - * List the saved versions (history snapshots) of a page, newest first. - * Docmost auto-snapshots on every save. Returns one cursor-paginated page of - * results: `{ items, nextCursor }`. The history record's id field is `id`. - */ - async listPageHistory(pageId, cursor) { - await this.ensureAuthenticated(); - const payload = { pageId }; - if (cursor) - payload.cursor = cursor; - const response = await this.client.post("/pages/history", payload); - const data = response.data?.data ?? response.data; - return { - items: data?.items ?? [], - nextCursor: data?.meta?.nextCursor ?? null, - }; - } - /** - * Fetch a single page-history version including its lossless ProseMirror - * `content`. The version also carries pageId/title/createdAt. - */ - async getPageHistory(historyId) { - await this.ensureAuthenticated(); - const response = await this.client.post("/pages/history/info", { - historyId, - }); - return response.data?.data ?? response.data; - } - /** - * "Restore" a version: Docmost has NO restore endpoint, so we take the - * version's `content` and write it as the page's current content via the live - * collab path (which itself creates a new history snapshot). Returns the - * affected pageId and the source historyId. - */ - async restorePageVersion(historyId) { - await this.ensureAuthenticated(); - const version = await this.getPageHistory(historyId); - if (!version || - !version.pageId || - !version.content || - typeof version.content !== "object") { - throw new Error(`restore_page_version: history ${historyId} has no usable content`); - } - // Defense-in-depth: sanitize URLs in the restored content (parity with the - // JSON write path) before writing it back. - this.validateDocUrls(version.content); - const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await mutatePageContent(version.pageId, collabToken, this.apiUrl, () => version.content); - return { - pageId: version.pageId, - restoredFrom: historyId, - verify: mutation.verify, - }; - } - /** - * Diff two versions of a page and return a Docmost-equivalent change set. - * `from`/`to` each resolve to a ProseMirror doc: - * - null / undefined / "current" -> the page's CURRENT content; - * - any other string -> that historyId's content. - * Returns the diff plus the resolved version metadata for each side. - */ - async diffPageVersions(pageId, from, to) { - await this.ensureAuthenticated(); - const isCurrent = (v) => v == null || v === "" || v === "current"; - const resolveSide = async (v) => { - if (isCurrent(v)) { - const raw = await this.getPageRaw(pageId); - return { - doc: raw.content || { type: "doc", content: [] }, - meta: { - kind: "current", - pageId, - title: raw.title, - updatedAt: raw.updatedAt, - }, - }; - } - const version = await this.getPageHistory(v); - return { - doc: version.content || { type: "doc", content: [] }, - meta: { - kind: "history", - historyId: version.id, - pageId: version.pageId, - title: version.title, - createdAt: version.createdAt, - }, - }; - }; - const fromSide = await resolveSide(from); - const toSide = await resolveSide(to); - const diff = diffDocs(fromSide.doc, toSide.doc); - return { from: fromSide.meta, to: toSide.meta, diff }; - } - /** - * Edit a page by running an arbitrary user-supplied JS transform against the - * live document, with a diff preview + page-history safety net. - * - * The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm - * sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals, - * a 5s timeout, and NO access to require/process/fs/network. It must return a - * `{ type: "doc" }` node, which is validated structurally before any write. - * - * `ctx` exposes: - * - comments: the page's comments (fetched before the live read); - * - log: an array the transform can push diagnostics to (via console.log); - * - consume(id): mark a comment id as consumed (for deleteComments); - * - helpers: the transforms.ts primitives + commentsToFootnotes. - * - * Footnote convention used by the helpers: footnote markers are plain "[N]" - * text in the body, and the notes are an orderedList under a heading whose - * text is "Примечания переводчика". - * - * dryRun (default true): read the page's current content, run the transform, - * and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket. - * Otherwise the transform runs atomically inside mutatePageContent, optionally - * deletes consumed comments, and returns the new historyId + diff + log. - */ - async transformPage(pageId, transformJs, opts = {}) { - const dryRun = opts.dryRun ?? true; - const deleteComments = opts.deleteComments ?? false; - await this.ensureAuthenticated(); - const comments = await this.listComments(pageId); - // ctx handed to the sandbox. consume() records ids; helpers are the pure - // transform primitives. log is captured from console.log inside the sandbox. - const ctx = { - comments, - log: [], - consumed: new Set(), - consume(id) { - this.consumed.add(id); - }, - helpers: { - blockText, - walk, - getList, - insertMarkerAfter, - setCalloutRange, - noteItem, - mdToInlineNodes, - commentsToFootnotes, - }, - }; - // Captured oldDoc / newDoc for the diff (set inside runTransform). - let oldDoc; - let newDoc; - // SYNCHRONOUS transform runner — safe to call inside mutatePageContent's - // onSynced (no await between the live read and the write). - const runTransform = (liveDoc) => { - oldDoc = structuredClone(liveDoc); - const sandbox = { - doc: structuredClone(liveDoc), - ctx, - structuredClone, - console: { - log: (...a) => ctx.log.push(a.map((x) => String(x)).join(" ")), - }, - }; - // Wrap the provided string in parentheses so both an expression-arrow - // (`(doc, ctx) => {...}`) and a parenthesized function work. Run it in a - // fresh context with no require/process/module so the transform cannot - // touch fs/network/process. 5s wall-clock timeout. - let fn; - try { - fn = vm.runInNewContext("(" + transformJs + ")", sandbox, { - timeout: 5000, - }); - } - catch (e) { - throw new Error(`transform did not compile: ${e?.message ?? e}`); - } - if (typeof fn !== "function") { - throw new Error("transform must evaluate to a function (doc, ctx) => doc"); - } - const result = vm.runInNewContext("f(d, c)", { f: fn, d: sandbox.doc, c: ctx }, { timeout: 5000 }); - if (!result || - typeof result !== "object" || - result.type !== "doc" || - !Array.isArray(result.content)) { - throw new Error('transform must return a ProseMirror doc node ({ type:"doc", content:[...] })'); - } - // Validate the returned doc before it can be written. - this.validateDocStructure(result); - this.validateDocUrls(result); - newDoc = result; - return result; - }; - if (dryRun) { - // Preview only: run against the current REST snapshot, never open the - // socket. oldDoc/newDoc are captured by runTransform. - const raw = await this.getPageRaw(pageId); - const current = raw.content || { type: "doc", content: [] }; - runTransform(current); - // Exercise the same Yjs encoder the apply path uses, so the preview - // fails with the SAME descriptive error when the doc is not encodable - // instead of returning a misleadingly-green diff. - assertYjsEncodable(newDoc); - return { - pushed: false, - diff: diffDocs(oldDoc, newDoc), - log: ctx.log, - }; - } - // Apply atomically against the live doc. - const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, runTransform); - // Optionally delete consumed comments (best-effort; a delete failure must - // not undo the successful write). - const deletedComments = []; - if (deleteComments) { - for (const id of ctx.consumed) { - try { - await this.deleteComment(id); - deletedComments.push(id); - } - catch (e) { - if (process.env.DEBUG) { - console.error(`transform: failed to delete comment ${id}:`, e); - } - } - } - } - // Fetch the newest historyId (Docmost snapshots on the write above). - let historyId = null; - try { - const hist = await this.listPageHistory(pageId); - historyId = hist.items?.[0]?.id ?? null; - } - catch (e) { - if (process.env.DEBUG) { - console.error("transform: failed to fetch history id:", e); - } - } - return { - pushed: true, - historyId, - diff: diffDocs(oldDoc, newDoc), - deletedComments, - log: ctx.log, - verify: mutation.verify, - }; - } -} diff --git a/packages/mcp/build/http.js b/packages/mcp/build/http.js deleted file mode 100644 index 45c422b0..00000000 --- a/packages/mcp/build/http.js +++ /dev/null @@ -1,133 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import { createDocmostMcpServer } from "./index.js"; -/** - * Build a stateful Streamable-HTTP handler for the Docmost MCP server. The - * embedding host (the gitmost NestJS server) bridges its raw Node req/res into - * `handleRequest`. One McpServer + transport is created per MCP session and - * kept alive between requests, keyed by the `mcp-session-id` header. - * - * `config` is EITHER a static `DocmostMcpConfig` (back-compat: stdio + the env - * service account, unchanged) OR a `McpConfigResolver` run once per session at - * `initialize` to bind that session to the request's identity. - */ -export function createMcpHttpHandler(config, options = {}) { - // One transport (and one McpServer) per MCP session, keyed by session id. - const transports = {}; - // Last activity timestamp per session id, used for idle eviction. - const lastSeen = {}; - // Anti-session-fixation: the opaque identity key bound to each session at - // initialize. A later request for that session whose key differs is rejected. - const sessionIdentity = {}; - // Write a JSON-RPC error and end the response. Used for the 400/401 paths so - // every early rejection is a well-formed JSON-RPC error, not a torn response. - const sendJsonRpcError = (res, statusCode, code, message) => { - res.statusCode = statusCode; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ - jsonrpc: "2.0", - error: { code, message }, - id: null, - })); - }; - // Idle session TTL (ms): a session with no activity for this long is evicted. - // Defaults to 30 min; overridable via MCP_SESSION_IDLE_MS. - const idleTtlMs = (() => { - const parsed = parseInt(process.env.MCP_SESSION_IDLE_MS ?? "", 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 30 * 60 * 1000; - })(); - // Periodically close transports idle longer than the TTL. transport.close() - // triggers its onclose, which removes it from `transports`; we also drop the - // lastSeen entry. unref() so this timer never keeps the process alive. - const sweepIntervalMs = 5 * 60 * 1000; - const sweepTimer = setInterval(() => { - const now = Date.now(); - for (const sid of Object.keys(transports)) { - if (now - (lastSeen[sid] ?? 0) > idleTtlMs) { - void transports[sid].close(); - delete lastSeen[sid]; - delete sessionIdentity[sid]; - } - } - }, sweepIntervalMs); - sweepTimer.unref(); - async function handleRequest(req, res, parsedBody) { - const sessionId = req.headers["mcp-session-id"]; - const method = (req.method || "GET").toUpperCase(); - let transport = sessionId ? transports[sessionId] : undefined; - if (method === "POST" && !transport) { - // A new session may only be created by an initialize request without a - // session id. - if (sessionId || !isInitializeRequest(parsedBody)) { - sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided"); - return; - } - // Resolve the per-session config from the request (per-user identity) when - // a resolver was supplied; otherwise use the static config unchanged. The - // resolver may throw (e.g. bad credentials) — surface a clean 401, never - // a created session. - let sessionConfig; - let identity; - try { - sessionConfig = - typeof config === "function" ? await config(req) : config; - if (options.identify) - identity = await options.identify(req); - } - catch (err) { - sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized"); - return; - } - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sid) => { - transports[sid] = transport; - lastSeen[sid] = Date.now(); - // Bind the resolved identity to the new session id for anti-fixation. - if (identity !== undefined) - sessionIdentity[sid] = identity; - }, - }); - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) - delete transports[sid]; - if (sid) - delete sessionIdentity[sid]; - }; - const server = createDocmostMcpServer(sessionConfig); - await server.connect(transport); - await transport.handleRequest(req, res, parsedBody); - return; - } - if (!transport) { - sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided"); - return; - } - // Anti-session-fixation: a request reusing an existing session id must - // present credentials/token that resolve to the SAME identity bound at - // initialize, otherwise reject with 401. This prevents hijacking another - // user's established session by replaying its session id with different - // credentials. - if (options.identify && sessionId && sessionId in sessionIdentity) { - let presented; - try { - presented = await options.identify(req); - } - catch (err) { - sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized"); - return; - } - if (presented !== sessionIdentity[sessionId]) { - sendJsonRpcError(res, 401, -32001, "Credentials do not match the user that owns this MCP session."); - return; - } - } - // Routing to an existing transport: refresh its idle timestamp. - if (sessionId) - lastSeen[sessionId] = Date.now(); - await transport.handleRequest(req, res, parsedBody); - } - return { handleRequest }; -} diff --git a/packages/mcp/build/index.js b/packages/mcp/build/index.js deleted file mode 100644 index 7f258a19..00000000 --- a/packages/mcp/build/index.js +++ /dev/null @@ -1,691 +0,0 @@ -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 } 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); -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) => ({ - content: [{ type: "text", 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) { - // 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 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 - 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` = `#` 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` = `#` 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` = `#` 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` = `#` 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", 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 /share//p/.", - 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", 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", - 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), " + - "and commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " + - "comments into numbered footnotes). 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) 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: 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; -} diff --git a/packages/mcp/build/lib/auth-utils.js b/packages/mcp/build/lib/auth-utils.js deleted file mode 100644 index cc61481c..00000000 --- a/packages/mcp/build/lib/auth-utils.js +++ /dev/null @@ -1,74 +0,0 @@ -import axios from "axios"; -export async function getCollabToken(baseUrl, apiToken) { - try { - const response = await axios.post(`${baseUrl}/auth/collab-token`, {}, { - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - }); - // console.error('Collab Token Response:', response.data); - // Response is wrapped in { data: { token: ... } } - return response.data.data?.token || response.data.token; - } - catch (error) { - if (axios.isAxiosError(error)) { - // Attach the HTTP status to the plain Error so callers (e.g. - // getCollabTokenWithReauth) can still detect a 401/403 after the - // original AxiosError has been wrapped away. - // Avoid leaking the full server response body by default; include only - // status + statusText. Append the body only when DEBUG is set. - let message = `Failed to get collab token: ${error.response?.status} ${error.response?.statusText}`; - if (process.env.DEBUG) { - message += ` - ${JSON.stringify(error.response?.data)}`; - } - const err = new Error(message); - err.status = error.response?.status; - throw err; - } - throw error; - } -} -export async function performLogin(baseUrl, email, password) { - try { - const response = await axios.post(`${baseUrl}/auth/login`, { - email, - password, - }); - // Extract token from Set-Cookie header - const cookies = response.headers["set-cookie"]; - if (!cookies) { - throw new Error("No Set-Cookie header found in login response"); - } - // Match the cookie name exactly to avoid matching a future - // authTokenRefresh cookie (startsWith would catch it). - const authCookie = cookies.find((c) => { - const kv = c.split(";")[0]; - return kv.slice(0, kv.indexOf("=")) === "authToken"; - }); - if (!authCookie) { - throw new Error("No authToken cookie found in login response"); - } - // Take everything after the FIRST "=" up to the first ";". - // Splitting on "=" would truncate base64 values containing "=" padding. - const kv = authCookie.split(";")[0]; - const token = kv.slice(kv.indexOf("=") + 1); - return token; - } - catch (error) { - // Avoid leaking the full server response body by default; log only the - // HTTP status. Log the verbose body only when DEBUG is set. - if (axios.isAxiosError(error)) { - if (process.env.DEBUG) { - console.error("Login failed:", error.response?.data); - } - else { - console.error("Login failed:", error.response?.status); - } - } - else { - console.error("Login failed:", error.message); - } - throw error; - } -} diff --git a/packages/mcp/build/lib/collaboration.js b/packages/mcp/build/lib/collaboration.js deleted file mode 100644 index 5140acee..00000000 --- a/packages/mcp/build/lib/collaboration.js +++ /dev/null @@ -1,716 +0,0 @@ -import { HocuspocusProvider } from "@hocuspocus/provider"; -import { TiptapTransformer } from "@hocuspocus/transformer"; -import * as Y from "yjs"; -import WebSocket from "ws"; -import { marked } from "marked"; -import { generateJSON } from "@tiptap/html"; -import { JSDOM } from "jsdom"; -import { docmostExtensions } from "./docmost-schema.js"; -import { withPageLock } from "./page-lock.js"; -import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js"; -import { summarizeChange } from "./diff.js"; -// Setup DOM environment for Tiptap HTML parsing in Node.js -const dom = new JSDOM(""); -global.window = dom.window; -global.document = dom.window.document; -// @ts-ignore -global.Element = dom.window.Element; -// @ts-ignore -global.WebSocket = WebSocket; -// Navigator is read-only in newer Node versions and already exists -// global.navigator = dom.window.navigator; -/** - * Hard ceiling above which we skip callout preprocessing entirely. The linear - * scanner below has no quadratic blow-up, but we still cap input defensively so - * a pathological multi-megabyte payload cannot tie up the event loop; in that - * case the markdown is passed through verbatim (callouts are simply not - * detected) rather than risking a slow scan. - */ -const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB -/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */ -const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/; -/** Matches a bare closing callout fence: `:::`. */ -const CALLOUT_CLOSE_RE = /^:::\s*$/; -/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */ -const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/; -/** - * Pre-process Docmost-flavoured markdown: convert `:::type ... :::` - * callout blocks (the syntax our markdown export produces) into HTML - * divs that the callout extension parses. The inner content is rendered - * through marked as regular markdown. - * - * Implemented as a single linear pass over the lines (no quadratic regex - * rescan). It: - * - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a - * `:::` line that lives inside a code fence as a callout delimiter, so a - * callout body that itself contains a fenced code block with a `:::` line is - * no longer corrupted; - * - matches an opening `:::type` line with the next CLOSING `:::` at the SAME - * nesting level, supporting NESTED callouts via a depth counter (an inner - * `:::type` opens a deeper level and consumes a matching `:::`); - * - emits the same `
` output - * (inner rendered through marked) as the previous regex implementation. - */ -async function preprocessCallouts(markdown) { - // Defensive cap: skip preprocessing for pathologically large inputs. - if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) { - return markdown; - } - // Recursively transform a slice of lines, converting top-level callouts in - // that slice into
blocks and rendering their inner content (which may - // itself contain nested callouts) through this same function. - const transform = async (lines) => { - const out = []; - let inCodeFence = false; - let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it - let i = 0; - while (i < lines.length) { - const line = lines[i]; - // Inside a code fence, only its matching closing fence is significant; - // everything else (including `:::` lines) is copied through verbatim. - if (inCodeFence) { - out.push(line); - const fence = line.match(CODE_FENCE_RE); - if (fence && fence[2].startsWith(codeFenceMarker[0]) && - fence[2].length >= codeFenceMarker.length) { - inCodeFence = false; - codeFenceMarker = ""; - } - i++; - continue; - } - // A code fence opening outside any callout body: enter code-fence mode. - const fenceOpen = line.match(CODE_FENCE_RE); - if (fenceOpen) { - inCodeFence = true; - codeFenceMarker = fenceOpen[2]; - out.push(line); - i++; - continue; - } - // An opening callout fence: scan forward (with code-fence and nested - // callout awareness) for its matching closing `:::` at the same level. - const open = line.match(CALLOUT_OPEN_RE); - if (open) { - const type = open[1].toLowerCase(); - const bodyLines = []; - let depth = 1; - let innerInCodeFence = false; - let innerCodeFenceMarker = ""; - let j = i + 1; - for (; j < lines.length; j++) { - const bl = lines[j]; - if (innerInCodeFence) { - const f = bl.match(CODE_FENCE_RE); - if (f && f[2].startsWith(innerCodeFenceMarker[0]) && - f[2].length >= innerCodeFenceMarker.length) { - innerInCodeFence = false; - innerCodeFenceMarker = ""; - } - bodyLines.push(bl); - continue; - } - const innerFence = bl.match(CODE_FENCE_RE); - if (innerFence) { - innerInCodeFence = true; - innerCodeFenceMarker = innerFence[2]; - bodyLines.push(bl); - continue; - } - if (CALLOUT_OPEN_RE.test(bl)) { - depth++; - bodyLines.push(bl); - continue; - } - if (CALLOUT_CLOSE_RE.test(bl)) { - depth--; - if (depth === 0) - break; // matching close for THIS callout - bodyLines.push(bl); - continue; - } - bodyLines.push(bl); - } - if (j < lines.length) { - // Found the matching closing fence: render the body (recursively, so - // nested callouts are handled) and emit the callout div. - const inner = await transform(bodyLines); - const renderedInner = await marked.parse(inner); - out.push(`\n
${renderedInner}
\n`); - i = j + 1; // skip past the closing `:::` - continue; - } - // No matching close (unterminated callout): treat the opener as a - // literal line and continue, preserving the original text. - out.push(line); - i++; - continue; - } - out.push(line); - i++; - } - return out.join("\n"); - }; - return transform(markdown.split("\n")); -} -/** - * Bridge marked's checkbox lists to TipTap task lists. - * - * marked renders GitHub task list items (`- [x] done`) as a plain - * `
  • text

` WITHOUT the - * markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists - * into the shape those extensions expect: - * TaskList parseHTML matches `ul[data-type="taskList"]`, - * TaskItem matches `li[data-type="taskItem"]`, - * the checked state is read from `data-checked === "true"`. - * - * A list is only converted when it has at least one `
  • ` and EVERY direct - * `
  • ` contains a checkbox input. Both `
      ` and `
        ` are considered: a - * numbered checklist (`1. [x] a`, which marked renders as an `
          ` of checkbox - * `
        1. `s) would otherwise lose its task state. TipTap task lists are unordered, - * so a matching `
            ` is emitted as `data-type="taskList"` exactly like a - * `
              `. Mixed or ordinary lists (including ordinary `
                ` lists) are left - * untouched so they keep rendering as bullet/numbered lists. The marked `

                ` - * wrapper is kept inside the `

              1. ` because TaskItem content allows paragraphs. - */ -function bridgeTaskLists(html) { - // Cheap early-out: if the markup contains no checkbox input at all there is - // nothing to bridge, so skip the expensive JSDOM parse entirely. This is the - // common case (most pages have no task lists). - if (!/type=["']?checkbox/i.test(html)) { - return html; - } - // Defensive cap (consistent with preprocessCallouts): skip the bridge for - // pathologically large inputs rather than running a second expensive JSDOM - // parse on a multi-megabyte payload. The markup is passed through verbatim. - if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) { - return html; - } - const dom = new JSDOM(html); - const document = dom.window.document; - // Collect the checkbox(es) that belong to THIS
              2. directly: either direct - // child elements or ones inside the
              3. 's direct

                - // child (the shape marked emits: `

              4. text

              5. `). - // Checkboxes nested deeper (e.g. inside a child
                  /
                    ) are excluded so a - // bullet
                  1. that merely contains a nested task sublist is not misdetected. - // Raw inline HTML can put more than one checkbox in a single
                  2. ; we gather - // ALL of them so none survive into the converted item. - const directCheckboxes = (li) => { - const found = []; - for (const child of Array.from(li.children)) { - if (child.tagName === "INPUT" && - child.getAttribute("type") === "checkbox") { - found.push(child); - continue; - } - if (child.tagName === "P") { - for (const inp of Array.from(child.querySelectorAll(":scope > input[type='checkbox']"))) { - found.push(inp); - } - } - } - return found; - }; - // Both
                      and
                        are candidates: an
                          whose every direct
                        1. carries - // its own checkbox is a numbered checklist that must also become a taskList. - const lists = Array.from(document.querySelectorAll("ul, ol")); - for (const list of lists) { - // Only consider DIRECT child
                        2. elements; nested lists are handled by - // their own iteration of the outer loop. - const items = Array.from(list.children).filter((child) => child.tagName === "LI"); - if (items.length === 0) - continue; - const itemCheckboxes = items.map((li) => directCheckboxes(li)); - // Convert only when every direct
                        3. carries at least one OWN checkbox. - if (!itemCheckboxes.every((boxes) => boxes.length > 0)) - continue; - // A numbered checklist arrives as an
                            . We must NOT leave the tag as - //
                              while tagging it data-type="taskList": generateJSON would then match - // BOTH the orderedList rule (tag ol) and the taskList rule (data-type), - // emitting a phantom empty orderedList beside the real taskList. So rename a - // qualifying
                                to a
                                  — move its
                                • children over and replace it — - // leaving only the taskList rule to match. Already-
                                    lists are unchanged. - let target = list; - if (list.tagName === "OL") { - const ul = document.createElement("ul"); - // Carry over existing attributes (e.g. class) so nothing is silently lost. - for (const attr of Array.from(list.attributes)) { - ul.setAttribute(attr.name, attr.value); - } - // Move every child node (including the
                                  • s we collected) into the
                                      . - while (list.firstChild) { - ul.appendChild(list.firstChild); - } - list.replaceWith(ul); - target = ul; - } - target.setAttribute("data-type", "taskList"); - items.forEach((li, index) => { - const boxes = itemCheckboxes[index]; - // The first checkbox determines the checked state (matches the previous - // single-checkbox behaviour); any extras only need removing. - const input = boxes[0] ?? null; - li.setAttribute("data-type", "taskItem"); - const checked = input != null && - (input.hasAttribute("checked") || input.checked); - li.setAttribute("data-checked", checked ? "true" : "false"); - // Remove ALL direct checkbox inputs so none survive into the content - // (a raw-inline-HTML
                                    • may carry more than one). - for (const box of boxes) { - box.remove(); - } - }); - } - return document.body.innerHTML; -} -// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline -// marker becomes , and `[^id]: text` -// definition lines are collected into a single
                                      . -const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/; -const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/; -function escapeFootnoteAttr(value) { - return String(value).replace(/&/g, "&").replace(/"/g, """); -} -function escapeFootnoteRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} -/** - * Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of - * an original id `X` during definition dedup. - * - * EXACT MIRROR of editor-ext `deriveFootnoteId` - * (packages/editor-ext/src/lib/footnote/footnote-util.ts). These two copies MUST - * STAY IN SYNC: the same markdown imported through the editor and through this - * MCP path has to produce identical ids, and the sync plugin (which re-ids on - * every collaborating client) relies on the same scheme to converge. NEVER use - * Math.random()/Date.now()/uuid here — a random id would diverge across clients. - * - * Scheme: base candidate `${originalId}__${occurrence}` (e.g. `X__2`), bumped - * with a stable alphabetic suffix (`X__2b`, `X__2c`, ...) until it is not in - * `taken` (the set of ids already present / already minted — pure doc state). - */ -function deriveFootnoteId(originalId, occurrence, taken) { - let candidate = `${originalId}__${occurrence}`; - let n = 0; - while (taken.has(candidate)) { - n += 1; - candidate = `${originalId}__${occurrence}${footnoteSuffix(n)}`; - } - return candidate; -} -/** Map 1 -> "b", 2 -> "c", ... (mirror of editor-ext `suffix`). */ -function footnoteSuffix(n) { - let out = ""; - let x = n; - while (x > 0) { - const rem = (x - 1) % 25; - out = String.fromCharCode(98 + rem) + out; // 98 = 'b' - x = Math.floor((x - 1) / 25); - } - return out; -} -const footnoteRefMarkedExtension = { - name: "footnoteRef", - level: "inline", - start(src) { - return src.match(/\[\^/)?.index ?? -1; - }, - tokenizer(src) { - const match = FOOTNOTE_REF_RE.exec(src); - if (match && match.index === 0) { - return { type: "footnoteRef", raw: match[0], id: match[1] }; - } - return undefined; - }, - renderer(token) { - return ``; - }, -}; -marked.use({ extensions: [footnoteRefMarkedExtension] }); -/** - * Pull `[^id]: text` definition lines out of the body and render a single - *
                                      for them (or "" when there are none). - */ -function extractFootnotes(markdown) { - const lines = markdown.split("\n"); - const bodyLines = []; - const defs = []; - // Track fenced-code state so a `[^id]: ...` line shown inside a ``` / ~~~ code - // block is preserved verbatim and not treated as a footnote definition. - let fence = null; - for (const line of lines) { - const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line); - if (fenceMatch) { - const marker = fenceMatch[2][0]; - if (fence === null) - fence = marker; - else if (marker === fence) - fence = null; - bodyLines.push(line); - continue; - } - const m = fence === null ? FOOTNOTE_DEF_RE.exec(line) : null; - if (m) - defs.push({ id: m[1], text: m[2] }); - else - bodyLines.push(line); - } - if (defs.length === 0) - return { body: markdown, section: "" }; - // De-duplicate colliding definition ids (mirror of editor-ext - // extractFootnoteDefinitions). Two definitions sharing an id would otherwise - // collapse into one footnote downstream; rename each colliding id to a - // DETERMINISTIC derived one (NOT random) and rewrite the corresponding `[^id]` - // marker so the (reference, definition) pairing stays 1:1. Determinism lets - // the same markdown imported here and via the editor produce identical ids. - let dedupedBody = bodyLines.join("\n"); - const taken = new Set(defs.map((d) => d.id)); - const seenDefIds = new Map(); - for (const def of defs) { - const originalId = def.id; - const count = seenDefIds.get(originalId) ?? 0; - seenDefIds.set(originalId, count + 1); - if (count === 0) - continue; // first definition keeps its id - const newId = deriveFootnoteId(originalId, count + 1, taken); - taken.add(newId); - def.id = newId; - // Remaining `[^originalId]` matches: index 0 = keeper's marker (left alone), - // index 1 = this duplicate's marker. Rewrite index 1. - let occurrence = 0; - let rewritten = false; - const re = new RegExp(`\\[\\^${escapeFootnoteRegExp(originalId)}\\]`, "g"); - dedupedBody = dedupedBody.replace(re, (match) => { - const idx = occurrence++; - if (!rewritten && idx === 1) { - rewritten = true; - return `[^${newId}]`; - } - return match; - }); - } - const inner = defs - .map((d) => `

                                      ${marked.parseInline(d.text || "")}

                                      `) - .join(""); - return { - body: dedupedBody, - section: `
                                      ${inner}
                                      `, - }; -} -/** Convert markdown to a ProseMirror doc using the full Docmost schema. */ -export async function markdownToProseMirror(markdownContent) { - const withCallouts = await preprocessCallouts(markdownContent); - const { body, section } = extractFootnotes(withCallouts); - const html = (await marked.parse(body)) + section; - const bridged = bridgeTaskLists(html); - return generateJSON(bridged, docmostExtensions); -} -/** - * Build the collaboration WebSocket URL from an API base URL: - * switch http(s)->ws(s), strip a trailing /api, mount on /collab. - * Shared by the live read and the mutate path so both target the same socket. - */ -export function buildCollabWsUrl(baseUrl) { - let wsUrl = baseUrl.replace(/^http/, "ws"); - try { - const urlObj = new URL(wsUrl); - if (urlObj.pathname.endsWith("/api") || urlObj.pathname.endsWith("/api/")) { - urlObj.pathname = urlObj.pathname.replace(/\/api\/?$/, ""); - } - urlObj.pathname = urlObj.pathname.replace(/\/$/, "") + "/collab"; - // Drop any query/hash from the base URL so it is not carried into the - // collaboration ws URL. - urlObj.search = ""; - urlObj.hash = ""; - wsUrl = urlObj.toString(); - } - catch (e) { - // Fallback if URL parsing fails - if (!wsUrl.endsWith("/collab")) { - wsUrl = wsUrl.replace(/\/$/, "") + "/collab"; - } - } - return wsUrl; -} -/** - * Encode a ProseMirror doc to a Yjs document, sanitizing it first and turning - * the opaque yjs "Unexpected content type" failure into a descriptive error. - * - * `sanitizeForYjs` strips `undefined` node/mark attributes (the common cause of - * the failure); if `toYdoc` still throws, `findUnstorableAttr` is used to point - * at the offending attribute path. - */ -export function buildYDoc(doc) { - const safe = sanitizeForYjs(doc); - try { - return TiptapTransformer.toYdoc(safe, "default", docmostExtensions); - } - catch (e) { - const bad = findUnstorableAttr(safe); - throw new Error(`Failed to encode document to Yjs (toYdoc): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`); - } -} -/** - * Validate that a doc is Yjs-encodable by building (and discarding) a Y.Doc. - * Throws the same descriptive error as the apply path when it is not. Used by - * the dry-run preview so it fails identically to apply. - */ -export function assertYjsEncodable(doc) { - buildYDoc(doc); -} -/** Time we wait for the initial handshake/sync before giving up. */ -const CONNECT_TIMEOUT_MS = 25000; -/** Time we wait for the server to acknowledge our write before giving up. */ -const PERSIST_TIMEOUT_MS = 20000; -/** - * Safely mutate the live content of a page over the collaboration websocket. - * - * This is the single safe write path for every MCP content mutation. It: - * 1. serializes per-page writes through withPageLock (no two MCP writes on - * the same page overlap); - * 2. connects to Hocuspocus and waits for the initial sync so the local ydoc - * mirrors the authoritative server doc — INCLUDING edits/comments/images - * that are not yet in the debounced REST snapshot; - * 3. inside onSynced, SYNCHRONOUSLY reads the live doc, runs `transform`, and - * writes the result back — with no `await` between read and write so no - * remote update can interleave and clobber concurrent human edits; - * 4. waits for the server to acknowledge the write (unsyncedChanges -> 0) - * before resolving, so the next operation observes our change. - * - * `transform` receives the live ProseMirror doc and returns the NEW full - * ProseMirror doc to write, or `null` to abort with no write (a no-op). If - * `transform` throws, the error is propagated to the caller (not swallowed). - * - * Resolves a `MutationResult { doc, verify }`: `doc` is the doc that was - * written (or the live doc when the transform aborted), and `verify` is a - * verifiable change report (text/block/mark deltas) of what actually changed. - * The report is computed AFTER the atomic read->write, so it never widens the - * read->write window, and it never throws (it can NEVER break a write). - */ -export async function mutatePageContent(pageId, collabToken, baseUrl, transform) { - return withPageLock(pageId, () => { - if (process.env.DEBUG) { - console.error(`Starting realtime content mutate for page ${pageId}`); - // Token prefix is sensitive; only log it under DEBUG. - console.error(`Token prefix: ${collabToken ? collabToken.substring(0, 5) : "NONE"}...`); - } - const ydoc = new Y.Doc(); - const wsUrl = buildCollabWsUrl(baseUrl); - if (process.env.DEBUG) - console.error(`Connecting to WebSocket: ${wsUrl}`); - return new Promise((resolve, reject) => { - let provider; - let applied = false; // onSynced may fire again on reconnect — apply once. - let settled = false; - // Set true on disconnect/close so a reconnect-driven unsyncedChanges->0 - // cannot be mistaken for a successful persist of our write. - let connectionLost = false; - let connectTimer; - let persistTimer; - let unsyncedHandler; - const cleanup = () => { - if (connectTimer) - clearTimeout(connectTimer); - if (persistTimer) - clearTimeout(persistTimer); - if (provider) { - if (unsyncedHandler) { - try { - provider.off("unsyncedChanges", unsyncedHandler); - } - catch (err) { } - } - try { - provider.destroy(); - } - catch (err) { } - } - }; - const finish = (err, value) => { - if (settled) - return; - settled = true; - cleanup(); - if (err) - reject(err); - else - resolve(value); - }; - connectTimer = setTimeout(() => { - finish(new Error("Connection timeout to collaboration server")); - }, CONNECT_TIMEOUT_MS); - // Resolve once the server has acknowledged our update. The provider - // increments unsyncedChanges when our local update is sent and - // decrements it when the server replies with a SyncStatus(applied=true); - // reaching 0 means the authoritative in-memory ydoc on the server now - // contains our write. - const waitForPersistence = () => { - if (settled) - return; - // A missing provider is a failure, not a success: without it the write - // can never have been acknowledged. Only an actual unsyncedChanges===0 - // on a live provider counts as persisted. - if (!provider) { - finish(new Error("collab provider gone before persistence")); - return; - } - if (provider.unsyncedChanges === 0) { - finish(null, mutationResult); - return; - } - persistTimer = setTimeout(() => { - finish(new Error("Timeout waiting for collaboration server to persist the update")); - }, PERSIST_TIMEOUT_MS); - unsyncedHandler = (data) => { - // Only treat unsyncedChanges->0 as success when the connection is - // still up. A transient disconnect + reconnect handshake can drive - // the counter back to 0 without our write being re-transmitted; in - // that case let the disconnect/close error win instead. - if (data.number === 0 && !connectionLost) { - finish(null, mutationResult); - } - }; - provider.on("unsyncedChanges", unsyncedHandler); - }; - // The verifiable result resolved on every success/abort path. Set on - // abort (no-op report) and after a real write (computed change report). - let mutationResult; - provider = new HocuspocusProvider({ - url: wsUrl, - name: `page.${pageId}`, - document: ydoc, - token: collabToken, - // @ts-ignore - Required for Node.js environment - WebSocketPolyfill: WebSocket, - onConnect: () => { - if (process.env.DEBUG) - console.error("WS Connect"); - }, - // An unexpected disconnect/close while we are still waiting (during the - // connect-wait before onSynced, or during the persistence wait after the - // write) means the update will never be acknowledged — surface it now - // instead of hanging until the connect/persist timeout fires. `finish` - // is idempotent via the `settled` flag, so the onClose that our own - // cleanup()->provider.destroy() triggers (after settled=true is set) is - // a harmless no-op and cannot cause a double-resolve. - onDisconnect: () => { - if (process.env.DEBUG) - console.error("WS Disconnect"); - // Mark BEFORE finish so the unsyncedChanges handler (if it races) - // sees the connection as lost and won't report a false success. - connectionLost = true; - finish(new Error("Collaboration connection closed before the update was persisted/synced")); - }, - onClose: () => { - if (process.env.DEBUG) - console.error("WS Close"); - // Mark BEFORE finish so the unsyncedChanges handler (if it races) - // sees the connection as lost and won't report a false success. - connectionLost = true; - finish(new Error("Collaboration connection closed before the update was persisted/synced")); - }, - onSynced: () => { - if (applied || settled) - return; - applied = true; - if (process.env.DEBUG) - console.error("Connected and synced!"); - // CRITICAL: everything between reading the live doc and writing it - // back must stay synchronous (no await). While the JS event loop is - // not yielded, no incoming remote update can interleave, so any - // already-synced concurrent edits are preserved in liveDoc. - let newDoc; - let beforeDoc; - try { - let liveDoc = TiptapTransformer.fromYdoc(ydoc, "default"); - if (!liveDoc || - typeof liveDoc !== "object" || - !Array.isArray(liveDoc.content)) { - liveDoc = { type: "doc", content: [] }; - } - // Snapshot the before-doc for the change report. Docs are - // JSON-serializable, so this is a safe deep clone. - beforeDoc = JSON.parse(JSON.stringify(liveDoc)); - newDoc = transform(liveDoc); - if (newDoc == null) { - // Transform aborted — write nothing, return the live doc with a - // no-op change report. - mutationResult = { - doc: liveDoc, - verify: { - changed: false, - textInserted: 0, - textDeleted: 0, - blocksChanged: 0, - marks: {}, - summary: "no changes (transform aborted)", - }, - }; - finish(null, mutationResult); - return; - } - const tempDoc = buildYDoc(newDoc); - // Fetch the fragment immediately before the transact that mutates - // it, rather than reusing a handle grabbed across the transform. - const fragment = ydoc.getXmlFragment("default"); - ydoc.transact(() => { - if (fragment.length > 0) { - fragment.delete(0, fragment.length); - } - Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(tempDoc)); - }); - } - catch (e) { - // Includes errors thrown by transform (e.g. "afterText not found", - // "text not found"): propagate them verbatim to the caller. - finish(e instanceof Error ? e : new Error(String(e))); - return; - } - // Compute the verifiable change report AFTER the transact write: it - // only needs the JSON before/after, so it cannot affect the atomic - // read->write window, and summarizeChange never throws. - mutationResult = { - doc: newDoc, - verify: summarizeChange(beforeDoc, newDoc), - }; - if (process.env.DEBUG) - console.error("Content written, waiting for server to persist..."); - waitForPersistence(); - }, - onAuthenticationFailed: () => { - finish(new Error("Authentication failed for collaboration connection")); - }, - }); - }); - }); -} -/** - * Replace the live content of a page over the collaboration websocket. - * Accepts a ready ProseMirror JSON document; the caller controls whether - * it was produced from markdown (ids regenerate) or edited in place - * (existing block ids preserved). - * - * This is an intentional full replace (used by update_page / update_page_json), - * but now runs under the per-page lock and waits for server persistence via - * mutatePageContent. - */ -export async function replacePageContent(pageId, prosemirrorDoc, collabToken, baseUrl) { - // Fail fast on a bad document instead of deferring the failure into the - // collaboration write (where TiptapTransformer.toYdoc(undefined) used to - // throw). The transform must return a valid ProseMirror doc. - if (prosemirrorDoc == null || - typeof prosemirrorDoc !== "object" || - prosemirrorDoc.type !== "doc") { - throw new Error("replacePageContent: invalid ProseMirror document"); - } - return await mutatePageContent(pageId, collabToken, baseUrl, () => prosemirrorDoc); -} -/** - * Markdown update path (kept for backwards compatibility). - * NOTE: this re-imports the whole document — block ids are regenerated. - * Tables and :::callout::: blocks survive thanks to the full schema. - */ -export async function updatePageContentRealtime(pageId, markdownContent, collabToken, baseUrl) { - const tiptapJson = await markdownToProseMirror(markdownContent); - return await mutatePageContent(pageId, collabToken, baseUrl, () => tiptapJson); -} diff --git a/packages/mcp/build/lib/comment-anchor.js b/packages/mcp/build/lib/comment-anchor.js deleted file mode 100644 index 50e113b2..00000000 --- a/packages/mcp/build/lib/comment-anchor.js +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Inline-comment anchoring against a ProseMirror document. - * - * Docmost stores an inline comment's highlight as a `comment` MARK on the - * document text (`{ type: "comment", attrs: { commentId, resolved } }`); the - * `/comments/create` API only records the comment row + its `selection` text and - * does NOT insert that mark, so the anchor has to be written into the page - * content separately. This module finds where a selection lives in the document - * and splices the comment mark across the matched range. - * - * Matching has to be robust because the agent supplies the selection as plain - * text while the document stores rich inline content: a selection can span - * several adjacent text nodes (inline code / bold / links each become their own - * text node), and the document may use smart/typographic quotes, dash variants, - * non-breaking spaces, or collapsed runs of whitespace that the agent typed as - * ASCII quotes/hyphens/single spaces. We therefore normalize both sides before - * comparing and match across maximal runs of consecutive text nodes within a - * single block, while mapping every normalized character back to its raw index - * so the mark lands on the exact original characters. - */ -/** Typographic double-quote variants mapped to ASCII `"`. */ -const DOUBLE_QUOTES = "«»„“”‟〝〞""; -/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */ -const SINGLE_QUOTES = "‘’‚‛"; -/** Dash variants mapped to ASCII `-`. */ -const DASHES = "–—―−‐‑‒"; -/** Guard against pathological/cyclic documents in the depth-first walk. */ -const MAX_DEPTH = 200; -/** The comment mark Docmost stores on anchored text. */ -function makeCommentMark(commentId) { - // The comment mark schema declares both commentId and resolved; include - // resolved:false for completeness so the stored mark matches the editor's. - return { type: "comment", attrs: { commentId, resolved: false } }; -} -/** True for any character we collapse/replace with a single normal space. */ -function isWhitespaceChar(ch) { - // Regular ASCII whitespace plus the special spaces called out in the spec: - // nbsp, narrow nbsp, en/em/thin/hair/figure spaces, etc. \s covers tab and - // newline; the explicit code points cover the non-breaking variants \s misses - // in some engines, so list them for determinism. - return (/\s/.test(ch) || - ch === " " || // no-break space - ch === " " || // figure space - ch === " " || // narrow no-break space - ch === " " || // thin space - ch === " " || // hair space - ch === " " || // en space - ch === " " // em space - ); -} -/** - * Normalize a string for matching and return both the normalized text and a - * `map` where `map[i]` is the index into the ORIGINAL `s` of the i-th - * normalized character. - * - * Rules: map smart quotes / dashes / special spaces to their ASCII forms, - * collapse any run of whitespace to a SINGLE space (whose map entry points at - * the FIRST raw whitespace char of the run), and DO NOT lowercase (anchoring is - * case-sensitive to match the exact document text). - */ -export function normalizeForMatch(s) { - let norm = ""; - const map = []; - let i = 0; - while (i < s.length) { - const ch = s[i]; - if (isWhitespaceChar(ch)) { - // Collapse the whole whitespace run to one space mapped to the run start. - const runStart = i; - while (i < s.length && isWhitespaceChar(s[i])) - i++; - norm += " "; - map.push(runStart); - continue; - } - let mapped = ch; - if (DOUBLE_QUOTES.indexOf(ch) !== -1) - mapped = '"'; - else if (SINGLE_QUOTES.indexOf(ch) !== -1) - mapped = "'"; - else if (DASHES.indexOf(ch) !== -1) - mapped = "-"; - norm += mapped; - map.push(i); - i++; - } - return { norm, map }; -} -/** - * Find a selection inside a SINGLE block's direct `content` array. - * - * Builds maximal runs of consecutive `text` nodes (any non-text inline node, - * e.g. a mention, breaks the run), normalizes each run and the selection the - * same way, then searches each run for the normalized selection. Returns the - * child/offset range of the FIRST matching run, or `null` if none match. - */ -export function findAnchorInBlock(blockContent, selection) { - if (!Array.isArray(blockContent)) - return null; - const normSelObj = normalizeForMatch(selection); - // Trim leading/trailing spaces on the NORMALIZED selection only. - const normSel = normSelObj.norm.trim(); - if (normSel.length === 0) - return null; - let i = 0; - while (i < blockContent.length) { - const node = blockContent[i]; - if (!node || typeof node !== "object" || node.type !== "text") { - i++; - continue; - } - // Accumulate a maximal run of consecutive text nodes. - let rawRun = ""; - const rawToChild = []; - let j = i; - while (j < blockContent.length) { - const n = blockContent[j]; - if (!n || typeof n !== "object" || n.type !== "text") - break; - const text = typeof n.text === "string" ? n.text : ""; - for (let k = 0; k < text.length; k++) { - rawToChild.push({ childIdx: j, offset: k }); - } - rawRun += text; - j++; - } - // Try to match within this run. - const { norm, map } = normalizeForMatch(rawRun); - const idx = norm.indexOf(normSel); - if (idx !== -1) { - const rawStart = map[idx]; - const rawEndExclusive = idx + normSel.length < map.length - ? map[idx + normSel.length] - : rawRun.length; - const startLoc = rawToChild[rawStart]; - // rawEndExclusive points at the raw char AFTER the match; the last matched - // raw char is at rawEndExclusive-1, so endOffset is its offset + 1. - const lastLoc = rawToChild[rawEndExclusive - 1]; - return { - startChild: startLoc.childIdx, - startOffset: startLoc.offset, - endChild: lastLoc.childIdx, - endOffset: lastLoc.offset + 1, - }; - } - // No match in this run: continue scanning AFTER it. - i = j > i ? j : i + 1; - } - return null; -} -/** - * Depth-first, document-order check for whether `selection` can be anchored - * anywhere in `doc`. At each node with an array `content`, first try to match - * within that node's own content, then recurse into children that themselves - * have a `content` array. - */ -export function canAnchorInDoc(doc, selection) { - const visit = (node, depth) => { - if (depth > MAX_DEPTH || !node || typeof node !== "object") - return false; - if (!Array.isArray(node.content)) - return false; - if (findAnchorInBlock(node.content, selection)) - return true; - for (const child of node.content) { - if (child && typeof child === "object" && Array.isArray(child.content)) { - if (visit(child, depth + 1)) - return true; - } - } - return false; - }; - return visit(doc, 0); -} -/** - * Split the matched text nodes and splice the comment mark across the range. - * `blockContent` is mutated IN PLACE. `match.startChild..endChild` are all text - * nodes (guaranteed by findAnchorInBlock building runs of text nodes). - */ -function spliceCommentMark(blockContent, match, commentId) { - const { startChild, startOffset, endChild, endOffset } = match; - const commentMark = makeCommentMark(commentId); - const fragments = []; - for (let k = startChild; k <= endChild; k++) { - const n = blockContent[k]; - const text = typeof n.text === "string" ? n.text : ""; - const sliceStart = k === startChild ? startOffset : 0; - const sliceEnd = k === endChild ? endOffset : text.length; - const before = k === startChild ? text.slice(0, startOffset) : ""; - const marked = text.slice(sliceStart, sliceEnd); - const after = k === endChild ? text.slice(endOffset) : ""; - // Process per-node so each node's OWN marks/attrs are preserved. - const ownMarks = Array.isArray(n.marks) ? n.marks : []; - // Drop any pre-existing comment mark from the marked fragment so it ends up - // with exactly one comment mark (the new one) rather than two. - const markedBaseMarks = ownMarks.filter((m) => !(m && m.type === "comment")); - if (before.length > 0) { - fragments.push({ ...n, text: before, marks: [...ownMarks] }); - } - if (marked.length > 0) { - fragments.push({ - ...n, - text: marked, - marks: [...markedBaseMarks, commentMark], - }); - } - if (after.length > 0) { - fragments.push({ ...n, text: after, marks: [...ownMarks] }); - } - } - blockContent.splice(startChild, endChild - startChild + 1, ...fragments); -} -/** - * Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block - * whose content matches `selection`, splice the comment mark across the matched - * range in place and return true. Returns false (and does NOT mutate) when no - * block matches. - */ -export function applyAnchorInDoc(doc, selection, commentId) { - const visit = (node, depth) => { - if (depth > MAX_DEPTH || !node || typeof node !== "object") - return false; - if (!Array.isArray(node.content)) - return false; - const match = findAnchorInBlock(node.content, selection); - if (match) { - spliceCommentMark(node.content, match, commentId); - return true; - } - for (const child of node.content) { - if (child && typeof child === "object" && Array.isArray(child.content)) { - if (visit(child, depth + 1)) - return true; - } - } - return false; - }; - return visit(doc, 0); -} diff --git a/packages/mcp/build/lib/diff.js b/packages/mcp/build/lib/diff.js deleted file mode 100644 index 516a3c81..00000000 --- a/packages/mcp/build/lib/diff.js +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Headless, Docmost-equivalent document diff. - * - * Docmost's history editor computes a change set with the exact pipeline below - * (recreateTransform -> ChangeSet.addSteps -> simplifyChanges) and renders it as - * editor decorations. This module runs the SAME computation but serializes the - * result to text + integrity counts instead of decorations, so a diff can be - * previewed without a browser. - * - * recreateTransform here comes from @fellow/prosemirror-recreate-transform, the - * maintained published fork of the MIT prosemirror-recreate-steps source that - * Docmost vendors in @docmost/editor-ext; it exposes the identical - * recreateTransform(fromDoc, toDoc, { complexSteps, wordDiffs, simplifyDiff }) - * signature. - * - * If recreateTransform / the changeset throws on a pathological document pair, - * we fall back to a coarse block-level text diff so the tool never hard-fails. - */ -import { getSchema } from "@tiptap/core"; -import { Node } from "@tiptap/pm/model"; -import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; -import { recreateTransform } from "@fellow/prosemirror-recreate-transform"; -import { docmostExtensions } from "./docmost-schema.js"; -/** Build the schema once; it is pure and reused across calls. */ -const schema = getSchema(docmostExtensions); -/** Recursively concatenate the plain text of a JSON node. */ -function plainText(node) { - if (!node || typeof node !== "object") - return ""; - let out = ""; - if (typeof node.text === "string") - out += node.text; - if (Array.isArray(node.content)) { - for (const child of node.content) - out += plainText(child); - } - return out; -} -/** Count nodes in a JSON doc that satisfy `pred` (recursive). */ -function countNodes(doc, pred) { - let n = 0; - const visit = (node) => { - if (!node || typeof node !== "object") - return; - if (pred(node)) - n++; - if (Array.isArray(node.content)) - for (const c of node.content) - visit(c); - }; - visit(doc); - return n; -} -/** - * Count UNIQUE links in a JSON doc by their `href`. A single link can be split - * across several adjacent text runs (e.g. a "link+bold" run followed by a "link" - * run); counting link-bearing runs would over-count it. Walking the tree and - * collecting hrefs into a Set keys each distinct link once. Link marks with a - * missing/empty href are bucketed under a single "" key so a malformed link is - * still counted as one. - */ -function countUniqueLinks(doc) { - const hrefs = new Set(); - const visit = (node) => { - if (!node || typeof node !== "object") - return; - if (node.type === "text" && Array.isArray(node.marks)) { - for (const m of node.marks) { - if (m && m.type === "link") { - const href = m.attrs && typeof m.attrs.href === "string" ? m.attrs.href : ""; - hrefs.add(href); - } - } - } - if (Array.isArray(node.content)) - for (const c of node.content) - visit(c); - }; - visit(doc); - return hrefs.size; -} -/** Count footnoteReference nodes anywhere under a node (reading order). */ -function countFootnoteRefs(node) { - if (!node || typeof node !== "object") - return 0; - let n = node.type === "footnoteReference" ? 1 : 0; - if (Array.isArray(node.content)) { - for (const child of node.content) - n += countFootnoteRefs(child); - } - return n; -} -/** - * Ordered list of footnote marker numbers found in the BODY only (every - * top-level block before the first "Примечания..." notes heading; if no such - * heading, the whole doc), in reading order. - * - * Supports BOTH representations: - * - real `footnoteReference` nodes (the current footnote feature) — numbered - * 1..n by reading position, since their visible number is derived; - * - legacy `[N]` text markers (older translated docs) — the literal N. - */ -function footnoteMarkers(doc, notesHeading) { - const top = Array.isArray(doc?.content) ? doc.content : []; - const notesIdx = top.findIndex((n) => n && - n.type === "heading" && - plainText(n).trim() === notesHeading); - const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top; - // Real footnoteReference nodes take precedence: when present, number them by - // reading position (their displayed number is not stored). - let refCount = 0; - for (const block of bodyBlocks) - refCount += countFootnoteRefs(block); - if (refCount > 0) { - return Array.from({ length: refCount }, (_, i) => i + 1); - } - // Fallback: legacy `[N]` text markers. - const markers = []; - const re = /\[(\d+)\]/g; - for (const block of bodyBlocks) { - const text = plainText(block); - let m; - re.lastIndex = 0; - while ((m = re.exec(text)) !== null) { - markers.push(Number(m[1])); - } - } - return markers; -} -/** Compute the [old,new] integrity tuples for two JSON docs. */ -function computeIntegrity(oldDoc, newDoc, notesHeading) { - const images = [ - countNodes(oldDoc, (n) => n.type === "image"), - countNodes(newDoc, (n) => n.type === "image"), - ]; - const links = [ - countUniqueLinks(oldDoc), - countUniqueLinks(newDoc), - ]; - const tables = [ - countNodes(oldDoc, (n) => n.type === "table"), - countNodes(newDoc, (n) => n.type === "table"), - ]; - const callouts = [ - countNodes(oldDoc, (n) => n.type === "callout"), - countNodes(newDoc, (n) => n.type === "callout"), - ]; - const fns = [ - footnoteMarkers(oldDoc, notesHeading), - footnoteMarkers(newDoc, notesHeading), - ]; - return { images, links, tables, callouts, footnoteMarkers: fns }; -} -/** - * Resolve the lead text of the top-level block in a ProseMirror Node that - * contains the given document position. Returns "" when out of range. - */ -function blockContextAt(node, pos) { - try { - const clamped = Math.max(0, Math.min(pos, node.content.size)); - const $pos = node.resolve(clamped); - // depth 1 is the top-level block in a doc node. - const block = $pos.depth >= 1 ? $pos.node(1) : $pos.node(0); - const text = block.textContent || ""; - return text.length > 80 ? text.slice(0, 77) + "..." : text; - } - catch { - return ""; - } -} -/** Truncate a string for the markdown summary. */ -function truncate(s, n = 120) { - return s.length > n ? s.slice(0, n - 3) + "..." : s; -} -/** - * Coarse fallback: a block-by-block plain-text diff. Used only when the precise - * changeset pipeline throws, so the tool degrades gracefully instead of failing. - */ -function coarseDiff(oldDoc, newDoc) { - const oldBlocks = Array.isArray(oldDoc?.content) ? oldDoc.content : []; - const newBlocks = Array.isArray(newDoc?.content) ? newDoc.content : []; - const oldTexts = oldBlocks.map(plainText); - const newTexts = newBlocks.map(plainText); - const oldSet = new Set(oldTexts); - const newSet = new Set(newTexts); - const changes = []; - for (const t of oldTexts) { - if (!newSet.has(t) && t.trim() !== "") { - changes.push({ op: "delete", block: truncate(t, 80), text: t }); - } - } - for (const t of newTexts) { - if (!oldSet.has(t) && t.trim() !== "") { - changes.push({ op: "insert", block: truncate(t, 80), text: t }); - } - } - return changes; -} -/** Build the human-readable unified-ish markdown summary. */ -function renderMarkdown(result, fellBack) { - const lines = []; - const { summary, integrity, changes } = result; - lines.push(`# Diff: ${summary.inserted} inserted / ${summary.deleted} deleted (${summary.blocksChanged} blocks changed)`); - if (fellBack) { - lines.push(""); - lines.push("> note: precise diff failed; coarse block-level diff shown."); - } - lines.push(""); - lines.push("## Integrity (old -> new)"); - lines.push(`- images: ${integrity.images[0]} -> ${integrity.images[1]}`); - lines.push(`- links: ${integrity.links[0]} -> ${integrity.links[1]}`); - lines.push(`- tables: ${integrity.tables[0]} -> ${integrity.tables[1]}`); - lines.push(`- callouts: ${integrity.callouts[0]} -> ${integrity.callouts[1]}`); - lines.push(`- footnoteMarkers: [${integrity.footnoteMarkers[0].join(", ")}] -> [${integrity.footnoteMarkers[1].join(", ")}]`); - lines.push(""); - lines.push("## Changes"); - if (changes.length === 0) { - lines.push("(no textual changes)"); - } - else { - for (const c of changes) { - const sign = c.op === "insert" ? "+" : "-"; - const ctx = c.block ? ` @ ${truncate(c.block, 60)}` : ""; - lines.push(`${sign} ${truncate(c.text)}${ctx}`); - } - } - return lines.join("\n"); -} -/** - * Diff two ProseMirror JSON documents the way Docmost's history editor does and - * serialize the result to text + integrity counts. - * - * @param oldDocJson the earlier document - * @param newDocJson the later document - * @param notesHeading heading delimiting body from notes for footnote counting - */ -export function diffDocs(oldDocJson, newDocJson, notesHeading = "Примечания переводчика") { - const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading); - let changes = []; - let inserted = 0; - let deleted = 0; - let fellBack = false; - const changedBlocks = new Set(); - try { - const oldNode = Node.fromJSON(schema, oldDocJson); - const newNode = Node.fromJSON(schema, newDocJson); - const tr = recreateTransform(oldNode, newNode, { - complexSteps: false, - wordDiffs: true, - simplifyDiff: true, - }); - const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []); - const simplified = simplifyChanges(changeSet.changes, newNode); - for (const change of simplified) { - // Deleted text lives in the OLD doc coordinate range [fromA, toA). - if (change.toA > change.fromA) { - const text = oldNode.textBetween(change.fromA, change.toA, "\n", " "); - if (text.length > 0) { - deleted += text.length; - const block = blockContextAt(oldNode, change.fromA); - changes.push({ op: "delete", block, text }); - if (block) - changedBlocks.add("d:" + block); - } - } - // Inserted text lives in the NEW doc coordinate range [fromB, toB). - if (change.toB > change.fromB) { - const text = newNode.textBetween(change.fromB, change.toB, "\n", " "); - if (text.length > 0) { - inserted += text.length; - const block = blockContextAt(newNode, change.fromB); - changes.push({ op: "insert", block, text }); - if (block) - changedBlocks.add("i:" + block); - } - } - } - } - catch { - // Pathological pair: degrade to a coarse block-level diff so we never throw. - fellBack = true; - changes = coarseDiff(oldDocJson, newDocJson); - for (const c of changes) { - if (c.op === "insert") - inserted += c.text.length; - else - deleted += c.text.length; - if (c.block) - changedBlocks.add(c.op[0] + ":" + c.block); - } - } - const partial = { - summary: { inserted, deleted, blocksChanged: changedBlocks.size }, - integrity, - changes, - }; - return { ...partial, markdown: renderMarkdown(partial, fellBack) }; -} -/** - * Recursively walk every `text` node and tally the count of each mark by - * `mark.type` (e.g. `{ bold: 5, strike: 3, link: 2 }`). Pure and never throws. - */ -function markCounts(doc) { - const counts = {}; - const visit = (node) => { - if (!node || typeof node !== "object") - return; - if (node.type === "text" && Array.isArray(node.marks)) { - for (const m of node.marks) { - if (m && typeof m.type === "string") { - counts[m.type] = (counts[m.type] || 0) + 1; - } - } - } - if (Array.isArray(node.content)) - for (const c of node.content) - visit(c); - }; - visit(doc); - return counts; -} -/** - * Build a VerifyReport for a content mutation. Pure and never throws — on any - * internal error it returns a minimal "changed (diff unavailable)" report so it - * can NEVER break a write. - * - * `changed` is VALUE-based, not JSON-string-based: it is derived from the actual - * deltas (text chars, blocks, mark counts, structural integrity counts), so two - * value-equal docs that differ only in JSON key order report cleanly as - * `changed:false` / "no content change" rather than a misleading +0/-0 change. - * - * The structural integrity delta (from diffDocs's `integrity` tuples) is what - * makes `changed` true for an image/table/callout/link count change that diffs - * to zero text — closing a verify blind spot for insert_image, delete_node on a - * table, etc. - */ -export function summarizeChange(before, after) { - try { - const diff = diffDocs(before, after); - // Per-mark-type delta: include a type only when its count actually changed. - const beforeMarks = markCounts(before); - const afterMarks = markCounts(after); - const marks = {}; - for (const type of new Set([ - ...Object.keys(beforeMarks), - ...Object.keys(afterMarks), - ])) { - const b = beforeMarks[type] || 0; - const a = afterMarks[type] || 0; - if (b !== a) - marks[type] = [b, a]; - } - // Structural integrity delta from diffDocs: count-based [old,new] tuples for - // images/links/tables/callouts. Include a type only when old != new. - const integrity = diff.integrity; - const structure = {}; - const countTypes = [ - "images", - "links", - "tables", - "callouts", - ]; - for (const type of countTypes) { - const [b, a] = integrity[type]; - if (b !== a) - structure[type] = [b, a]; - } - const textInserted = diff.summary.inserted; - const textDeleted = diff.summary.deleted; - const blocksChanged = diff.summary.blocksChanged; - const hasMarkDelta = Object.keys(marks).length > 0; - const hasStructureDelta = Object.keys(structure).length > 0; - // VALUE-based change decision: ignore JSON key-order no-ops entirely. - const changed = textInserted > 0 || - textDeleted > 0 || - blocksChanged > 0 || - hasMarkDelta || - hasStructureDelta; - if (!changed) { - return { - changed: false, - textInserted: 0, - textDeleted: 0, - blocksChanged: 0, - marks: {}, - summary: "no content change", - }; - } - const parts = []; - // Only mention text/blocks when they actually changed (avoid a misleading - // "+0/-0 chars, 0 block(s)" prefix on a pure mark/structure change). - if (textInserted > 0 || textDeleted > 0 || blocksChanged > 0) { - parts.push(`+${textInserted}/-${textDeleted} chars, ${blocksChanged} block(s)`); - } - const markParts = Object.entries(marks).map(([type, [b, a]]) => `${type} ${b}→${a}`); - if (markParts.length > 0) - parts.push(`marks: ${markParts.join(", ")}`); - const structureParts = Object.entries(structure).map(([type, [b, a]]) => `${type} ${b}→${a}`); - if (structureParts.length > 0) - parts.push(structureParts.join(", ")); - // `changed` is true here, so at least one group is present and parts is non-empty. - const summary = `changed: ${parts.join("; ")}`; - const report = { - changed: true, - textInserted, - textDeleted, - blocksChanged, - marks, - summary, - }; - if (hasStructureDelta) - report.structure = structure; - return report; - } - catch { - // A pathological pair must never break a write: degrade to a minimal report. - return { - changed: true, - textInserted: 0, - textDeleted: 0, - blocksChanged: 0, - marks: {}, - summary: "changed (diff unavailable)", - }; - } -} diff --git a/packages/mcp/build/lib/docmost-schema.js b/packages/mcp/build/lib/docmost-schema.js deleted file mode 100644 index 976e2d7f..00000000 --- a/packages/mcp/build/lib/docmost-schema.js +++ /dev/null @@ -1,1128 +0,0 @@ -/** - * Full TipTap extension set matching the real Docmost document schema. - * - * The default StarterKit-only schema silently destroys Docmost-specific - * nodes (callout, table) and drops attributes it does not know about - * (node ids, image sizing, link targets). Every code path that converts - * to or from ProseMirror JSON must use THIS set, otherwise a round-trip - * loses content. - */ -import StarterKit from "@tiptap/starter-kit"; -import Image from "@tiptap/extension-image"; -import TaskList from "@tiptap/extension-task-list"; -import TaskItem from "@tiptap/extension-task-item"; -import Highlight from "@tiptap/extension-highlight"; -import Subscript from "@tiptap/extension-subscript"; -import Superscript from "@tiptap/extension-superscript"; -import { Node, Extension, Mark } from "@tiptap/core"; -// Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this -// package can stay on the same @tiptap/core version as the editor and avoid a -// duplicate-tiptap version split in the monorepo. Reads a single declaration -// from an element's inline `style` attribute, last-wins, case-insensitive. -function getStyleProperty(element, propertyName) { - const styleAttr = element.getAttribute("style"); - if (!styleAttr) { - return null; - } - const decls = styleAttr.split(";").map((decl) => decl.trim()).filter(Boolean); - const target = propertyName.toLowerCase(); - for (let i = decls.length - 1; i >= 0; i -= 1) { - const decl = decls[i]; - const colonIndex = decl.indexOf(":"); - if (colonIndex === -1) { - continue; - } - const prop = decl.slice(0, colonIndex).trim().toLowerCase(); - if (prop === target) { - return decl.slice(colonIndex + 1).trim(); - } - } - return null; -} -/** Allowed Docmost callout types; anything else falls back to "info". */ -const CALLOUT_TYPES = ["info", "warning", "danger", "success"]; -export const clampCalloutType = (value) => value && CALLOUT_TYPES.includes(value.toLowerCase()) - ? value.toLowerCase() - : "info"; -/** - * Allowlist guard for CSS color values imported from HTML. - * - * Docmost interpolates stored mark colors straight into an inline style - * attribute (e.g. style="background-color: ${color}" / "color: ${color}"). - * An unsanitized value such as `red; --x: url(...)` or `red">