From d269bd9efe070cc2982451e35fee401924290fc9 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 20:10:38 +0300 Subject: [PATCH 1/8] =?UTF-8?q?feat(mcp):=20markdown=20=E2=80=94=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20=D0=BF=D0=BE=20=D1=83=D0=BC?= =?UTF-8?q?=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B1=D0=BB=D0=BE=D1=87=D0=BD=D1=8B=D1=85=20getNode/patchNo?= =?UTF-8?q?de/insertNode=20(#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Канонический конвертер (#293/#345/#351) зрел для блочного уровня: markdown становится дефолтом чтения/записи ОДНОГО блока, PM JSON — опция «для тонких работ». Новых тулов нет, поверхность не растёт. Имена уже camelCase (стоит на #412). - getNode(pageId, nodeId, format='markdown'): дефолт markdown — обёртка {type:doc,content:[node]} → convertProseMirrorToMarkdown, comment-якоря (ВКЛЮЧАЯ resolved) сохранены (это чтение под редактирование, не getPage). format:'json' — сырой сабтри. Авто-фолбэк не-топ-левел типов (tableRow/Cell/ Header по #) в JSON через canBeDocChild = docmostSchema.nodes.doc. contentMatch.matchType (не рукописный список); поле format на каждом ответе. - patchNode/insertNode: XOR-вход {markdown?|node?} (оба optional в схеме, XOR на рантайме). markdown → импорт фрагмента → 1→N сплайс: первый блок наследует id цели, остальные свежие; dry replaceNodeById для #159-ambiguity ДО сплайса; соседние блоки byte-identical. Guard findUnrepresentableTableAttrs: цель со span/colwidth/backgroundColor → отказ с указанием на table-тулы/node-JSON. insertNode — insertNodesRelative (N блоков по порядку); голый tableRow/Cell JSON-only. - Сноски: ^[...] во фрагменте → канон-импортёр; importMarkdownFragment делит блоки от footnotesList, РЕМАПИТ id сносок фрагмента в свежие uuid (fn-1 фрагмента не коллизит с fn-1 страницы), mergeFootnoteDefinitions добавляет через ту же машинерию appendDefinition→normalizeAndMergeFootnotes→ canonicalizeFootnotes, что insertFootnote. Сырые JSON-пути не тронуты. - node-ops: replaceNodeByIdWithMany/insertNodesRelative (сплайс массива) в prosemirror-markdown/node-ops (канон после #414) + barrel. - ROUTING_PROSE (READ/EDIT), compile-time client-call contract, CHANGELOG (getNode default→markdown, breaking для внешних клиентов, в окне #411/#412). Тесты: сходимость (patchNode(markdown) блок docsCanonicallyEqual полному импорту — нет «второго канона»); id-нить 1→N (первый наследует, остальные свежие, соседи byte-identical); XOR; getNode markdown/json/non-top-level-fallback; сохранение comment-якорей (active+resolved); ^[...]→хвостовой список+перенумерация; guard. mcp node --test 697/697; pmd vitest 736; tsc чисто; server jest 273. Третий линк breaking-окна, стоит на #412 (#411→#412→ЭТОТ→#415). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 + .../tools/ai-chat-tools.service.spec.ts | 29 +- .../ai-chat/tools/ai-chat-tools.service.ts | 18 +- packages/mcp/src/client.ts | 304 ++++++++++++-- packages/mcp/src/lib/markdown-fragment.ts | 243 +++++++++++ packages/mcp/src/lib/transforms.ts | 31 ++ packages/mcp/src/server-instructions.ts | 4 +- packages/mcp/src/tool-specs.ts | 148 +++++-- .../mcp/test/mock/ambiguous-node-id.test.mjs | 6 +- .../mcp/test/mock/get-node-format.test.mjs | 157 +++++++ .../mock/invalid-node-validation.test.mjs | 18 +- .../test/mock/markdown-patch-insert.test.mjs | 386 ++++++++++++++++++ .../mcp/test/unit/markdown-fragment.test.mjs | 121 ++++++ packages/mcp/test/unit/tool-specs.test.mjs | 68 +-- .../prosemirror-markdown/src/lib/index.ts | 2 + .../prosemirror-markdown/src/lib/node-ops.ts | 130 ++++++ .../test/node-ops-splice.test.ts | 119 ++++++ 17 files changed, 1681 insertions(+), 119 deletions(-) create mode 100644 packages/mcp/src/lib/markdown-fragment.ts create mode 100644 packages/mcp/test/mock/get-node-format.test.mjs create mode 100644 packages/mcp/test/mock/markdown-patch-insert.test.mjs create mode 100644 packages/mcp/test/unit/markdown-fragment.test.mjs create mode 100644 packages/prosemirror-markdown/test/node-ops-splice.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbdb63a..d9ad3d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `updatePageContent`). The total MCP tool count is unchanged (−1 / +1). The external names shown here are the post-#412 camelCase names. (#411) +- **`getNode` now returns Markdown by default (was ProseMirror JSON).** The + block-level read/write tools default to Markdown so a block round trip is + `getNode` (markdown) → edit → `patchNode` (markdown). `getNode` now returns + `{ …, format: "markdown", markdown }` unless you pass `format: "json"` (which + restores the previous `{ …, node }` ProseMirror subtree); comment anchors — + including resolved ones — are preserved in the markdown so a write-back never + orphans a thread, and a node that cannot be a document top-level block + (`tableRow`/`tableCell`/`tableHeader` addressed via `#`) auto-falls back + to JSON with `format: "json"` in the response. `patchNode`/`insertNode` gain a + `markdown` input alongside `node` (provide exactly one): the markdown fragment + may rewrite/insert several blocks at once and supports `^[...]` footnotes. + *Migration (external MCP clients only):* a client that consumed `getNode`'s + `node` field must now either read `markdown`, or pass `format: "json"` to keep + the old ProseMirror-JSON output. Released together with the `#411`/`#412` + breaking window so external configs break exactly once. (#413) + ### Added - **Place several images side by side in a row.** A new "Inline (side by diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts index 00990666..78b3611d 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts @@ -355,23 +355,32 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => { content: [{ type: 'text', text: 'Hello' }], }; - it('patchNode parses a JSON-string node and forwards it as an object', async () => { + it('patchNode parses a JSON-string node and forwards it as { node } (object)', async () => { const tools = await buildTools(); await tools.patchNode.execute( { pageId: 'p1', nodeId: 'n1', node: JSON.stringify(NODE_OBJ) } as never, {} as never, ); expect(patchNodeCalls).toHaveLength(1); - expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]); + // #413: the 3rd arg is now the XOR input { markdown?, node? }. + expect(patchNodeCalls[0]).toEqual([ + 'p1', + 'n1', + { markdown: undefined, node: NODE_OBJ }, + ]); }); - it('patchNode passes an object node through unchanged', async () => { + it('patchNode passes an object node through unchanged inside { node }', async () => { const tools = await buildTools(); await tools.patchNode.execute( { pageId: 'p1', nodeId: 'n1', node: NODE_OBJ } as never, {} as never, ); - expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]); + expect(patchNodeCalls[0]).toEqual([ + 'p1', + 'n1', + { markdown: undefined, node: NODE_OBJ }, + ]); }); it('patchNode throws the documented message on invalid JSON string', async () => { @@ -385,7 +394,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => { expect(patchNodeCalls).toHaveLength(0); }); - it('insertNode parses a JSON-string node and forwards it as an object', async () => { + it('insertNode parses a JSON-string node and forwards it inside { node }', async () => { const tools = await buildTools(); await tools.insertNode.execute( { @@ -396,9 +405,15 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => { {} as never, ); expect(insertNodeCalls).toHaveLength(1); - const [pageId, node] = insertNodeCalls[0]; + // #413: the 2nd arg is the XOR input { markdown?, node? }, the 3rd is opts. + const [pageId, input, opts] = insertNodeCalls[0] as [ + string, + { markdown?: unknown; node?: unknown }, + { position?: string }, + ]; expect(pageId).toBe('p1'); - expect(node).toEqual(NODE_OBJ); + expect(input).toEqual({ markdown: undefined, node: NODE_OBJ }); + expect(opts.position).toBe('append'); }); it('insertNode throws the documented message on invalid JSON string', async () => { diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index e7e0414b..d9fa8aa9 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -62,7 +62,7 @@ function __assertClientCallContract(client: DocmostClientLike): void { void client.listSidebarPages(s, s); void client.getOutline(s); void client.getPageJson(s); - void client.getNode(s, s); + void client.getNode(s, s, 'markdown'); void client.searchInPage(s, s, { regex: true, caseSensitive: true, @@ -84,12 +84,16 @@ function __assertClientCallContract(client: DocmostClientLike): void { void client.movePage(s, s, s); void client.deletePage(s); void client.editPageText(s, edits); - void client.patchNode(s, s, node); - void client.insertNode(s, node, { - position: 'append', - anchorNodeId: s, - anchorText: s, - }); + void client.patchNode(s, s, { markdown: s, node }); + void client.insertNode( + s, + { markdown: s, node }, + { + position: 'append', + anchorNodeId: s, + anchorText: s, + }, + ); void client.deleteNode(s, s); void client.updatePageJson(s, node, s); void client.tableInsertRow(s, s, cells, n); diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 8852378f..0e532a32 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -32,9 +32,11 @@ import { } from "./lib/markdown-document.js"; import { replaceNodeById, + replaceNodeByIdWithMany, deleteNodeById, assertUnambiguousMatch, insertNodeRelative, + insertNodesRelative, blockPlainText, buildOutline, getNodeByRef, @@ -44,6 +46,11 @@ import { updateTableCell, findInvalidNode, } from "@docmost/prosemirror-markdown"; +import { + importMarkdownFragment, + canBeDocChild, + findUnrepresentableTableAttrs, +} from "./lib/markdown-fragment.js"; import { searchInDoc, SearchOptions } from "./lib/page-search.js"; import { withPageLock } from "./lib/page-lock.js"; import { @@ -83,6 +90,7 @@ import { commentsToFootnotes, canonicalizeFootnotes, insertInlineFootnote, + mergeFootnoteDefinitions, } from "./lib/transforms.js"; import { normalizeAndMergeFootnotes } from "./lib/footnote-normalize-merge.js"; import vm from "node:vm"; @@ -1298,12 +1306,31 @@ export class DocmostClient { } /** - * 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). + * Fetch a single block for editing 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). + * + * `format` (#413): + * - `"markdown"` (DEFAULT): serialize the block via the canonical converter + * (`{type:"doc",content:[node]}` -> `convertProseMirrorToMarkdown`) — a read + * "for editing": pair it with `patchNode({markdown})` to rewrite the block. + * Comment anchors (``, INCLUDING resolved ones) are + * NOT stripped here (unlike getPage): losing them on write-back would + * orphan the thread. Returns `{ ..., format:"markdown", markdown }`. + * - `"json"`: return the raw ProseMirror subtree as-is (lossless; the previous + * default). Returns `{ ..., format:"json", node }`. + * + * AUTO fallback: a type that cannot be a document top-level child + * (tableRow/tableCell/tableHeader, addressed by `#`) is NOT expressible + * as a standalone markdown document, so a `"markdown"` request for such a node + * transparently falls back to JSON with an explicit `format:"json"` field. The + * check derives from the schema's `doc` contentMatch, so it tracks the schema. */ - async getNode(pageId: string, nodeId: string) { + async getNode( + pageId: string, + nodeId: string, + format: "markdown" | "json" = "markdown", + ) { await this.ensureAuthenticated(); const data = await this.getPageRaw(pageId); const hit = getNodeByRef( @@ -1315,12 +1342,35 @@ export class DocmostClient { `getNode: no node found for "${nodeId}" on page ${pageId} (use a block id from getOutline, or "#" for a top-level block such as a table)`, ); } + + // JSON requested (or a non-top-level type that markdown cannot represent as a + // standalone document): return the subtree verbatim. + if (format === "json" || !canBeDocChild(hit.type)) { + return { + pageId, + ref: nodeId, + path: hit.path, + type: hit.type, + format: "json" as const, + node: hit.node, + }; + } + + // Markdown: wrap the node as a one-block doc and run the canonical converter. + // Comment anchors are DELIBERATELY preserved (converter default) so a + // getNode(markdown) -> edit -> patchNode(markdown) round trip does not orphan + // a comment thread; this differs from getPage, which strips them. + const markdown = convertProseMirrorToMarkdown({ + type: "doc", + content: [hit.node], + }); return { pageId, ref: nodeId, path: hit.path, type: hit.type, - node: hit.node, + format: "markdown" as const, + markdown, }; } @@ -2307,17 +2357,61 @@ export class DocmostClient { } /** - * 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. + * Replace the block whose attrs.id === nodeId. 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. + * Exactly one of `input.markdown` / `input.node` (#413): + * - `markdown` (RECOMMENDED): the block is rewritten from a canonical markdown + * fragment. The fragment may import to N blocks (a "1 -> N" splice: rewrite a + * whole section in one call). The FIRST resulting block INHERITS the target's + * `attrs.id` (so an existing comment anchoring the block by id survives); the + * rest get FRESH ids. `^[...]` footnotes in the fragment are first-class: + * their definitions merge into the page's TAIL footnote list (content-key + * dedup + canonicalize), same machinery insertFootnote uses. REJECTED when + * the TARGET block carries a table-cell attribute markdown cannot represent + * (colspan/rowspan/colwidth/background) — use the table tools or `node`. + * - `node`: a raw ProseMirror node for precise attr/mark work. The replacement + * keeps the target id (if `node.attrs.id` is missing it is set to nodeId). + * + * #159 ambiguous-id semantics are unchanged: 0 matches -> "no node"; >1 matches + * -> "ambiguous, refused" (nothing written), on BOTH paths — the markdown path + * runs a dry `replaceNodeById` count first, so a duplicated id never splices. */ - async patchNode(pageId: string, nodeId: string, node: any) { + async patchNode( + pageId: string, + nodeId: string, + input: { markdown?: string; node?: any }, + ) { await this.ensureAuthenticated(); + // XOR: exactly one of markdown / node. Both optional in the schema; the + // runtime enforces the recommendation ("markdown for prose, node for fine + // work") without letting an ambiguous both-or-neither call through. + const hasMd = + input != null && + typeof input.markdown === "string" && + input.markdown.trim() !== ""; + const hasNode = input != null && input.node != null; + if (hasMd === hasNode) { + throw new Error( + "patchNode: provide exactly one of `markdown` (recommended, for prose) " + + "or `node` (a raw ProseMirror node, for precise attr/mark work)", + ); + } + + if (hasMd) { + return this.patchNodeMarkdown(pageId, nodeId, input.markdown as string); + } + return this.patchNodeJson(pageId, nodeId, input.node); + } + + /** + * patchNode with a raw ProseMirror `node` (the pre-#413 behavior). Replaces + * EVERY node whose attrs.id === nodeId; the swapped-in node keeps the target + * id. #159 ambiguity refused. Split out so the markdown path can reuse the + * shared collab/guard plumbing without a giant branch. + */ + private async patchNodeJson(pageId: string, nodeId: string, node: any) { if (!node || typeof node !== "object" || typeof node.type !== "string") { throw new Error( "patchNode: `node` must be an object with a string `type`", @@ -2382,22 +2476,138 @@ export class DocmostClient { } /** - * Insert a node relative to an anchor (or append it at the top level). + * patchNode with a MARKDOWN fragment (#413). Imports the fragment through the + * canonical importer, then 1 -> N splices the resulting blocks in place of the + * target block on the LIVE collab doc: + * - the FIRST block inherits the target's id; the rest get FRESH ids (minted + * by the importer/id-remap, so neighbour blocks are untouched); + * - `^[...]` footnote definitions merge into the page's tail list; + * - REJECTED when the target block carries a markdown-unrepresentable table + * attr (colspan/rowspan/colwidth/background) — guarding against silent loss; + * - #159 ambiguity is enforced by a dry `replaceNodeById` count BEFORE the + * splice, so a duplicated id never writes. + */ + private async patchNodeMarkdown( + pageId: string, + nodeId: string, + markdown: string, + ) { + // Import the fragment up front (network-free, canonical) so a bad fragment + // fails before any collab connection or page lock. + const { blocks, definitions } = await importMarkdownFragment(markdown); + + // The first imported block inherits the target id; the rest keep the fresh + // ids the importer assigned. Build the thread now so it is stable across a + // collab retry (the transform below is pure over its inputs). + const threaded = blocks.map((b, i) => { + if (i !== 0) return b; + return { + ...b, + attrs: { + ...(b && typeof b.attrs === "object" ? b.attrs : {}), + id: nodeId, + }, + }; + }); + + // Shape-validate every imported block up front (parity with the JSON path): + // the importer only emits schema nodes, but the check is cheap insurance and + // yields the same rich #409 diagnostics if the schema ever drifts. + for (const b of threaded) { + this.assertValidNodeShape("patchNode", b); + } + + const collabToken = await this.getCollabTokenWithReauth(); + // Open the collab doc by the canonical UUID, never the slugId (#260). + const pageUuid = await this.resolvePageId(pageId); + + let replaced = 0; + let guardAttrs: string | null = null; + const mutation = await mutatePageContent( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + replaced = 0; + guardAttrs = null; + + // #159: count matches with the same recursive walk the JSON path uses; + // only an UNAMBIGUOUS single match may write. A dry count keeps the + // ambiguity semantics identical across both paths. + const { replaced: count } = replaceNodeById(liveDoc, nodeId, { + type: "paragraph", + }); + replaced = count; + if (count !== 1) return null; + + // Guard against SILENT LOSS: if the target block carries a table-cell + // attribute markdown cannot represent (colspan/rowspan/colwidth/ + // background), refuse the markdown rewrite so those attrs are not + // dropped. Simple tables (no such attrs) rewrite fine. + const hit = getNodeByRef(liveDoc, nodeId); + guardAttrs = hit ? findUnrepresentableTableAttrs(hit.node) : null; + if (guardAttrs != null) return null; + + // 1 -> N splice, then merge any fragment footnote definitions into the + // page's tail list and re-derive canonical footnote numbering. + const { doc: spliced } = replaceNodeByIdWithMany( + liveDoc, + nodeId, + threaded, + ); + return mergeFootnoteDefinitions(spliced, definitions); + }, + ); + + // Surface the guard rejection with an actionable message (nothing written). + if (guardAttrs != null) { + throw new Error( + `patchNode: the target block has table-cell attributes markdown cannot ` + + `represent (${guardAttrs}) — a markdown rewrite would drop them. Use ` + + `the table tools (tableUpdateCell/tableInsertRow) or pass a raw ` + + `ProseMirror \`node\` instead of \`markdown\`.`, + ); + } + + // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform skipped the write + // for any count !== 1). Shared #159 guard, identical to the JSON path. + assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId); + + return { + success: true, + replaced, + nodeId, + blocks: threaded.length, + verify: mutation.verify, + }; + } + + /** + * Insert content relative to an anchor (or append it at the top level). * Operates on the LIVE collab document so comments and concurrent edits are * preserved. * + * Exactly one of `input.markdown` / `input.node` (#413): + * - `markdown` (RECOMMENDED): a canonical markdown fragment. It may import to + * SEVERAL blocks — they are inserted IN ORDER at the anchor. `^[...]` + * footnote definitions merge into the page's tail list (same machinery as + * insertFootnote). Every inserted block gets a fresh id. + * - `node`: a raw ProseMirror node for precise attr/mark work, or to insert + * table structure (a bare tableRow/tableCell/tableHeader — NOT expressible in + * markdown, so those stay JSON-only). + * * 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. + * - "append": push the content at the end of the top-level content. + * - "before"/"after": insert 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: string, - node: any, + input: { markdown?: string; node?: any }, opts: { position: "before" | "after" | "append"; anchorNodeId?: string; @@ -2406,11 +2616,19 @@ export class DocmostClient { ) { await this.ensureAuthenticated(); - if (!node || typeof node !== "object" || typeof node.type !== "string") { + // XOR: exactly one of markdown / node (both optional in the schema). + const hasMd = + input != null && + typeof input.markdown === "string" && + input.markdown.trim() !== ""; + const hasNode = input != null && input.node != null; + if (hasMd === hasNode) { throw new Error( - "insertNode: `node` must be an object with a string `type`", + "insertNode: provide exactly one of `markdown` (recommended, for prose) " + + "or `node` (a raw ProseMirror node, for precise attr/mark work or table structure)", ); } + if ( !opts || (opts.position !== "before" && @@ -2434,10 +2652,32 @@ export class DocmostClient { } } + // Resolve the ordered list of blocks to insert plus any footnote definitions + // to merge. The markdown path imports canonically (so an inserted block is + // byte-identical to the same content in a full-page import); the node path is + // a single block with no footnote merge (raw JSON `^[...]` is not touched). + let blocks: any[]; + let definitions: any[] = []; + if (hasMd) { + const frag = await importMarkdownFragment(input.markdown as string); + blocks = frag.blocks; + definitions = frag.definitions; + } else { + const node = input.node; + if (!node || typeof node !== "object" || typeof node.type !== "string") { + throw new Error( + "insertNode: `node` must be an object with a string `type`", + ); + } + blocks = [node]; + } + // #409: fail fast on a malformed node SHAPE (a nested child with an // absent/unknown `type`) BEFORE opening a collab session or taking the page // lock — the root-only check above never sees nested children. - this.assertValidNodeShape("insertNode", node); + for (const b of blocks) { + this.assertValidNodeShape("insertNode", b); + } const collabToken = await this.getCollabTokenWithReauth(); // Open the collab doc by the canonical UUID, never the slugId (#260). @@ -2452,14 +2692,17 @@ export class DocmostClient { this.apiUrl, (liveDoc) => { inserted = false; - const { doc: nd, inserted: ins } = insertNodeRelative( - liveDoc, - node, - opts, - ); - inserted = ins; + // Single-block node path keeps `insertNodeRelative` (it owns the + // structural table-node splicing); the markdown path uses the array + // splice so N blocks land in order at one anchor. + const res = hasMd + ? insertNodesRelative(liveDoc, blocks, opts) + : insertNodeRelative(liveDoc, blocks[0], opts); + inserted = res.inserted; if (!inserted) return null; // anchor not found -> skip the write entirely - return nd; + // Merge any fragment footnote definitions into the page tail list and + // re-derive canonical numbering (no-op when there are none). + return mergeFootnoteDefinitions(res.doc, definitions); }, ); @@ -2482,6 +2725,7 @@ export class DocmostClient { success: true, inserted: true, position: opts.position, + blocks: blocks.length, verify: mutation.verify, }; } diff --git a/packages/mcp/src/lib/markdown-fragment.ts b/packages/mcp/src/lib/markdown-fragment.ts new file mode 100644 index 00000000..79796212 --- /dev/null +++ b/packages/mcp/src/lib/markdown-fragment.ts @@ -0,0 +1,243 @@ +/** + * Single-BLOCK markdown fragment support for `patch_node` / `insert_node` + * (#413). These tools accept EITHER a raw ProseMirror `node` (fine attr/mark + * work) OR a `markdown` string (the recommended default): a small markdown + * fragment is run through the canonical importer, yielding the SAME topology a + * full-page markdown import would — so a block written via markdown is + * canonically identical to the same content imported whole (no "second canon"). + * + * The importer produces a full `{type:"doc", content:[...blocks..., footnotesList?]}`. + * A fragment write needs the BLOCKS separately from the footnote DEFINITIONS so + * the caller can splice the blocks into the live document and merge the + * definitions into the page's TAIL footnote list via the existing footnote + * machinery (`insertInlineFootnote`'s `appendDefinition` + `canonicalizeFootnotes`). + * + * Footnote id-collision safety: the importer assigns sequential ids (`fn-1`, + * `fn-2`, …) starting from 1 for EVERY fragment, so a fragment's `fn-1` would + * collide with an existing page footnote also numbered `fn-1` — and + * `canonicalizeFootnotes` matches references to definitions BY id, so the + * fragment's reference would silently re-hang onto the page's unrelated + * definition. To make the merge safe regardless of the page's current numbering, + * every fragment footnote id is REMAPPED to a fresh uuid (via the importer's own + * `generateFootnoteId`) across BOTH the references (inside the blocks) and the + * definitions before either is handed back. Content-identical notes still merge + * downstream via `normalizeAndMergeFootnotes` (content-key), and the whole doc is + * renumbered by `canonicalizeFootnotes`, so the caller-visible numbering stays + * canonical. + */ + +import { markdownToProseMirror } from "./collaboration.js"; +import { generateFootnoteId } from "@docmost/prosemirror-markdown"; +import { docmostSchema } from "./docmost-schema.js"; + +/** True if `value` is a non-null, non-array object. */ +function isObject(value: any): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Deep-walk `node` collecting every footnote id it uses (on `footnoteReference` + * and `footnoteDefinition` nodes) and build a stable OLD->NEW remap, minting a + * fresh uuid per distinct old id. The map is shared across a fragment's blocks + * and definitions so a reference and its definition receive the SAME new id. + */ +function buildFootnoteIdRemap(nodes: any[]): Map { + const remap = new Map(); + const visit = (node: any): void => { + if (!isObject(node)) return; + if ( + (node.type === "footnoteReference" || + node.type === "footnoteDefinition") && + isObject(node.attrs) && + typeof node.attrs.id === "string" && + node.attrs.id !== "" + ) { + if (!remap.has(node.attrs.id)) { + remap.set(node.attrs.id, generateFootnoteId()); + } + } + if (Array.isArray(node.content)) { + for (const child of node.content) visit(child); + } + }; + for (const n of nodes) visit(n); + return remap; +} + +/** Rewrite every footnote id in `node` IN PLACE using `remap` (deep). */ +function applyFootnoteIdRemap(node: any, remap: Map): void { + if (!isObject(node)) return; + if ( + (node.type === "footnoteReference" || node.type === "footnoteDefinition") && + isObject(node.attrs) && + typeof node.attrs.id === "string" + ) { + const next = remap.get(node.attrs.id); + if (next) node.attrs.id = next; + } + if (Array.isArray(node.content)) { + for (const child of node.content) applyFootnoteIdRemap(child, remap); + } +} + +/** + * Generate a short random block id for an imported block that arrives without one + * (the markdown importer emits `attrs.id: null`). Mirrors the mcp `freshId` + * convention (base36 random, unique within one document). The patch path then + * OVERWRITES the first block's id with the target id; every other block keeps the + * fresh id minted here — so a 1 -> N section rewrite yields addressable, + * comment-anchorable blocks rather than a run of null-id paragraphs. + */ +function freshBlockId(): string { + return ( + Math.random().toString(36).slice(2, 12) + + Math.random().toString(36).slice(2, 6) + ); +} + +/** + * Assign a fresh id to every top-level block whose `attrs.id` is null/missing, + * IN PLACE. Only the block's own id is touched (not descendants — those keep the + * importer's structure). Ensures each imported block is independently addressable. + */ +function assignFreshBlockIds(blocks: any[]): void { + for (const b of blocks) { + if (!isObject(b)) continue; + if (!isObject(b.attrs)) b.attrs = {}; + if (b.attrs.id == null || b.attrs.id === "") { + b.attrs.id = freshBlockId(); + } + } +} + +/** The parsed shape of a markdown fragment: its blocks + footnote definitions. */ +export interface MarkdownFragment { + /** Top-level blocks, in order, with the trailing `footnotesList` removed. */ + blocks: any[]; + /** + * The `footnoteDefinition` nodes lifted from the imported `footnotesList`, with + * ids already remapped to match the references left inside `blocks`. Empty when + * the fragment used no footnotes. + */ + definitions: any[]; +} + +/** + * Import a markdown fragment and return its blocks separately from its footnote + * definitions, with all footnote ids remapped to fresh uuids (see the file + * header). The importer's `^[body]` inline-footnote handling is used verbatim — + * `^[...]` in the fragment is a first-class footnote, NOT rejected — so the + * markdown path matches the full-page import exactly. + * + * Throws when the fragment imports to zero blocks (an empty / whitespace-only + * markdown string is not a valid block write). + */ +export async function importMarkdownFragment( + markdown: string, +): Promise { + const doc = await markdownToProseMirror(markdown); + const content: any[] = Array.isArray(doc?.content) ? doc.content : []; + + const blocks: any[] = []; + const definitions: any[] = []; + for (const node of content) { + if (isObject(node) && node.type === "footnotesList") { + // Lift the definitions out of the list; the list wrapper itself is + // reconstructed on the page by the canonicalizer after the merge. + if (Array.isArray(node.content)) { + for (const def of node.content) { + if (isObject(def) && def.type === "footnoteDefinition") { + definitions.push(def); + } + } + } + continue; + } + blocks.push(node); + } + + if (blocks.length === 0) { + throw new Error( + "markdown fragment produced no blocks — provide non-empty markdown, or use `node` for a raw ProseMirror node", + ); + } + + // Remap footnote ids across BOTH blocks and definitions so a fragment `fn-1` + // cannot collide with a page footnote of the same number. + const remap = buildFootnoteIdRemap([...blocks, ...definitions]); + if (remap.size > 0) { + for (const b of blocks) applyFootnoteIdRemap(b, remap); + for (const d of definitions) applyFootnoteIdRemap(d, remap); + } + + // Every top-level block needs a stable id (the importer leaves them null). The + // patch path OVERWRITES the first block's id with the target id afterwards. + assignFreshBlockIds(blocks); + + return { blocks, definitions }; +} + +/** + * True when `type` is a valid TOP-LEVEL child of the document node per the + * canonical schema's content model — i.e. `get_node` can serialize it to + * markdown by wrapping it in `{type:"doc",content:[node]}`. Derived from the + * schema's `doc` contentMatch (NOT a hand-written type list) so it tracks the + * schema automatically: `tableRow`/`tableCell`/`tableHeader` (addressed only via + * `#`) are NOT doc children and yield false, so `get_node` auto-falls back + * to JSON for them. + */ +export function canBeDocChild(type: string | undefined): boolean { + if (typeof type !== "string") return false; + const nodeType = docmostSchema.nodes[type]; + if (!nodeType) return false; + return docmostSchema.nodes.doc.contentMatch.matchType(nodeType) != null; +} + +/** + * Table-cell attributes that CANNOT survive a markdown round-trip: the converter + * emits colspan/rowspan (and align) as HTML `` cell attrs, but silently + * drops `colwidth`, `backgroundColor`, and `backgroundColorName`. A markdown + * `patch_node` on a block that carries any of these (a merged / colored / + * fixed-width cell) would therefore lose them — so it is REJECTED, pointing the + * caller at the table tools or the raw-`node` JSON path. `align` is intentionally + * absent: it round-trips as GFM alignment. + */ +function cellCarriesUnrepresentableAttrs(node: any): boolean { + if (!isObject(node)) return false; + if (node.type !== "tableCell" && node.type !== "tableHeader") return false; + const a = isObject(node.attrs) ? node.attrs : {}; + if ((a.colspan ?? 1) > 1) return true; + if ((a.rowspan ?? 1) > 1) return true; + if (a.colwidth != null) return true; + if (a.backgroundColor != null) return true; + if (a.backgroundColorName != null) return true; + return false; +} + +/** + * Scan a target block (the node being replaced) for any table cell carrying an + * attribute markdown cannot represent (colspan/rowspan/colwidth/background). When + * one is found, return a human-readable list of the offending attr NAMES so the + * caller can build an actionable rejection message; return null when the block is + * safe to rewrite from markdown. Deep — a colored cell nested inside a table + * inside a callout is still caught. + */ +export function findUnrepresentableTableAttrs(node: any): string | null { + const found = new Set(); + const visit = (n: any): void => { + if (!isObject(n)) return; + if (cellCarriesUnrepresentableAttrs(n)) { + const a = isObject(n.attrs) ? n.attrs : {}; + if ((a.colspan ?? 1) > 1) found.add("colspan"); + if ((a.rowspan ?? 1) > 1) found.add("rowspan"); + if (a.colwidth != null) found.add("colwidth"); + if (a.backgroundColor != null) found.add("backgroundColor"); + if (a.backgroundColorName != null) found.add("backgroundColorName"); + } + if (Array.isArray(n.content)) { + for (const child of n.content) visit(child); + } + }; + visit(node); + return found.size > 0 ? Array.from(found).sort().join(", ") : null; +} diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index c65e30e1..cde42f8d 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -774,6 +774,37 @@ export function insertInlineFootnote( return { doc: working, inserted: true, footnoteId, reused }; } +/** + * Merge an ARRAY of footnote definitions (e.g. the definitions lifted from an + * imported markdown FRAGMENT) into `doc`\'s footnote list, then re-derive the + * canonical footnote topology — the SAME two-step machinery `insertInlineFootnote` + * uses (`appendDefinition` -> `normalizeAndMergeFootnotes` -> `canonicalizeFootnotes`). + * + * The fragment\'s `footnoteReference` nodes are assumed to ALREADY be spliced into + * `doc` (inside the just-inserted blocks) with ids matching these definitions, so + * after appending the definitions the canonicalizer orders/numbers everything by + * first-reference order, merges content-identical notes, and drops any orphan. + * Same documented caveat as every other write path: full canonicalization drops a + * definition no reference points at. + * + * A no-op returning `doc` unchanged when `definitions` is empty, so the + * non-footnote fast path stays untouched. When definitions exist the work runs + * through the pure passes (which clone), so the caller\'s `doc` is not mutated. + */ +export function mergeFootnoteDefinitions(doc: any, definitions: any[]): any { + if (!Array.isArray(definitions) || definitions.length === 0) return doc; + // Clone before appending: `appendDefinition` mutates in place, and the caller + // must not see a half-merged doc if a later pass throws. + let working = clone(doc); + for (const def of definitions) { + appendDefinition(working, def); + } + // #419: normalize + merge glyph-forked definitions before canonicalizing. + working = normalizeAndMergeFootnotes(working); + working = canonicalizeFootnotes(working); + return working; +} + /** * Append a definition node so the canonicalizer can order/place it: into the * first existing footnotesList, or a new trailing list when none exists. diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index 03dc170b..d3a6b74b 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -40,8 +40,8 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; */ export const ROUTING_PROSE = "Docmost editing guide — choose the tool by intent. The at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" + - "READ: find a page -> search (workspace-wide full-text); list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block's subtree -> getNode (by attrs.id, or \"#\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, lossy; inline tags are comment anchors — markup, not text) or getPageJson (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + - "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patchNode (by attrs.id from getOutline). Add a block -> insertNode (before/after a block by attrs.id or by anchor text, or append). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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.\n" + + "READ: find a page -> search (workspace-wide full-text); list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, lossy; inline tags are comment anchors — markup, not text) or getPageJson (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + + "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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.\n" + "PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" + "HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown."; diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index 874b4619..e2488f7c 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -305,22 +305,41 @@ export const SHARED_TOOL_SPECS = { mcpName: 'getNode', inAppKey: 'getNode', description: - "Fetch a single node's full ProseMirror subtree (lossless) without " + - 'pulling the whole document. `nodeId` is a block id from the page ' + + "Fetch a single block for editing. `nodeId` is a block id from the page " + 'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' + '`#` to fetch a top-level block by its outline index — use the ' + - '`#` form for tables/rows/cells, which carry no id.', + '`#` form for tables/rows/cells, which carry no id. ' + + "`format` defaults to \"markdown\": the block is returned as a canonical " + + 'markdown fragment (comment anchors are KEPT so a patchNode write-back does ' + + 'not orphan a thread) — edit it and write it back with patchNode({markdown}). ' + + 'Pass format:"json" for the raw lossless ProseMirror subtree (for precise ' + + 'attr/mark work). A node that cannot be a document top-level block ' + + '(tableRow/tableCell/tableHeader via "#") auto-falls back to JSON with ' + + 'format:"json" in the response.', tier: 'core', catalogLine: - "getNode — fetch one block's ProseMirror subtree by block id or #index.", + "getNode — fetch one block (markdown by default; json for the raw subtree).", buildShape: (z) => ({ pageId: z.string().min(1), nodeId: z.string().min(1), + format: z + .enum(['markdown', 'json']) + .optional() + .describe( + 'Output format: "markdown" (default, for editing → patchNode) or ' + + '"json" (raw ProseMirror subtree). A non-top-level type auto-falls ' + + 'back to json.', + ), }), - execute: (client, { pageId, nodeId }) => - client.getNode(pageId as string, nodeId as string), + execute: (client, { pageId, nodeId, format }) => + client.getNode( + pageId as string, + nodeId as string, + format as 'markdown' | 'json' | undefined, + ), }, + // --- in-page occurrence search (client-side, over ProseMirror plain text) --- searchInPage: { @@ -415,24 +434,30 @@ export const SHARED_TOOL_SPECS = { mcpName: 'patchNode', inAppKey: 'patchNode', description: - 'Replace a single content block identified by its attrs.id with a new ' + - 'ProseMirror node, WITHOUT resending the whole document; the replacement ' + - 'keeps the same node id. Get the block id from the page outline (cheap) ' + - 'or the page-JSON view, 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":' + + 'Replace a single content block identified by its attrs.id, WITHOUT ' + + 'resending the whole document; the replacement keeps the same block id. ' + + 'Get the block id from the page outline (cheap) or the page-JSON view. ' + + 'Provide EXACTLY ONE of `markdown` or `node`. ' + + '`markdown` (RECOMMENDED for prose): a canonical markdown fragment — the ' + + 'usual round trip is getNode (markdown) → edit the markdown → patchNode ' + + '(markdown). The fragment may be SEVERAL blocks (a "1 → N" splice: rewrite a ' + + 'whole section in one call) — the first block inherits this block id, the ' + + 'rest get fresh ids. `^[...]` footnotes are supported (their definitions ' + + "merge into the page's footnote list). REJECTED when the target is a table " + + 'cell with attributes markdown cannot represent (merged/colored/fixed-width) ' + + '— use the table tools or `node`. ' + + '`node` (for precise attr/mark work): a raw ProseMirror node, e.g. 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). EVERY node, including ' + - 'nested children, must carry a string `type` from the Docmost schema; ' + - 'text leaves are {"type":"text","text":"..."} (a bare {"text":"..."} is ' + - 'rejected up front). Cheaper and safer than ' + - 'replacing the whole document for one-block structural edits. Reversible: ' + + '{"type":"text","text":"x","marks":[{"type":"bold"}]}. EVERY node, including ' + + 'nested children, must carry a string `type` from the Docmost schema; text ' + + 'leaves are {"type":"text","text":"..."} (a bare {"text":"..."} is rejected). ' + + 'The node may be a JSON object or a JSON string (both accepted). Reversible: ' + 'the previous version is kept in page history.', tier: 'deferred', catalogLine: - 'patchNode — replace one block with a new ProseMirror node, keeping its id.', + 'patchNode — rewrite one block from markdown (or a raw node), keeping its id.', buildShape: (z) => ({ pageId: z.string().min(1).describe('ID of the page containing the block'), nodeId: z @@ -442,35 +467,53 @@ export const SHARED_TOOL_SPECS = { 'attrs.id of the block to replace (from the page outline or ' + 'page-JSON view)', ), + markdown: z + .string() + .optional() + .describe( + 'RECOMMENDED. Canonical markdown to replace the block with; may be ' + + 'several blocks (the first inherits the id, the rest get fresh ids). ' + + 'Exactly one of markdown / node.', + ), node: z .any() + .optional() .describe( - 'ProseMirror node to put in place of the node with this id, e.g. ' + + 'For precise attr/mark work: a ProseMirror node to put in place of ' + + 'the block, e.g. ' + '{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' + - 'JSON object or JSON string both accepted.', + 'JSON object or JSON string both accepted. Exactly one of markdown / node.', ), }), // parseNodeArg normalizes a JSON-string node into an object (the model // sometimes serializes it as a string) before the client's typeof-object - // guard rejects it — identical on both hosts. - execute: (client, { pageId, nodeId, node }) => - client.patchNode(pageId as string, nodeId as string, parseNodeArg(node)), + // guard rejects it — identical on both hosts. The XOR (markdown vs node) is + // enforced at runtime in the client (both schema-optional). + execute: (client, { pageId, nodeId, markdown, node }) => + client.patchNode(pageId as string, nodeId as string, { + markdown: markdown as string | undefined, + node: node == null ? undefined : parseNodeArg(node), + }), }, + insertNode: { mcpName: 'insertNode', inAppKey: 'insertNode', description: - 'Insert a block before/after another block (by attrs.id or anchor text) ' + + 'Insert content before/after another block (by attrs.id or anchor text) ' + 'or append it at the end (top level). For before/after you MUST provide ' + 'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' + 'page outline or the page-JSON view. 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). ' + + 'Provide EXACTLY ONE of `markdown` or `node`. ' + + '`markdown` (RECOMMENDED): a canonical markdown fragment — may be SEVERAL ' + + 'blocks, inserted in order at the anchor; `^[...]` footnotes supported. ' + + '`node` (for precise attr/mark work OR table structure): a raw ProseMirror ' + + 'node. Table structure is JSON-only (not expressible in markdown): 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). ' + "`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 ' + @@ -485,15 +528,23 @@ export const SHARED_TOOL_SPECS = { 'JSON object or a JSON string (both accepted). Reversible via page history.', tier: 'deferred', catalogLine: - 'insertNode — insert a block before/after an anchor, or append at the end.', + 'insertNode — insert markdown (or a raw node) before/after an anchor, or append.', buildShape: (z) => ({ pageId: z.string().min(1), + markdown: z + .string() + .optional() + .describe( + 'RECOMMENDED. Canonical markdown to insert; may be several blocks ' + + '(inserted in order). Exactly one of markdown / node.', + ), node: z .any() + .optional() .describe( - 'ProseMirror node to insert, e.g. ' + - '{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' + - 'JSON object or JSON string both accepted.', + 'For precise attr/mark work or table structure: a ProseMirror node, ' + + 'e.g. {"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' + + 'JSON object or JSON string both accepted. Exactly one of markdown / node.', ), position: z .enum(['before', 'after', 'append']) @@ -511,14 +562,27 @@ export const SHARED_TOOL_SPECS = { 'are tolerated as a fallback; prefer plain text or anchorNodeId.', ), }), - execute: (client, { pageId, node, position, anchorNodeId, anchorText }) => - client.insertNode(pageId as string, parseNodeArg(node), { - position: position as 'before' | 'after' | 'append', - anchorNodeId: anchorNodeId as string | undefined, - anchorText: anchorText as string | undefined, - }), + // The XOR (markdown vs node) is enforced at runtime in the client (both + // schema-optional). parseNodeArg only runs on the node path. + execute: ( + client, + { pageId, markdown, node, position, anchorNodeId, anchorText }, + ) => + client.insertNode( + pageId as string, + { + markdown: markdown as string | undefined, + node: node == null ? undefined : parseNodeArg(node), + }, + { + position: position as 'before' | 'after' | 'append', + anchorNodeId: anchorNodeId as string | undefined, + anchorText: anchorText as string | undefined, + }, + ), }, + // --- share management --- // Unified from the per-layer inline definitions (#294). Both layers already diff --git a/packages/mcp/test/mock/ambiguous-node-id.test.mjs b/packages/mcp/test/mock/ambiguous-node-id.test.mjs index 03eaf9bd..d011a40a 100644 --- a/packages/mcp/test/mock/ambiguous-node-id.test.mjs +++ b/packages/mcp/test/mock/ambiguous-node-id.test.mjs @@ -133,8 +133,10 @@ test("patchNode REFUSES an ambiguous (duplicate) id without writing to collab", await assert.rejects( () => client.patchNode("11111111-1111-4111-8111-111111111111", DUP_ID, { - type: "paragraph", - content: [{ type: "text", text: "replacement" }], + node: { + type: "paragraph", + content: [{ type: "text", text: "replacement" }], + }, }), /ambiguous/i, "patchNode must reject a duplicate-id target with an 'ambiguous' error", diff --git a/packages/mcp/test/mock/get-node-format.test.mjs b/packages/mcp/test/mock/get-node-format.test.mjs new file mode 100644 index 00000000..996e7187 --- /dev/null +++ b/packages/mcp/test/mock/get-node-format.test.mjs @@ -0,0 +1,157 @@ +// #413: getNode's markdown-default format, its JSON opt-in, the non-top-level +// AUTO fallback to JSON, and comment-anchor preservation (incl. resolved) on the +// markdown read. getNode only reads (getPageRaw), so a lightweight subclass that +// stubs auth + the page fetch is enough — no collab socket needed. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { DocmostClient } from "../../build/client.js"; + +function makeClient(doc) { + class TestClient extends DocmostClient { + async ensureAuthenticated() {} + async getPageRaw(pageId) { + return { id: pageId, slugId: "s", title: "P", spaceId: "sp", content: doc }; + } + } + return new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw"); +} + +const P = "p1"; + +test("getNode defaults to markdown for a paragraph", async () => { + const doc = { + type: "doc", + content: [ + { + type: "paragraph", + attrs: { id: "b1" }, + content: [{ type: "text", text: "hello world" }], + }, + ], + }; + const res = await makeClient(doc).getNode(P, "b1"); + assert.equal(res.format, "markdown"); + assert.equal(typeof res.markdown, "string"); + assert.match(res.markdown, /hello world/); + assert.equal(res.node, undefined, "markdown result carries no raw node"); +}); + +test("getNode format:'json' returns the raw subtree verbatim", async () => { + const target = { + type: "paragraph", + attrs: { id: "b1" }, + content: [{ type: "text", text: "hello" }], + }; + const doc = { type: "doc", content: [target] }; + const res = await makeClient(doc).getNode(P, "b1", "json"); + assert.equal(res.format, "json"); + assert.deepEqual(res.node, target); + assert.equal(res.markdown, undefined); +}); + +test("getNode AUTO-falls back to JSON for a non-top-level type (tableRow via #index)", async () => { + const doc = { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableCell", + attrs: { colspan: 1, rowspan: 1 }, + content: [ + { + type: "paragraph", + attrs: { id: "cp" }, + content: [{ type: "text", text: "x" }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + // "#0.0"-style refs are not supported; the whole table is "#0", a row is only + // reachable by drilling — but a tableRow IS a non-doc-child type. Address the + // table itself as "#0": a table CAN be a doc child, so markdown is fine there. + // To hit the fallback, address the row by walking: getNode resolves "#0" to the + // table (doc child -> markdown). Instead we verify the schema gate directly by + // asking for the table (markdown) and a row is exercised via the unit test on + // canBeDocChild; here confirm a table renders as markdown. + const tableRes = await makeClient(doc).getNode(P, "#0"); + assert.equal(tableRes.format, "markdown", "a table is a doc child -> markdown"); + + // Now build a doc whose top-level block IS a tableRow (schematically invalid but + // exercises the getNode fallback branch): getNode("#0") resolves it and, because + // tableRow cannot be a doc child, must fall back to JSON. + const rowDoc = { + type: "doc", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableCell", + attrs: { colspan: 1, rowspan: 1 }, + content: [{ type: "paragraph", content: [{ type: "text", text: "y" }] }], + }, + ], + }, + ], + }; + const rowRes = await makeClient(rowDoc).getNode(P, "#0"); + assert.equal(rowRes.format, "json", "a tableRow cannot be a doc child -> JSON fallback"); + assert.equal(rowRes.type, "tableRow"); + assert.ok(rowRes.node, "the JSON fallback returns the raw subtree"); +}); + +test("getNode(markdown) PRESERVES comment anchors — active and resolved", async () => { + // A paragraph with two comment marks: one active, one resolved. get_page strips + // resolved anchors; getNode must NOT (a read for editing/write-back). + const doc = { + type: "doc", + content: [ + { + type: "paragraph", + attrs: { id: "b1" }, + content: [ + { type: "text", text: "start " }, + { + type: "text", + text: "active", + marks: [{ type: "comment", attrs: { commentId: "cid-active" } }], + }, + { type: "text", text: " mid " }, + { + type: "text", + text: "resolved", + marks: [ + { + type: "comment", + attrs: { commentId: "cid-resolved", resolved: true }, + }, + ], + }, + { type: "text", text: " end" }, + ], + }, + ], + }; + const res = await makeClient(doc).getNode(P, "b1"); + assert.equal(res.format, "markdown"); + assert.match( + res.markdown, + /data-comment-id="cid-active"/, + "the active comment anchor is preserved", + ); + assert.match( + res.markdown, + /data-comment-id="cid-resolved"/, + "the RESOLVED comment anchor is ALSO preserved (unlike get_page)", + ); +}); diff --git a/packages/mcp/test/mock/invalid-node-validation.test.mjs b/packages/mcp/test/mock/invalid-node-validation.test.mjs index bb534581..6331dedc 100644 --- a/packages/mcp/test/mock/invalid-node-validation.test.mjs +++ b/packages/mcp/test/mock/invalid-node-validation.test.mjs @@ -135,7 +135,7 @@ test("patchNode fails fast on a nested typeless node — no collab connection", const client = new DocmostClient(baseURL, "user@example.com", "pw"); await assert.rejects( - () => client.patchNode(PAGE, SEED_ID, nestedTypelessNode()), + () => client.patchNode(PAGE, SEED_ID, { node: nestedTypelessNode() }), (err) => { assert.match(err.message, /patchNode: invalid node/); assert.match(err.message, /missing "type"/); @@ -158,9 +158,13 @@ test("insertNode fails fast on a nested UNKNOWN type — no collab connection", await assert.rejects( () => - client.insertNode(PAGE, nestedUnknownTypeNode(), { - position: "append", - }), + client.insertNode( + PAGE, + { node: nestedUnknownTypeNode() }, + { + position: "append", + }, + ), (err) => { assert.match(err.message, /insertNode: invalid node/); assert.match(err.message, /unknown node type "paragraf"/); @@ -225,8 +229,10 @@ test("patchNode with a well-formed node proceeds to the collab write", async () const client = new DocmostClient(baseURL, "user@example.com", "pw"); const result = await client.patchNode(PAGE, SEED_ID, { - type: "paragraph", - content: [{ type: "text", text: "replacement" }], + node: { + type: "paragraph", + content: [{ type: "text", text: "replacement" }], + }, }); assert.equal(result.success, true); diff --git a/packages/mcp/test/mock/markdown-patch-insert.test.mjs b/packages/mcp/test/mock/markdown-patch-insert.test.mjs new file mode 100644 index 00000000..f508a572 --- /dev/null +++ b/packages/mcp/test/mock/markdown-patch-insert.test.mjs @@ -0,0 +1,386 @@ +// Mock collab tests for the #413 MARKDOWN path of patchNode / insertNode and the +// markdown-default getNode. These stand up a real Hocuspocus collab server seeded +// with a chosen document (mirroring ambiguous-node-id.test.mjs), let the client +// run its real transform against a live Y.Doc, and read the persisted result back +// to assert on the written document. +// +// Coverage (issue #413): +// - CANON CONVERGENCE: a block written via patchNode(markdown) is canonically +// equal to the SAME content run through a full markdown import (no "second +// canon" appears on the block-level path). +// - id-THREAD on a 1->N splice: the first block inherits the target id, the rest +// get fresh ids, and every NEIGHBOUR block is byte-identical before/after. +// - XOR validation (both / neither markdown+node -> error). +// - span/color-attr GUARD on the target block (a merged/colored cell refuses a +// markdown patch, nothing written). +// - `^[...]` footnote in the fragment -> a definition in the tail list + renumber. +// - insertNode(markdown) inserts N blocks in order at the anchor. +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { WebSocketServer } from "ws"; +import { Hocuspocus } from "@hocuspocus/server"; +import { DocmostClient } from "../../build/client.js"; +import { buildYDoc } from "../../build/lib/collaboration.js"; +import { + docsCanonicallyEqual, + markdownToProseMirror, +} from "@docmost/prosemirror-markdown"; + +const PAGE = "11111111-1111-4111-8111-111111111111"; + +// Deep JSON clone for byte-identity assertions. +const jclone = (v) => JSON.parse(JSON.stringify(v)); + +function findAll(node, type, acc = []) { + if (!node || typeof node !== "object") return acc; + if (node.type === type) acc.push(node); + if (Array.isArray(node.content)) + for (const c of node.content) findAll(c, type, acc); + return acc; +} + +// Stand up an HTTP+Hocuspocus stack seeded with `seedDoc`. `state.lastDoc` holds +// the most recently persisted document JSON (decoded from the live Y.Doc on every +// change) so a test can inspect exactly what was written. +async function spawnCollabStack(seedDoc) { + const state = { changed: false, lastDoc: null }; + + const hocuspocus = new Hocuspocus({ + quiet: true, + async onLoadDocument() { + return buildYDoc(seedDoc); + }, + async onChange(data) { + state.changed = true; + try { + const frag = data.document.getXmlFragment("default"); + // Decode the live fragment back to JSON via the same helper the client + // reads with — but simpler: use the yjs->json path exposed by the doc. + state.lastDoc = fragmentToJson(frag); + } catch { + /* ignore decode errors in teardown races */ + } + }, + }); + + const wss = new WebSocketServer({ noServer: true }); + const server = http.createServer((req, res) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + if (req.url === "/api/auth/login") { + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": "authToken=t; Path=/; HttpOnly", + }); + res.end(JSON.stringify({ success: true })); + return; + } + if (req.url === "/api/auth/collab-token") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ data: { token: "collab-jwt" } })); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ message: "not found" })); + }); + }); + + server.on("upgrade", (request, socket, head) => { + if (!request.url || !request.url.startsWith("/collab")) { + socket.destroy(); + return; + } + wss.handleUpgrade(request, socket, head, (ws) => { + hocuspocus.handleConnection(ws, request); + }); + }); + + const baseURL = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve(`http://127.0.0.1:${port}/api`); + }); + }); + + openStacks.push({ server, hocuspocus }); + return { state, baseURL }; +} + +// Minimal XmlFragment -> ProseMirror JSON decode, mirroring the shape Docmost +// stores. Reads element name as node type, attributes as attrs, and recurses into +// children; text nodes carry their string. +function fragmentToJson(frag) { + const decodeNode = (el) => { + if (el.constructor.name === "YXmlText") { + // A yjs text node: collect the string with its formatting deltas. + const delta = el.toDelta(); + return delta.map((d) => { + const node = { type: "text", text: d.insert }; + if (d.attributes && Object.keys(d.attributes).length) { + node.marks = Object.entries(d.attributes).map(([type, attrs]) => + attrs && typeof attrs === "object" && Object.keys(attrs).length + ? { type, attrs } + : { type }, + ); + } + return node; + }); + } + const node = { type: el.nodeName }; + const attrs = el.getAttributes(); + if (attrs && Object.keys(attrs).length) node.attrs = attrs; + const children = []; + for (const child of el.toArray()) { + const decoded = decodeNode(child); + if (Array.isArray(decoded)) children.push(...decoded); + else children.push(decoded); + } + if (children.length) node.content = children; + return node; + }; + const content = []; + for (const child of frag.toArray()) content.push(decodeNode(child)); + return { type: "doc", content }; +} + +const openStacks = []; +after(async () => { + await Promise.all( + openStacks.map( + ({ server, hocuspocus }) => + new Promise((resolve) => { + server.close(() => { + Promise.resolve(hocuspocus.destroy?.()).finally(resolve); + }); + }), + ), + ); +}); + +// A seed doc with two neighbour paragraphs around a target paragraph. +function seed3() { + return { + type: "doc", + content: [ + { + type: "paragraph", + attrs: { id: "before-id" }, + content: [{ type: "text", text: "before" }], + }, + { + type: "paragraph", + attrs: { id: "target-id" }, + content: [{ type: "text", text: "old target" }], + }, + { + type: "paragraph", + attrs: { id: "after-id" }, + content: [{ type: "text", text: "after" }], + }, + ], + }; +} + +test("patchNode(markdown): XOR — both markdown and node is rejected, nothing written", async () => { + const { state, baseURL } = await spawnCollabStack(seed3()); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + await assert.rejects( + () => + client.patchNode(PAGE, "target-id", { + markdown: "hello", + node: { type: "paragraph" }, + }), + /exactly one of/i, + ); + assert.equal(state.changed, false, "no write on an XOR violation"); +}); + +test("patchNode(markdown): XOR — neither markdown nor node is rejected", async () => { + const { baseURL } = await spawnCollabStack(seed3()); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + await assert.rejects( + () => client.patchNode(PAGE, "target-id", {}), + /exactly one of/i, + ); +}); + +test("patchNode(markdown): single block keeps the id; neighbours byte-identical", async () => { + const before = seed3(); + const { state, baseURL } = await spawnCollabStack(before); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + const res = await client.patchNode(PAGE, "target-id", { + markdown: "the **new** target", + }); + assert.equal(res.success, true); + assert.equal(res.replaced, 1); + assert.equal(res.blocks, 1); + + const doc = state.lastDoc; + const paras = doc.content; + // The rewritten block still carries the target id. + const target = paras.find((p) => p.attrs?.id === "target-id"); + assert.ok(target, "rewritten block inherits target-id"); + assert.equal(target.content.some((n) => n.text === "new"), true); + // Neighbours are byte-identical to the seed. + const beforeNode = paras.find((p) => p.attrs?.id === "before-id"); + const afterNode = paras.find((p) => p.attrs?.id === "after-id"); + assert.deepEqual(beforeNode, before.content[0]); + assert.deepEqual(afterNode, before.content[2]); +}); + +test("patchNode(markdown): 1->N splice threads the id onto the first block; neighbours byte-identical", async () => { + const before = seed3(); + const { state, baseURL } = await spawnCollabStack(before); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + // Two paragraphs of markdown -> a 2-block fragment replacing one block. + const res = await client.patchNode(PAGE, "target-id", { + markdown: "first para\n\nsecond para", + }); + assert.equal(res.blocks, 2); + + const doc = state.lastDoc; + const idx = doc.content.findIndex((p) => p.attrs?.id === "target-id"); + assert.ok(idx >= 0, "first spliced block inherits target-id"); + const first = doc.content[idx]; + const second = doc.content[idx + 1]; + assert.equal(first.content.some((n) => n.text === "first para"), true); + assert.equal(second.content.some((n) => n.text === "second para"), true); + // The second block has a DIFFERENT (fresh) id. + assert.notEqual(second.attrs?.id, "target-id"); + assert.ok(second.attrs?.id, "the extra block gets a fresh id"); + // Neighbours untouched, byte-identical. + assert.deepEqual( + doc.content.find((p) => p.attrs?.id === "before-id"), + before.content[0], + ); + assert.deepEqual( + doc.content.find((p) => p.attrs?.id === "after-id"), + before.content[2], + ); +}); + +test("patchNode(markdown): CANON CONVERGENCE — block equals the same content full-imported", async () => { + const { state, baseURL } = await spawnCollabStack(seed3()); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + const md = "a paragraph with **bold**, _italic_ and `code`"; + await client.patchNode(PAGE, "target-id", { markdown: md }); + + // The block as persisted. + const target = state.lastDoc.content.find((p) => p.attrs?.id === "target-id"); + // The same markdown run through the full-page importer. + const full = await markdownToProseMirror(md); + const fullBlock = full.content[0]; + + assert.ok( + docsCanonicallyEqual( + { type: "doc", content: [target] }, + { type: "doc", content: [fullBlock] }, + ), + "a patchNode(markdown) block must be canonically equal to a full import — no second canon", + ); +}); + +test("patchNode(markdown): a paragraph inside a merged (colspan) cell rewrites fine — the cell's span is preserved", async () => { + // A cell paragraph carries an id and IS id-targetable; rewriting ITS content + // from markdown replaces only the paragraph, so the cell's colspan is NOT lost + // (the span lives on the cell, which patchNode leaves in place). This is the + // correct behavior: no false guard, no loss. The guard's REJECTION logic (when + // the replaced block itself carries/contains an unrepresentable span) is proven + // by the findUnrepresentableTableAttrs unit test — that case is not reachable + // through the id-targeting API because tables/cells carry no addressable id. + const doc = { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableCell", + attrs: { colspan: 2, rowspan: 1 }, + content: [ + { + type: "paragraph", + attrs: { id: "cell-para" }, + content: [{ type: "text", text: "merged" }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + const { state, baseURL } = await spawnCollabStack(doc); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + const res = await client.patchNode(PAGE, "cell-para", { markdown: "rewritten" }); + assert.equal(res.success, true); + // The cell's colspan survives (the span is on the cell, not the paragraph). + const cell = findAll(state.lastDoc, "tableCell")[0]; + assert.equal(cell.attrs.colspan, 2, "the cell's colspan is preserved"); + const para = findAll(cell, "paragraph")[0]; + assert.equal( + (para.content || []).some((n) => n.text === "rewritten"), + true, + ); +}); + +test("patchNode(markdown): a `^[...]` footnote in the fragment lands in the tail list", async () => { + const { state, baseURL } = await spawnCollabStack(seed3()); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + await client.patchNode(PAGE, "target-id", { + markdown: "a claim^[the supporting note]", + }); + + const doc = state.lastDoc; + const lists = findAll(doc, "footnotesList"); + assert.equal(lists.length, 1, "exactly one tail footnotesList"); + const defs = findAll(doc, "footnoteDefinition"); + assert.equal(defs.length, 1, "one definition for the fragment footnote"); + const refs = findAll(doc, "footnoteReference"); + assert.equal(refs.length, 1, "one reference in the body"); + // Reference and definition share an id (renumbered canonically). + assert.equal(refs[0].attrs.id, defs[0].attrs.id); +}); + +test("insertNode(markdown): inserts N blocks in order after the anchor", async () => { + const before = seed3(); + const { state, baseURL } = await spawnCollabStack(before); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + const res = await client.insertNode( + PAGE, + { markdown: "new one\n\nnew two" }, + { position: "after", anchorNodeId: "before-id" }, + ); + assert.equal(res.success, true); + assert.equal(res.blocks, 2); + + const texts = state.lastDoc.content.map((p) => (p.content || []).map((n) => n.text).join("")); + // Order: before, new one, new two, target, after. + assert.deepEqual(texts, ["before", "new one", "new two", "old target", "after"]); +}); + +test("insertNode(markdown): XOR — both markdown and node is rejected", async () => { + const { baseURL } = await spawnCollabStack(seed3()); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + await assert.rejects( + () => + client.insertNode( + PAGE, + { markdown: "x", node: { type: "paragraph" } }, + { position: "append" }, + ), + /exactly one of/i, + ); +}); diff --git a/packages/mcp/test/unit/markdown-fragment.test.mjs b/packages/mcp/test/unit/markdown-fragment.test.mjs new file mode 100644 index 00000000..e704a8a5 --- /dev/null +++ b/packages/mcp/test/unit/markdown-fragment.test.mjs @@ -0,0 +1,121 @@ +// #413: unit tests for the markdown-fragment helpers used by patchNode/insertNode. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + importMarkdownFragment, + canBeDocChild, + findUnrepresentableTableAttrs, +} from "../../build/lib/markdown-fragment.js"; + +function findAll(node, type, acc = []) { + if (!node || typeof node !== "object") return acc; + if (node.type === type) acc.push(node); + if (Array.isArray(node.content)) + for (const c of node.content) findAll(c, type, acc); + return acc; +} + +test("importMarkdownFragment: plain markdown -> blocks, no definitions", async () => { + const { blocks, definitions } = await importMarkdownFragment( + "first\n\nsecond", + ); + assert.equal(blocks.length, 2); + assert.equal(definitions.length, 0); + assert.equal(blocks[0].type, "paragraph"); +}); + +test("importMarkdownFragment: `^[...]` footnote -> a definition + a remapped ref", async () => { + const { blocks, definitions } = await importMarkdownFragment( + "a claim^[the note]", + ); + assert.equal(definitions.length, 1); + const refs = findAll({ type: "doc", content: blocks }, "footnoteReference"); + assert.equal(refs.length, 1); + // The reference id must match the (remapped) definition id. + assert.equal(refs[0].attrs.id, definitions[0].attrs.id); + // The id is NOT the importer's sequential "fn-1" — it was remapped to a fresh + // uuid so it cannot collide with a page footnote of the same number. + assert.notEqual(refs[0].attrs.id, "fn-1"); +}); + +test("importMarkdownFragment: whitespace markdown imports to a single empty paragraph", async () => { + // The importer yields one empty paragraph for whitespace-only input (not zero + // blocks), so the fragment path returns that block. The client's XOR guard + // (markdown.trim() !== "") is what rejects an empty-string patch up front, so + // importMarkdownFragment never sees a truly empty string via patch/insert. + const { blocks, definitions } = await importMarkdownFragment(" \n "); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].type, "paragraph"); + assert.equal(definitions.length, 0); +}); + +test("canBeDocChild: paragraph/heading/table are doc children; tableRow/cell are not", () => { + assert.equal(canBeDocChild("paragraph"), true); + assert.equal(canBeDocChild("heading"), true); + assert.equal(canBeDocChild("table"), true); + assert.equal(canBeDocChild("tableRow"), false); + assert.equal(canBeDocChild("tableCell"), false); + assert.equal(canBeDocChild("tableHeader"), false); + assert.equal(canBeDocChild("text"), false); + assert.equal(canBeDocChild(undefined), false); + assert.equal(canBeDocChild("notARealType"), false); +}); + +const cell = (attrs, text) => ({ + type: "tableCell", + attrs, + content: [{ type: "paragraph", content: [{ type: "text", text }] }], +}); + +test("findUnrepresentableTableAttrs: null for a plain paragraph and a simple table", () => { + assert.equal( + findUnrepresentableTableAttrs({ + type: "paragraph", + content: [{ type: "text", text: "x" }], + }), + null, + ); + const simpleTable = { + type: "table", + content: [ + { + type: "tableRow", + content: [cell({ colspan: 1, rowspan: 1 }, "a")], + }, + ], + }; + assert.equal(findUnrepresentableTableAttrs(simpleTable), null); +}); + +test("findUnrepresentableTableAttrs: flags colspan/rowspan/colwidth/backgroundColor", () => { + const mk = (attrs) => ({ + type: "table", + content: [{ type: "tableRow", content: [cell(attrs, "a")] }], + }); + assert.match(findUnrepresentableTableAttrs(mk({ colspan: 2 })), /colspan/); + assert.match(findUnrepresentableTableAttrs(mk({ rowspan: 2 })), /rowspan/); + assert.match( + findUnrepresentableTableAttrs(mk({ colwidth: [120] })), + /colwidth/, + ); + assert.match( + findUnrepresentableTableAttrs(mk({ backgroundColor: "#eee" })), + /backgroundColor/, + ); +}); + +test("findUnrepresentableTableAttrs: finds a span nested deep (table inside a callout)", () => { + const doc = { + type: "callout", + content: [ + { + type: "table", + content: [ + { type: "tableRow", content: [cell({ colspan: 3 }, "wide")] }, + ], + }, + ], + }; + assert.match(findUnrepresentableTableAttrs(doc), /colspan/); +}); diff --git a/packages/mcp/test/unit/tool-specs.test.mjs b/packages/mcp/test/unit/tool-specs.test.mjs index b3b84bd3..e94ee222 100644 --- a/packages/mcp/test/unit/tool-specs.test.mjs +++ b/packages/mcp/test/unit/tool-specs.test.mjs @@ -81,49 +81,68 @@ test("editPageText builder produces { pageId, edits } and drops the stale strip- assert.match(spec.description, /REFUSED into\s+failed\[\]/); }); -test("getNode builder produces exactly { pageId, nodeId }", () => { - const shape = SHARED_TOOL_SPECS.getNode.buildShape(z); - assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]); +// #413: getNode gained an optional `format` (markdown default / json opt-in). +test("getNode builder produces { pageId, nodeId, format? } with format optional", () => { + const spec = SHARED_TOOL_SPECS.getNode; + const shape = spec.buildShape(z); + assert.deepEqual(Object.keys(shape).sort(), ["format", "nodeId", "pageId"]); + const schema = z.object(shape); + // format is optional (markdown default lives in the client). + assert.doesNotThrow(() => schema.parse({ pageId: "p1", nodeId: "n1" })); + assert.doesNotThrow(() => + schema.parse({ pageId: "p1", nodeId: "n1", format: "json" }), + ); + assert.throws(() => + schema.parse({ pageId: "p1", nodeId: "n1", format: "yaml" }), + ); + // The description advertises the markdown default and the json opt-in. + assert.match(spec.description, /markdown/i); + assert.match(spec.description, /json/i); }); -test("patchNode spec exists, merges BOTH descriptions, builds { pageId, nodeId, node }", () => { +// #413: patchNode takes XOR { markdown | node } (both schema-optional). +test("patchNode spec exists, describes markdown+node XOR, builds { pageId, nodeId, markdown?, node? }", () => { const spec = SHARED_TOOL_SPECS.patchNode; assert.ok(spec, "patchNode spec missing"); assert.equal(spec.mcpName, "patchNode"); assert.equal(spec.inAppKey, "patchNode"); - // The canonical description must carry the key guidance from BOTH originals: - // - MCP-only: "WITHOUT resending the whole document" + the cheaper/safer note. - // - in-app-only: "keeps the same node id" + the "Reversible ... page history" - // framing the MCP copy lacked. - assert.match(spec.description, /WITHOUT resending the whole document/); - assert.match(spec.description, /Cheaper and safer/); - assert.match(spec.description, /keeps the same node id/i); + // The canonical description must carry the #413 guidance. + assert.match(spec.description, /WITHOUT/i); + assert.match(spec.description, /EXACTLY ONE of `markdown` or `node`/); + assert.match(spec.description, /RECOMMENDED/); + assert.match(spec.description, /keeps the same block id/i); assert.match(spec.description, /Reversible/i); assert.match(spec.description, /page history/i); const shape = spec.buildShape(z); - assert.deepEqual(Object.keys(shape).sort(), ["node", "nodeId", "pageId"]); - // A minimal valid input parses (node accepts an arbitrary object via z.any()). - const parsed = z.object(shape).parse({ + assert.deepEqual( + Object.keys(shape).sort(), + ["markdown", "node", "nodeId", "pageId"], + ); + // markdown and node are BOTH optional in the schema (XOR enforced at runtime). + const schema = z.object(shape); + const parsedMd = schema.parse({ pageId: "p1", nodeId: "n1", markdown: "hi" }); + assert.equal(parsedMd.markdown, "hi"); + const parsedNode = schema.parse({ pageId: "p1", nodeId: "n1", node: { type: "paragraph" }, }); - assert.equal(parsed.pageId, "p1"); - assert.equal(parsed.nodeId, "n1"); + assert.equal(parsedNode.pageId, "p1"); + // Neither given parses at the schema level (the client throws the XOR error). + assert.doesNotThrow(() => schema.parse({ pageId: "p1", nodeId: "n1" })); }); -test("insertNode spec exists, merges BOTH descriptions, builds the full anchor shape", () => { +// #413: insertNode also takes XOR { markdown | node } plus the anchor shape. +test("insertNode spec exists, describes markdown+node XOR, builds the full anchor+content shape", () => { const spec = SHARED_TOOL_SPECS.insertNode; assert.ok(spec, "insertNode spec missing"); assert.equal(spec.mcpName, "insertNode"); assert.equal(spec.inAppKey, "insertNode"); - // Canonical description must keep BOTH sides' nuance: - // - in-app-only: "EXACTLY ONE of anchorNodeId or anchorText" + "Reversible". - // - MCP-only: the table-structure (tableRow/tableCell) insertion guidance. assert.match(spec.description, /EXACTLY ONE of anchorNodeId or anchorText/); + assert.match(spec.description, /EXACTLY ONE of `markdown` or `node`/); assert.match(spec.description, /tableRow/); assert.match(spec.description, /append is top-level only/); assert.match(spec.description, /Reversible via page history/); @@ -131,15 +150,18 @@ test("insertNode spec exists, merges BOTH descriptions, builds the full anchor s const shape = spec.buildShape(z); assert.deepEqual( Object.keys(shape).sort(), - ["anchorNodeId", "anchorText", "node", "pageId", "position"], + ["anchorNodeId", "anchorText", "markdown", "node", "pageId", "position"], ); - // before/after/append are the only accepted positions; anchors are optional. + // before/after/append are the only accepted positions; markdown/node/anchors optional. const schema = z.object(shape); + assert.doesNotThrow(() => + schema.parse({ pageId: "p1", markdown: "hi", position: "append" }), + ); assert.doesNotThrow(() => schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }), ); assert.throws(() => - schema.parse({ pageId: "p1", node: {}, position: "sideways" }), + schema.parse({ pageId: "p1", markdown: "x", position: "sideways" }), ); }); diff --git a/packages/prosemirror-markdown/src/lib/index.ts b/packages/prosemirror-markdown/src/lib/index.ts index 5cdbdb91..2b42e778 100644 --- a/packages/prosemirror-markdown/src/lib/index.ts +++ b/packages/prosemirror-markdown/src/lib/index.ts @@ -53,11 +53,13 @@ export { buildOutline, getNodeByRef, replaceNodeById, + replaceNodeByIdWithMany, deleteNodeById, sanitizeForYjs, findUnstorableAttr, findInvalidNode, insertNodeRelative, + insertNodesRelative, readTable, insertTableRow, deleteTableRow, diff --git a/packages/prosemirror-markdown/src/lib/node-ops.ts b/packages/prosemirror-markdown/src/lib/node-ops.ts index eadc5db0..a60b7978 100644 --- a/packages/prosemirror-markdown/src/lib/node-ops.ts +++ b/packages/prosemirror-markdown/src/lib/node-ops.ts @@ -217,6 +217,54 @@ export function replaceNodeById( return { doc: out, replaced }; } +/** + * Splice a SINGLE node whose `attrs.id === nodeId` with an ORDERED ARRAY of new + * nodes (a "1 -> N" replacement), anywhere in the tree. Used by the markdown + * patch path, where importing a markdown fragment can yield several blocks that + * must replace one existing block in place ("rewrite a section" in one call). + * + * Unlike `replaceNodeById` (which substitutes EVERY match), this walks to the + * FIRST match only and splices `newNodes` in its position, so ordering and the + * neighbouring blocks are preserved byte-for-byte. It deliberately does NOT + * touch further duplicates: the caller (#159 semantics) must have already + * verified the id is unambiguous via a `replaceNodeById` dry pass, so a single + * splice here is safe and every other block is untouched. + * + * Each entry of `newNodes` is deep-cloned so they never share references with + * each other or with the caller\'s array. Operates on a clone of `doc`; returns + * `{ doc, replaced }` where `replaced` is 1 when a match was spliced, else 0. + */ +export function replaceNodeByIdWithMany( + doc: any, + nodeId: string, + newNodes: any[], +): { doc: any; replaced: number } { + const out = clone(doc); + const fresh = Array.isArray(newNodes) ? newNodes.map((n) => clone(n)) : []; + let replaced = 0; + + // Walk to the FIRST match and splice the array in its place; stop afterwards. + const walkContent = (content: any[]): boolean => { + for (let i = 0; i < content.length; i++) { + const child = content[i]; + if (matchesId(child, nodeId)) { + content.splice(i, 1, ...fresh); + replaced = 1; + return true; + } + if (isObject(child) && Array.isArray(child.content)) { + if (walkContent(child.content)) return true; + } + } + return false; + }; + + if (isObject(out) && Array.isArray(out.content)) { + walkContent(out.content); + } + return { doc: out, replaced }; +} + /** * Remove EVERY node whose `attrs.id === nodeId` from its parent `content` * array, anywhere in the tree (recursive, including callouts and tables). @@ -725,6 +773,88 @@ export function insertNodeRelative( return { doc: out, inserted: false }; } +/** + * Insert an ORDERED ARRAY of nodes relative to an anchor, preserving their + * order. This is the multi-node twin of `insertNodeRelative`, used by the + * markdown insert path where importing a markdown fragment can yield several + * blocks that must land, in order, at one anchor. + * + * Semantics mirror `insertNodeRelative` exactly: + * - position "append": push every node onto the top-level `doc.content`. + * - position "before"/"after": splice every node into the anchor\'s parent + * `content` array immediately before / after it, keeping array order. + * + * The structural-table branch of `insertNodeRelative` is intentionally NOT + * duplicated here: a markdown fragment can never produce a bare tableRow/ + * tableCell/tableHeader (those are not expressible in markdown), so the markdown + * insert path only ever hands whole top-level blocks. Structural inserts stay on + * the single-node JSON path. An empty `nodes` array is a no-op that still + * reports `inserted:false` (nothing to place). + * + * Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is false + * when the anchor could not be resolved (doc returned unchanged apart from the + * clone) or when `nodes` is empty. + */ +export function insertNodesRelative( + doc: any, + nodes: any[], + opts: InsertOptions, +): { doc: any; inserted: boolean } { + const out = clone(doc); + const fresh = Array.isArray(nodes) ? nodes.map((n) => clone(n)) : []; + + if (!isObject(opts) || fresh.length === 0) { + return { doc: out, inserted: false }; + } + + // "append": push every node at the top level, in order. + if (opts.position === "append") { + if (isObject(out)) { + if (!Array.isArray(out.content)) out.content = []; + out.content.push(...fresh); + return { doc: out, inserted: true }; + } + return { doc: out, inserted: false }; + } + + const offset = opts.position === "after" ? 1 : 0; + + // Resolve by id anywhere in the tree: splice the whole array into the parent. + if (opts.anchorNodeId != null) { + let inserted = false; + const walkContent = (content: any[]): void => { + for (let i = 0; i < content.length; i++) { + const child = content[i]; + if (matchesId(child, opts.anchorNodeId as string)) { + content.splice(i + offset, 0, ...fresh); + inserted = true; + return; + } + if (isObject(child) && Array.isArray(child.content)) { + walkContent(child.content); + if (inserted) return; + } + } + }; + if (isObject(out) && Array.isArray(out.content)) { + walkContent(out.content); + } + return { doc: out, inserted }; + } + + // Resolve by text: only top-level doc.content blocks are scanned. Exact match + // wins; a markdown-stripped fallback is tried only on a miss. + if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) { + const i = findAnchorTextIndex(out.content, opts.anchorText); + if (i !== -1) { + out.content.splice(i + offset, 0, ...fresh); + return { doc: out, inserted: true }; + } + } + + return { doc: out, inserted: false }; +} + // =========================================================================== // Table editing helpers // diff --git a/packages/prosemirror-markdown/test/node-ops-splice.test.ts b/packages/prosemirror-markdown/test/node-ops-splice.test.ts new file mode 100644 index 00000000..5e063a4f --- /dev/null +++ b/packages/prosemirror-markdown/test/node-ops-splice.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + replaceNodeByIdWithMany, + insertNodesRelative, +} from "../src/lib/node-ops.js"; + +// #413: the array-splice helpers used by the markdown patch/insert paths. +const p = (id: string, t: string): any => ({ + type: "paragraph", + attrs: { id }, + content: [{ type: "text", text: t }], +}); + +describe("replaceNodeByIdWithMany", () => { + it("splices N nodes in place of the first match, keeping neighbours byte-identical", () => { + const before = { type: "doc", content: [p("a", "A"), p("b", "B"), p("c", "C")] }; + const snap = JSON.parse(JSON.stringify(before)); + const { doc, replaced } = replaceNodeByIdWithMany(before, "b", [ + p("b1", "B1"), + p("b2", "B2"), + ]); + expect(replaced).toBe(1); + expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "b1", "b2", "c"]); + // Input never mutated. + expect(before).toEqual(snap); + // Neighbours byte-identical. + expect(doc.content[0]).toEqual(snap.content[0]); + expect(doc.content[3]).toEqual(snap.content[2]); + }); + + it("reaches a nested match (inside a callout) and splices there", () => { + const before = { + type: "doc", + content: [ + { type: "callout", attrs: { id: "co" }, content: [p("x", "X")] }, + ], + }; + const { doc, replaced } = replaceNodeByIdWithMany(before, "x", [ + p("x1", "X1"), + p("x2", "X2"), + ]); + expect(replaced).toBe(1); + expect(doc.content[0].content.map((n: any) => n.attrs.id)).toEqual(["x1", "x2"]); + }); + + it("only touches the FIRST duplicate (caller guards ambiguity)", () => { + const before = { type: "doc", content: [p("d", "1"), p("d", "2")] }; + const { doc, replaced } = replaceNodeByIdWithMany(before, "d", [p("n", "N")]); + expect(replaced).toBe(1); + // First replaced; the second duplicate survives untouched. + expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["n", "d"]); + }); + + it("reports replaced:0 for no match, doc unchanged", () => { + const before = { type: "doc", content: [p("a", "A")] }; + const { doc, replaced } = replaceNodeByIdWithMany(before, "zzz", [p("n", "N")]); + expect(replaced).toBe(0); + expect(doc).toEqual(before); + }); +}); + +describe("insertNodesRelative", () => { + it("inserts an ordered array after an id anchor", () => { + const before = { type: "doc", content: [p("a", "A"), p("b", "B")] }; + const { doc, inserted } = insertNodesRelative( + before, + [p("n1", "N1"), p("n2", "N2")], + { position: "after", anchorNodeId: "a" }, + ); + expect(inserted).toBe(true); + expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2", "b"]); + }); + + it("inserts before an id anchor", () => { + const before = { type: "doc", content: [p("a", "A"), p("b", "B")] }; + const { doc } = insertNodesRelative(before, [p("n", "N")], { + position: "before", + anchorNodeId: "b", + }); + expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]); + }); + + it("appends an ordered array at the top level", () => { + const before = { type: "doc", content: [p("a", "A")] }; + const { doc } = insertNodesRelative(before, [p("n1", "N1"), p("n2", "N2")], { + position: "append", + }); + expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2"]); + }); + + it("resolves an anchor by top-level text", () => { + const before = { type: "doc", content: [p("a", "hello there"), p("b", "B")] }; + const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], { + position: "after", + anchorText: "hello", + }); + expect(inserted).toBe(true); + expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]); + }); + + it("reports inserted:false when the anchor is missing", () => { + const before = { type: "doc", content: [p("a", "A")] }; + const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], { + position: "after", + anchorNodeId: "missing", + }); + expect(inserted).toBe(false); + expect(doc).toEqual(before); + }); + + it("is a no-op for an empty node array", () => { + const before = { type: "doc", content: [p("a", "A")] }; + const { doc, inserted } = insertNodesRelative(before, [], { + position: "append", + }); + expect(inserted).toBe(false); + expect(doc).toEqual(before); + }); +}); -- 2.52.0 From e6171a1810503a1d3c20b2267ee61c9d632311f3 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 20:38:06 +0300 Subject: [PATCH 2/8] =?UTF-8?q?fix(mcp):=20=D1=81=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20orphan-=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=20+=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=20block?= =?UTF-8?q?-id=20=D0=B2=20markdown-splice=20(#413,=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правки по внутреннему ревью #413. 1. Orphan-сноска (нарушение «no second canon»). mergeFootnoteDefinitions раньше делал ранний return при пустых definitions, пропуская canonicalizeFootnotes. Если markdown-фрагмент БЕЗ сносок заменял/удалял блок, бывший последним referrer'ом существующей сноски, её определение оставалось orphan в хвостовом списке (полный ре-импорт его бы убрал). Теперь fast-path (возврат doc по ссылке без clone) только когда defs.length===0 И !hasFootnoteArtifacts(doc); иначе клон → append (no-op при пустых) → normalizeAndMergeFootnotes → canonicalizeFootnotes, как при полном импорте. Новый предикат hasFootnoteArtifacts обходит дерево на любой footnotesList/footnoteReference (существующие walk/isObject). Идемпотентно: несвязанный plain-патч на странице со сносками не трогает их топологию (canonicalizeFootnotes шаг 6 возвращает как есть). 2. Block-id disjoint от страницы. Новый экспорт reassignCollidingBlockIds (liveDoc, blocks, skipIndex?) в prosemirror-markdown/node-ops (переиспользует collectIds/makeFreshId) + barrel. Зовётся перед сплайсами в client.ts: patch — (liveDoc, threaded, 0) (skip 0 = блок, унаследовавший id цели); insert — (liveDoc, blocks) без skip. used-аккумулятор ловит и коллизию со страницей, и внутрифрагментную. freshBlockId/freshId без изменений. Тесты (+6, markdown-patch-insert): orphan-repro (0 defs/0 list/0 refs + docsCanonicallyEqual полному импорту), fast-path (footnote-free patch не трогает топологию, соседи byte-identical), insert канонизирует при пустых defs со страничной сноской, уникальность top-level block-id для patch 1→N и insert N. mcp node --test 702/702, pmd vitest 736, tsc чисто. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/src/client.ts | 9 ++ packages/mcp/src/lib/transforms.ts | 34 +++- .../test/mock/markdown-patch-insert.test.mjs | 152 ++++++++++++++++++ .../prosemirror-markdown/src/lib/index.ts | 1 + .../prosemirror-markdown/src/lib/node-ops.ts | 21 +++ 5 files changed, 212 insertions(+), 5 deletions(-) diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 0e532a32..e1ac7fa2 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -33,6 +33,7 @@ import { import { replaceNodeById, replaceNodeByIdWithMany, + reassignCollidingBlockIds, deleteNodeById, assertUnambiguousMatch, insertNodeRelative, @@ -2548,6 +2549,11 @@ export class DocmostClient { guardAttrs = hit ? findUnrepresentableTableAttrs(hit.node) : null; if (guardAttrs != null) return null; + // Re-mint any minted block id that collides with an existing page id + // (skip index 0: its id is intentionally the target nodeId, unique by + // the #159 dry-count above), so the 1 -> N splice stays page-wide unique. + reassignCollidingBlockIds(liveDoc, threaded, 0); + // 1 -> N splice, then merge any fragment footnote definitions into the // page's tail list and re-derive canonical footnote numbering. const { doc: spliced } = replaceNodeByIdWithMany( @@ -2692,6 +2698,9 @@ export class DocmostClient { this.apiUrl, (liveDoc) => { inserted = false; + // Re-mint any minted block id that collides with an existing page id + // (all inserted blocks are fresh, no skip) so the splice stays unique. + if (hasMd) reassignCollidingBlockIds(liveDoc, blocks); // Single-block node path keeps `insertNodeRelative` (it owns the // structural table-node splicing); the markdown path uses the array // splice so N blocks land in order at one anchor. diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index cde42f8d..ac19898c 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -787,16 +787,25 @@ export function insertInlineFootnote( * Same documented caveat as every other write path: full canonicalization drops a * definition no reference points at. * - * A no-op returning `doc` unchanged when `definitions` is empty, so the - * non-footnote fast path stays untouched. When definitions exist the work runs - * through the pure passes (which clone), so the caller\'s `doc` is not mutated. + * NOT merely a no-op when `definitions` is empty: it still canonicalizes when + * the (post-splice) `doc` carries footnote artifacts (a `footnotesList` or any + * `footnoteReference`), so a splice that removed the LAST referrer of a page + * footnote drops the now-orphaned definition — matching a full page re-import + * (which always canonicalizes) and preserving the "canonically identical to the + * same content imported whole" invariant. A truly footnote-free doc (no artifacts + * and no definitions) is returned untouched — the fast path, no clone. When the + * work runs it goes through the pure passes (which clone), so the caller\'s `doc` + * is not mutated. */ export function mergeFootnoteDefinitions(doc: any, definitions: any[]): any { - if (!Array.isArray(definitions) || definitions.length === 0) return doc; + const defs = Array.isArray(definitions) ? definitions : []; + // True fast path ONLY when there is nothing to merge AND nothing to canonicalize + // away; otherwise fall through so an orphan left by a splice is still dropped. + if (defs.length === 0 && !hasFootnoteArtifacts(doc)) return doc; // Clone before appending: `appendDefinition` mutates in place, and the caller // must not see a half-merged doc if a later pass throws. let working = clone(doc); - for (const def of definitions) { + for (const def of defs) { appendDefinition(working, def); } // #419: normalize + merge glyph-forked definitions before canonicalizing. @@ -805,6 +814,21 @@ export function mergeFootnoteDefinitions(doc: any, definitions: any[]): any { return working; } +/** + * True if `doc`'s tree contains any `footnotesList` node OR any + * `footnoteReference` node. Used to decide whether an empty-`definitions` merge + * must still canonicalize (to drop an orphan a splice left behind). + */ +function hasFootnoteArtifacts(doc: any): boolean { + let found = false; + walk(doc, (n) => { + if (isObject(n) && (n.type === "footnotesList" || n.type === "footnoteReference")) { + found = true; + } + }); + return found; +} + /** * Append a definition node so the canonicalizer can order/place it: into the * first existing footnotesList, or a new trailing list when none exists. diff --git a/packages/mcp/test/mock/markdown-patch-insert.test.mjs b/packages/mcp/test/mock/markdown-patch-insert.test.mjs index f508a572..a6b843eb 100644 --- a/packages/mcp/test/mock/markdown-patch-insert.test.mjs +++ b/packages/mcp/test/mock/markdown-patch-insert.test.mjs @@ -384,3 +384,155 @@ test("insertNode(markdown): XOR — both markdown and node is rejected", async ( /exactly one of/i, ); }); + +// A seed page whose ONLY footnote reference lives in the target paragraph p1, +// with a matching definition in a trailing footnotesList. Rewriting p1 with a +// footnote-free fragment removes the last referrer -> the definition is orphaned. +function seedOrphanFootnote() { + return { + type: "doc", + content: [ + { + type: "paragraph", + attrs: { id: "p1" }, + content: [ + { type: "text", text: "a claim" }, + { type: "footnoteReference", attrs: { id: "fn-1", referenceNumber: 1 } }, + ], + }, + { + type: "footnotesList", + content: [ + { + type: "footnoteDefinition", + attrs: { id: "fn-1" }, + content: [ + { + type: "paragraph", + attrs: { id: "def-para" }, + content: [{ type: "text", text: "the supporting note" }], + }, + ], + }, + ], + }, + ], + }; +} + +test("patchNode(markdown): removing the LAST footnote referrer drops the now-orphan definition (canonical convergence)", async () => { + const { state, baseURL } = await spawnCollabStack(seedOrphanFootnote()); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + // The fragment has NO footnotes -> definitions=[]; the splice removes the only + // footnoteReference, leaving the tail definition orphaned. The canonicalization + // pass (which mergeFootnoteDefinitions must still run) has to drop it. + await client.patchNode(PAGE, "p1", { markdown: "just text" }); + + const doc = state.lastDoc; + assert.equal( + findAll(doc, "footnoteDefinition").length, + 0, + "the orphaned definition is dropped", + ); + assert.equal( + findAll(doc, "footnotesList").length, + 0, + "the emptied footnotesList is removed", + ); + assert.equal(findAll(doc, "footnoteReference").length, 0, "no references remain"); + + // Convergence: the persisted result equals the SAME content imported whole. + const full = await markdownToProseMirror("just text"); + const target = doc.content.find((p) => p.attrs?.id === "p1"); + assert.ok( + docsCanonicallyEqual( + { type: "doc", content: [target] }, + { type: "doc", content: [full.content[0]] }, + ), + "the post-splice doc is canonically identical to a full re-import", + ); +}); + +test("patchNode(markdown): a pure-text patch on a footnote-FREE page leaves footnote topology untouched (fast path)", async () => { + const before = seed3(); + const { state, baseURL } = await spawnCollabStack(before); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + await client.patchNode(PAGE, "target-id", { markdown: "plain replacement" }); + + const doc = state.lastDoc; + assert.equal(findAll(doc, "footnotesList").length, 0, "no footnotesList appears"); + assert.equal(findAll(doc, "footnoteDefinition").length, 0, "no definition appears"); + assert.equal(findAll(doc, "footnoteReference").length, 0, "no reference appears"); + // Neighbours byte-identical (the fast path does not clone/reshape the tree). + assert.deepEqual( + doc.content.find((p) => p.attrs?.id === "before-id"), + before.content[0], + ); + assert.deepEqual( + doc.content.find((p) => p.attrs?.id === "after-id"), + before.content[2], + ); +}); + +test("insertNode(markdown): a footnote-free insert on a page carrying a footnote still canonicalizes (definitions empty)", async () => { + // The page has an existing footnote (ref + tail def). Inserting a footnote-free + // fragment keeps the reference alive, so the definition stays — but the write + // path must still run canonicalization (definitions=[]), producing exactly one + // tail list with the reference/definition ids in sync. + const { state, baseURL } = await spawnCollabStack(seedOrphanFootnote()); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + const res = await client.insertNode( + PAGE, + { markdown: "unrelated one\n\nunrelated two" }, + { position: "after", anchorNodeId: "p1" }, + ); + assert.equal(res.success, true); + + const doc = state.lastDoc; + assert.equal(findAll(doc, "footnoteReference").length, 1, "the existing reference survives"); + assert.equal(findAll(doc, "footnotesList").length, 1, "exactly one tail list"); + const defs = findAll(doc, "footnoteDefinition"); + assert.equal(defs.length, 1, "the definition is kept (still referenced)"); + assert.equal(findAll(doc, "footnoteReference")[0].attrs.id, defs[0].attrs.id); +}); + +// Collect every TOP-LEVEL block id in a doc (the invariant the splice dedup +// guarantees is page-wide top-level uniqueness). +function topLevelIds(doc) { + return doc.content + .map((b) => b?.attrs?.id) + .filter((id) => id != null); +} + +test("patchNode(markdown): a 1->N splice yields page-wide UNIQUE top-level block ids", async () => { + const before = seed3(); + const { state, baseURL } = await spawnCollabStack(before); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + await client.patchNode(PAGE, "target-id", { + markdown: "one\n\ntwo\n\nthree", + }); + + const ids = topLevelIds(state.lastDoc); + assert.equal(new Set(ids).size, ids.length, "all top-level block ids are unique"); + // The target id is still present (threaded onto the first block). + assert.ok(ids.includes("target-id"), "the first block still inherits target-id"); +}); + +test("insertNode(markdown): inserting multiple blocks yields page-wide UNIQUE top-level block ids", async () => { + const before = seed3(); + const { state, baseURL } = await spawnCollabStack(before); + const client = new DocmostClient(baseURL, "e@x.com", "pw"); + + await client.insertNode( + PAGE, + { markdown: "alpha\n\nbeta\n\ngamma" }, + { position: "after", anchorNodeId: "before-id" }, + ); + + const ids = topLevelIds(state.lastDoc); + assert.equal(new Set(ids).size, ids.length, "all top-level block ids are unique"); +}); diff --git a/packages/prosemirror-markdown/src/lib/index.ts b/packages/prosemirror-markdown/src/lib/index.ts index 2b42e778..1529fa49 100644 --- a/packages/prosemirror-markdown/src/lib/index.ts +++ b/packages/prosemirror-markdown/src/lib/index.ts @@ -54,6 +54,7 @@ export { getNodeByRef, replaceNodeById, replaceNodeByIdWithMany, + reassignCollidingBlockIds, deleteNodeById, sanitizeForYjs, findUnstorableAttr, diff --git a/packages/prosemirror-markdown/src/lib/node-ops.ts b/packages/prosemirror-markdown/src/lib/node-ops.ts index a60b7978..6d88e3a3 100644 --- a/packages/prosemirror-markdown/src/lib/node-ops.ts +++ b/packages/prosemirror-markdown/src/lib/node-ops.ts @@ -903,6 +903,27 @@ function makeFreshId(used: Set): string { return id; } +/** + * Re-mint any top-level block id in `blocks` that already exists in `liveDoc`, + * so a 1 -> N splice cannot introduce a duplicate id. `skipIndex` (optional) is a + * block whose id is intentionally set (the patch path's first block inherits the + * target node's id) and must not be re-minted. Mutates `blocks` in place. + */ +export function reassignCollidingBlockIds( + liveDoc: any, + blocks: any[], + skipIndex?: number, +): void { + const used = new Set(); + collectIds(liveDoc, used); + blocks.forEach((b, i) => { + if (i === skipIndex || !isObject(b)) return; + if (!isObject(b.attrs)) b.attrs = {}; + if (b.attrs.id != null && used.has(b.attrs.id)) b.attrs.id = makeFreshId(used); + if (b.attrs.id != null) used.add(b.attrs.id); + }); +} + /** * Resolve a table reference against an ALREADY-CLONED doc and return the LIVE * table node (a reference inside `rootClone`, so the caller may mutate it) plus -- 2.52.0 From 4be4a75fa322b92699843b3d712466f8836d8bba Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 20:55:28 +0300 Subject: [PATCH 3/8] =?UTF-8?q?docs(mcp):=20=D1=82=D0=BE=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=B5=D1=80=D1=8C=20getPage/exportPageMarkdo?= =?UTF-8?q?wn=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D1=80=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9=20(#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Описания двух тулов устарели после #345 (канонический конвертер, #293/#351): «LOSSY…approximated» у getPage и «lossless» у exportPageMarkdown искажали роутинг агентов и молчаливо скрывали реальную потерю данных. Четвёртый линк breaking-окна, стоит на #413. - tool-specs.ts: getPage — вместо «LOSSY…approximated» точный закрытый список потерь (canonical for text; теряет лишь id блоков, resolved-якоря комментариев и фиксированный набор ACCEPTED-атрибутов без markdown- представления: спаны/colwidth/фон ячеек таблиц, indent, callout.icon, orderedList.type, link internal/target/rel/class). exportPageMarkdown — убрано «lossless»: round-trip перегенерирует id блоков и молча отбрасывает тот же набор (в первую очередь merge-спаны ячеек); держать в page-JSON, если нужны. Список выведен из источника истины (ATTR_VALUE_FUZZ_ALLOWLIST + MARK_ATTR_ALLOWLIST); opaque carried-verbatim токены (attachmentId, mime, slugId и пр.) намеренно НЕ в списке потерь — они round-trip'ятся. - server-instructions.ts: READ-строка ROUTING_PROSE приведена к тому же точному списку, без противоречия markdown-default роутингу #413. - Исправлена вторая латентная неточность: getPage описывался как сохраняющий resolved-якоря — но client.ts передаёт dropResolvedCommentAnchors: true, resolved-якоря скрыты (getNode, наоборот, их сохраняет — другой тул). - README пакета (EN + RU-зеркало) приведены в соответствие passage-for-passage: убраны безоговорочные «lossless/lossy» для Markdown round-trip; genuinely- lossless ссылки на raw-JSON (getPageJson/getNode) оставлены. Логику не трогает. server-instructions.test.mjs удалён и заменён tool-inventory.test.mjs (без пинов на старую формулировку). mcp tsc чисто, node --test 702/702, tool-inventory 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/README.md | 29 +++++++++++++-------- packages/mcp/README.ru.md | 32 +++++++++++++++-------- packages/mcp/src/server-instructions.ts | 2 +- packages/mcp/src/tool-specs.ts | 34 ++++++++++++++++--------- 4 files changed, 62 insertions(+), 35 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index bdbaf6d9..b16b8c29 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -40,7 +40,7 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison. | **Enterprise license required** | **No** | **Yes** | No | No | No | | Authentication | email + password, **auto re-auth** | API key | email + password | cookie `authToken` (copy from DevTools) | Docmost API / **direct PostgreSQL** | | Read page as Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (read-only) | -| **Lossless Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — | +| **Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — | | Read **lossless ProseMirror JSON** (with block ids) | ✅ | — | — | — | — | | **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — | | **Fetch a single block** (by id or index) | ✅ | — | — | — | — | @@ -115,8 +115,10 @@ All 41 tools, grouped by what you'd reach for them. - **`listPages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50, max 100). Use `search` for lookups in large spaces. - **`search`** — Full-text search across pages and content (bounded by `limit`, max 100). -- **`getPage`** — A page's content as clean **Markdown** (convenient, but a *lossy* - view — block ids and exact table/callout structure are approximated). +- **`getPage`** — A page's content as clean **Markdown** (canonical for text; drops only + block ids, resolved-comment anchors, and a fixed no-Markdown-representation attr set — + table spans/colwidth/background, indent, `callout.icon`, `orderedList.type`, and link + `internal`/`target`/`rel`/`class`; use `getPageJson` when you need those). - **`getPageJson`** — A page's **lossless ProseMirror/TipTap JSON**, including every block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing tools consume. @@ -186,10 +188,14 @@ All 41 tools, grouped by what you'd reach for them. ### Markdown round-trip -- **`exportPageMarkdown`** — Export a page to a single self-contained, **lossless - Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors - and diagrams, and a trailing comments-thread block. To replace a page's body from plain - authoring Markdown, use `updatePageMarkdown`. +- **`exportPageMarkdown`** — Export a page to a single self-contained + **Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors + and diagrams, and a trailing comments-thread block. The download → edit → import + round-trip regenerates block ids and **silently drops** the no-Markdown-representation + attr set (table merge spans/colwidth/background, indent, `callout.icon`, + `orderedList.type`, link `internal`/`target`/`rel`/`class`); keep those in ProseMirror + JSON if they must survive. To replace a page's body from plain authoring Markdown, use + `updatePageMarkdown`. > **Removed in this release:** `importPageMarkdown` (the round-trip parser for an > exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**. @@ -293,15 +299,16 @@ so capable clients steer the model automatically. refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the collaboration-token path), with in-flight login de-duplication so a burst of calls triggers a single re-login. -- **Lossless and lossy reads.** `getPageJson` returns the exact ProseMirror tree with - block ids; `getPage` returns clean Markdown for convenience. +- **Precise reads.** `getPageJson` returns the exact ProseMirror tree with block ids; + `getPage` returns canonical Markdown that drops only a fixed, documented attr set. - **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds, highlights, sub/superscript and more, with defensive caps against pathological input. -- **Structured tables & lossless Markdown round-trip.** Tables can be edited as a matrix +- **Structured tables & Markdown round-trip.** Tables can be edited as a matrix (read, insert/delete rows, set cells by `[row,col]`) without resending the document, and a page can be exported to and re-imported from a self-contained Docmost-flavoured - Markdown file that preserves inline comment anchors and diagrams. + Markdown file that preserves inline comment anchors and diagrams (block ids regenerate + and a fixed no-Markdown-representation attr set is dropped — see `exportPageMarkdown`). - **Token-optimized responses.** API responses are filtered down to the fields agents actually need, and large collections (spaces, pages, comments, history) are paginated. - **Hardened runtime.** Global handlers keep a stray socket error from tearing down the diff --git a/packages/mcp/README.ru.md b/packages/mcp/README.ru.md index 18574af0..8c124439 100644 --- a/packages/mcp/README.ru.md +++ b/packages/mcp/README.ru.md @@ -43,7 +43,7 @@ Docmost-MCP не сочетают: | **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет | | Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** | | Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) | -| **Lossless Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — | +| **Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — | | Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — | | **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — | | **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — | @@ -119,8 +119,11 @@ Docmost-MCP не сочетают: 50, максимум 100). Для поиска в больших пространствах используйте `search`. - **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум 100). -- **`getPage`** — Контент страницы как чистый **Markdown** (удобно, но это - *lossy*-представление — id блоков и точная структура таблиц/коллаутов аппроксимируются). +- **`getPage`** — Контент страницы как чистый **Markdown** (канонично для текста; теряет + лишь id блоков, якоря разрешённых комментариев и фиксированный набор атрибутов без + markdown-представления — спаны/colwidth/фон ячеек таблиц, отступы (indent), + `callout.icon`, `orderedList.type` и `internal`/`target`/`rel`/`class` у ссылок; + используйте `getPageJson`, когда они нужны). - **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id` каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты поблочного редактирования. @@ -191,10 +194,14 @@ Docmost-MCP не сочетают: ### Markdown: экспорт и импорт -- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный, **lossless - Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и - диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из - обычного авторского Markdown, используйте `updatePageMarkdown`. +- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный + **Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и + диаграммами и завершающий блок тредов комментариев. Round-trip скачать → отредактировать → + импортировать перегенерирует id блоков и **молча отбрасывает** набор атрибутов без + markdown-представления (спаны/colwidth/фон ячеек таблиц, отступы (indent), `callout.icon`, + `orderedList.type`, `internal`/`target`/`rel`/`class` у ссылок); держите их в ProseMirror + JSON, если они должны выжить. Чтобы заменить тело страницы из обычного авторского Markdown, + используйте `updatePageMarkdown`. > **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для > экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**. @@ -302,16 +309,19 @@ Docmost-MCP не сочетают: автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один повторный логин. -- **Lossless- и lossy-чтение.** `getPageJson` возвращает точное дерево ProseMirror с id - блоков; `getPage` возвращает чистый Markdown для удобства. +- **Точные чтения.** `getPageJson` возвращает точное дерево ProseMirror с id блоков; + `getPage` возвращает канонический Markdown, теряющий лишь фиксированный, документированный + набор атрибутов. - **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты (включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы, блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами против патологического ввода. -- **Структурные таблицы и lossless Markdown round-trip.** Таблицы можно редактировать как +- **Структурные таблицы и Markdown round-trip.** Таблицы можно редактировать как матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки документа, а страницу — экспортировать и заново импортировать как самодостаточный - Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы. + Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы + (id блоков перегенерируются, а фиксированный набор атрибутов без markdown-представления + отбрасывается — см. `exportPageMarkdown`). - **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно нужных агентам, а большие коллекции (пространства, страницы, комментарии, история) пагинируются. diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index d3a6b74b..95e3f570 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -40,7 +40,7 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; */ export const ROUTING_PROSE = "Docmost editing guide — choose the tool by intent. The at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" + - "READ: find a page -> search (workspace-wide full-text); list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, lossy; inline tags are comment anchors — markup, not text) or getPageJson (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + + "READ: find a page -> search (workspace-wide full-text); list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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.\n" + "PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" + diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index e2488f7c..018a743a 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -877,11 +877,17 @@ export const SHARED_TOOL_SPECS = { inAppKey: 'getPage', description: 'Fetch a single page as Markdown by its id. Returns the page title and ' + - 'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' + - 'table/callout structure are approximated); for a lossless representation ' + - 'use the lossless page-JSON read tool. Inline tags in the markdown ' + - 'are comment highlight anchors (also present for RESOLVED threads) — ' + - 'treat them as markup, not page text.', + 'its Markdown content. The converter is canonical (round-trips text and ' + + 'block structure), so this is sufficient for text edits; use the ' + + 'page-JSON read tool only when you need what Markdown cannot carry. The ' + + 'Markdown drops exactly: (1) block ids (not visible in Markdown); ' + + '(2) resolved-comment anchors (hidden here; only active anchors remain); (3) a fixed set of attributes with no ' + + 'Markdown representation — table-cell colspan/rowspan/colwidth/' + + 'backgroundColor/backgroundColorName, heading/paragraph indent, ' + + 'callout.icon, orderedList.type, and link internal/target/rel/class. ' + + 'Inline tags in the markdown are comment highlight ' + + 'anchors — treat them as markup, not page text.', tier: 'core', catalogLine: 'getPage — fetch a page as Markdown by its id.', // Reconciled: MCP's stricter .min(1) kept; in-app's more-informative @@ -1240,13 +1246,17 @@ export const SHARED_TOOL_SPECS = { inAppKey: 'exportPageMarkdown', // CANONICAL: the MCP copy (a strict superset of the terse in-app wording). 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 -> page-Markdown import ' + - '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.', + 'Export a page to a single self-contained Docmost-flavoured Markdown ' + + 'file (custom extensions): YAML-free meta header, body with inline ' + + 'comment anchors (resolved ones kept) and diagrams, and a trailing ' + + 'comments-thread block. Designed for a download -> edit body -> ' + + 'page-Markdown import round-trip; block ids regenerate and comment ' + + 'THREADS, though kept in the file, are not re-pushed to the server on ' + + 'import. The round-trip SILENTLY DROPS a fixed set of attributes with no ' + + 'Markdown representation — table-cell merge spans (colspan/rowspan), ' + + 'colwidth, backgroundColor/backgroundColorName, heading/paragraph indent, ' + + 'callout.icon, orderedList.type, and link internal/target/rel/class. Use ' + + 'the page-JSON tools if those must survive.', tier: 'deferred', catalogLine: 'exportPageMarkdown — export a page to self-contained Markdown (body + comments).', -- 2.52.0 From e4e788f1513510fd43f6d9d17270ee8f2ae2914b Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 21:34:53 +0300 Subject: [PATCH 4/8] =?UTF-8?q?feat(search):=20=D0=B0=D0=B3=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=81=D0=BA=D0=B8=D0=B9=20lookup-=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B8=D0=BC=20=E2=80=94=20substring=20=D0=BF=D0=BE=20=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B0=D0=BC/=D0=B4=D0=B5=D1=84=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BC/=D1=86=D0=B8=D1=84=D1=80=D0=B0=D0=BC,=20path,=20sn?= =?UTF-8?q?ippet,=20scope=20(#443,=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=201/3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP-сервером пользуются LLM-агенты; основной паттерн — lookup: найти страницу по обрывку технической строки и сразу понять, где она лежит и что в ней. Раньше на такой вопрос уходило 3–5 вызовов. Эта часть закрывает `search` (часть 1 из 3; get_tree и get_page_context — следующими PR стопкой). Только чтение. Серверная часть — opt-in, веб-UI байт-в-байт неизменен: - SearchDTO: опциональные substring/parentPageId/titleOnly (default-off; без флагов путь FTS не тронут — guard `if (substring) return searchPageLookup`). - searchPageLookup: один скан pages, WHERE = title LIKE '%q%' OR (если не titleOnly) text LIKE '%q%' OR (если tsquery непустой) tsv @@ ... . LIKE- метасимволы %/_/\ экранируются (escapeLikePattern, ESCAPE '\') — `%`/`_` не матчат всё; substring-ветка работает даже при пустом tsquery (кейс 10.0.12). - Ранжирование тирами (TITLE_EXACT > TITLE_SUBSTRING > TEXT), вторичный сигнал ts_rank / позиция; score∈(0,1] только для сортировки одной выдачи (формула в комментарии). 200-cap упорядочен по SQL-прокси тира ДО среза (иначе Postgres отдаёт произвольные 200 и сильный хит мог выпасть). Пермишен-фильтр к merged-набору ДО limit. path — одна рекурсивная CTE на все хиты (не N+1). - snippet оконный в SQL (~500 символов вокруг первого совпадения). Позиция и срез в ОДНОМ пространстве LOWER(f_unaccent(...)) — f_unaccent не length- preserving (ß→ss, лигатуры, …→...), иначе окно смещалось/пустело. titleOnly → пустой snippet. Компромисс задокументирован. - Миграция: GIN gin_trgm_ops по LOWER(f_unaccent(text_content)); title-trgm индекс #348 переиспользован (IF NOT EXISTS), down() дропает только новый. MCP: схема search (spaceId/parentPageId/titleOnly/limit 1–50, default 10), client.search прокидывает substring:true, filterSearchResult → {pageId, title, path, snippet, score}. Инвариант: наружу только pageId (UUID), slugId/id никогда. Комментарии про намеренное расхождение с in-app hybrid-RRF (не тронут) и про деградацию на stock-upstream/Typesense (substring→plain FTS, без path/ snippet). Проверка на реальном Postgres: server integration 16/16 (вся acceptance-таблица #443 + регрессии на смещение snippet и cap-200), server unit 27/27, mcp node --test 708/708, tsc чисто. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/search/dto/search-response.dto.ts | 19 + apps/server/src/core/search/dto/search.dto.ts | 25 + .../src/core/search/search.controller.ts | 6 + .../core/search/search.service.lookup.spec.ts | 95 ++++ apps/server/src/core/search/search.service.ts | 393 ++++++++++++++- .../20260706T120000-search-lookup-trgm.ts | 56 +++ .../integration/search-lookup.int-spec.ts | 462 ++++++++++++++++++ packages/mcp/src/client.ts | 26 +- packages/mcp/src/index.ts | 55 ++- packages/mcp/src/lib/filters.ts | 32 +- packages/mcp/src/server-instructions.ts | 4 +- packages/mcp/test/unit/filters.test.mjs | 94 +++- 12 files changed, 1237 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/core/search/search.service.lookup.spec.ts create mode 100644 apps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts create mode 100644 apps/server/test/integration/search-lookup.int-spec.ts diff --git a/apps/server/src/core/search/dto/search-response.dto.ts b/apps/server/src/core/search/dto/search-response.dto.ts index 8f5b343d..e88356ad 100644 --- a/apps/server/src/core/search/dto/search-response.dto.ts +++ b/apps/server/src/core/search/dto/search-response.dto.ts @@ -12,3 +12,22 @@ export class SearchResponseDto { updatedAt: Date; space: Partial; } + +// Response shape for the opt-in agent-lookup mode (#443, `substring: true`). +// Additive to the FTS response: carries the location (`path`), a windowed +// `snippet` around the first match and a per-response sort `score`. The MCP +// layer maps `id → pageId`; `slugId` is never exposed. +export class SearchLookupResponseDto { + id: string; + slugId: string; + title: string; + parentPageId: string | null; + // Ancestor titles from the space root down to the direct parent; [] for a + // root page. + path: string[]; + // ~300–500 chars around the first match (or a leading text window / extended + // ts_headline fallback). + snippet: string; + // 0..1 float, meaningful ONLY for sorting within one response. + score: number; +} diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index f23dd4d5..dd0c0a1b 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -30,6 +30,31 @@ export class SearchDTO { @IsOptional() @IsNumber() offset?: number; + + // --- Opt-in agent-lookup mode (#443). ------------------------------------ + // These fields are ADDITIVE and default-off: a web client that sends none of + // them gets byte-identical FTS behaviour and result shape. They are only read + // by the substring/path/snippet code path in SearchService.searchPage. + // + // NOTE (standalone stdio vs stock upstream): stock upstream validates this DTO + // with `whitelist: true`, so an older server silently strips these unknown + // fields and the request degrades gracefully to the plain FTS behaviour. + + // Enables the hybrid substring branch (title + text_content LIKE) merged with + // the existing FTS branch, plus tiered ranking, path and windowed snippet. + @IsOptional() + @IsBoolean() + substring?: boolean; + + // Restrict the search to a page and all of its descendants (inclusive). + @IsOptional() + @IsString() + parentPageId?: string; + + // Match titles only; do not scan text_content. + @IsOptional() + @IsBoolean() + titleOnly?: boolean; } export class SearchShareDTO extends SearchDTO { diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts index c968c344..7504174a 100644 --- a/apps/server/src/core/search/search.controller.ts +++ b/apps/server/src/core/search/search.controller.ts @@ -60,6 +60,12 @@ export class SearchController { } } + // #443 graceful degradation: on EE/Typesense instances the request routes to + // the Typesense backend, which does NOT implement the opt-in agent-lookup + // mode. The `substring`/`parentPageId`/`titleOnly` fields are silently ignored + // and the response carries no `path`/`snippet`/`score` and no substring/tier + // ranking — it degrades to plain Typesense FTS. The native lookup mode below + // is Postgres-search-driver only. if (this.environmentService.getSearchDriver() === 'typesense') { return this.searchTypesense(searchDto, { userId: user.id, diff --git a/apps/server/src/core/search/search.service.lookup.spec.ts b/apps/server/src/core/search/search.service.lookup.spec.ts new file mode 100644 index 00000000..46d7f798 --- /dev/null +++ b/apps/server/src/core/search/search.service.lookup.spec.ts @@ -0,0 +1,95 @@ +import { + computeLookupScore, + escapeLikePattern, + SearchLookupTier, +} from './search.service'; + +/** + * Pure-function coverage for the #443 agent-lookup helpers: + * - escapeLikePattern: LIKE-metacharacter escaping so `%`/`_`/`\` are literals + * (the acceptance-table requirement that a query of `%` or `_` does NOT match + * everything); + * - computeLookupScore: the tiered 0..1 ranking score, where a stronger tier + * always outranks a weaker one regardless of the in-tier secondary signal. + * + * The DB-touching branch (substring UNION FTS, path CTE, snippet window) is + * covered by the integration spec against the real schema. + */ +describe('escapeLikePattern', () => { + it('escapes the LIKE metacharacters % _ and \\', () => { + expect(escapeLikePattern('%')).toBe('\\%'); + expect(escapeLikePattern('_')).toBe('\\_'); + expect(escapeLikePattern('\\')).toBe('\\\\'); + }); + + it('escapes the backslash FIRST so it does not double-escape %/_', () => { + // Input `\%` must become `\\` + `\%` = `\\\%`, not `\\%`. + expect(escapeLikePattern('\\%')).toBe('\\\\\\%'); + }); + + it('leaves ordinary technical chars (. - / digits) untouched', () => { + expect(escapeLikePattern('backup-srv.local')).toBe('backup-srv.local'); + expect(escapeLikePattern('10.0.12')).toBe('10.0.12'); + expect(escapeLikePattern('WB-MGE-30D86B')).toBe('WB-MGE-30D86B'); + expect(escapeLikePattern('a/b')).toBe('a/b'); + }); + + it('escapes only the metacharacters in a mixed string', () => { + expect(escapeLikePattern('50%_off.zip')).toBe('50\\%\\_off.zip'); + }); + + it('is null/undefined-safe', () => { + expect(escapeLikePattern(undefined as any)).toBe(''); + expect(escapeLikePattern(null as any)).toBe(''); + }); +}); + +describe('computeLookupScore', () => { + it('keeps every score within (0, 1]', () => { + for (const tier of [ + SearchLookupTier.TITLE_EXACT, + SearchLookupTier.TITLE_SUBSTRING, + SearchLookupTier.TEXT, + ]) { + for (const secondary of [0, 0.001, 1, 100, 1e6]) { + const s = computeLookupScore({ tier, secondary }); + expect(s).toBeGreaterThan(0); + expect(s).toBeLessThanOrEqual(1); + } + } + }); + + it('a stronger tier ALWAYS outranks a weaker tier, whatever the secondary', () => { + // Weak tier with a huge secondary must still lose to a strong tier with a + // tiny secondary — tiers dominate. + const strongLowSecondary = computeLookupScore({ + tier: SearchLookupTier.TITLE_EXACT, + secondary: 0, + }); + const weakHighSecondary = computeLookupScore({ + tier: SearchLookupTier.TEXT, + secondary: 1e9, + }); + expect(strongLowSecondary).toBeGreaterThan(weakHighSecondary); + }); + + it('within a tier a larger secondary sorts higher', () => { + const lo = computeLookupScore({ + tier: SearchLookupTier.TEXT, + secondary: 0.1, + }); + const hi = computeLookupScore({ + tier: SearchLookupTier.TEXT, + secondary: 5, + }); + expect(hi).toBeGreaterThan(lo); + }); + + it('treats a negative/absent secondary as 0', () => { + const zero = computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: 0 }); + expect(computeLookupScore({ tier: SearchLookupTier.TEXT })).toBe(zero); + expect( + computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: -5 }), + ).toBe(zero); + }); +}); diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 91e5e5f4..111c5938 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -1,6 +1,9 @@ import { Injectable } from '@nestjs/common'; import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto'; -import { SearchResponseDto } from './dto/search-response.dto'; +import { + SearchLookupResponseDto, + SearchResponseDto, +} from './dto/search-response.dto'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { sql } from 'kysely'; @@ -34,6 +37,53 @@ export function buildTsQuery(raw: string): string { return tsquery(cleaned + '*'); } +// Escape the LIKE metacharacters (`%`, `_`, `\`) in a raw user query so every +// character — including `.`, `-`, `_`, `%`, `/` — is matched LITERALLY by a +// `col LIKE '%' || q || '%'` predicate. Without this, a query of `%` or `_` +// would match every row (see the #443 acceptance table). The backslash is the +// escape char (Postgres LIKE default), so it must be escaped first. +export function escapeLikePattern(raw: string): string { + return (raw ?? '') + .replace(/\\/g, '\\\\') + .replace(/%/g, '\\%') + .replace(/_/g, '\\_'); +} + +// Ranking tiers for the agent-lookup mode (#443), highest first. A hit's tier +// is the strongest way it matched; ties inside a tier break on a secondary +// signal (FTS rank, or first-match position). The numeric `score` returned to +// the caller is derived from (tier, secondary) and is meaningful ONLY for +// ordering within a single response. +export enum SearchLookupTier { + // Title equals the query, case-insensitively. + TITLE_EXACT = 3, + // Query is a substring of the title. + TITLE_SUBSTRING = 2, + // Query matched in the text (substring or FTS). + TEXT = 1, +} + +export interface RankableHit { + tier: SearchLookupTier; + // Secondary in-tier signal, higher = better (e.g. ts_rank, or a + // position-derived closeness score). Defaults to 0. + secondary?: number; +} + +// Map (tier, secondary) → a 0..1 float used ONLY to sort one response. +// +// Formula: score = (tier + squash(secondary)) / (maxTier + 1), where +// squash(x) = x / (1 + x) maps any non-negative secondary into [0, 1) +// so a stronger tier ALWAYS outranks a weaker one regardless of the secondary +// value, and within a tier a larger secondary sorts higher. maxTier is the top +// enum value (TITLE_EXACT = 3), so the divisor keeps the result in (0, 1]. +export function computeLookupScore(hit: RankableHit): number { + const maxTier = SearchLookupTier.TITLE_EXACT; + const secondary = Math.max(0, hit.secondary ?? 0); + const squashed = secondary / (1 + secondary); + return (hit.tier + squashed) / (maxTier + 1); +} + @Injectable() export class SearchService { constructor( @@ -50,12 +100,19 @@ export class SearchService { userId?: string; workspaceId: string; }, - ): Promise<{ items: SearchResponseDto[] }> { + ): Promise<{ items: SearchResponseDto[] | SearchLookupResponseDto[] }> { const { query } = searchParams; if (query.length < 1) { return { items: [] }; } + + // Opt-in agent-lookup mode (#443). Guarded by the `substring` flag so the + // web-UI (which never sets it) keeps byte-identical FTS behaviour below. + if (searchParams.substring) { + return this.searchPageLookup(searchParams, opts); + } + const searchQuery = buildTsQuery(query); let queryResults = this.db @@ -175,6 +232,338 @@ export class SearchService { return { items: searchResults }; } + /** + * Agent-lookup search (#443, opt-in via `SearchDTO.substring`). + * + * ADDITIVE to the FTS path: runs a substring branch (title + optionally + * text_content, LIKE with metacharacters escaped) MERGED with the existing + * FTS branch, so technical tokens that the `english` tokenizer mangles + * (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are still found — even + * when `buildTsQuery()` returns '' for a dotted/numeric query. Results carry a + * location (`path`), a windowed `snippet` and a per-response `score`. + * + * The whole method is only reached when `substring: true`; the web-UI never + * sets it, so its behaviour is unchanged. + */ + private async searchPageLookup( + searchParams: SearchDTO, + opts: { userId?: string; workspaceId: string }, + ): Promise<{ items: SearchLookupResponseDto[] }> { + const rawQuery = searchParams.query.trim(); + if (!rawQuery) { + return { items: [] }; + } + + const limit = Math.min(Math.max(searchParams.limit || 10, 1), 50); + + // Normalize the query the same way as the FTS / suggest path: f_unaccent + + // lower, done in SQL. `q` is the escaped LIKE pattern body (literal chars). + const likeBody = escapeLikePattern(rawQuery); + // Compare against `LOWER(f_unaccent(col))`; unaccent+lower the needle too. + const needle = sql`LOWER(f_unaccent(${rawQuery}))`; + const likePattern = sql`LOWER(f_unaccent(${'%' + likeBody + '%'}))`; + const tsQuery = buildTsQuery(rawQuery); + const hasTsQuery = tsQuery.length > 0; + + // --- Resolve the space scope. --------------------------------------------- + // Mirrors searchPage: explicit spaceId, else the authenticated user's member + // spaces. The share path is not exposed to this opt-in mode. + let spaceIds: string[] = []; + if (searchParams.spaceId) { + spaceIds = [searchParams.spaceId]; + } else if (opts.userId) { + spaceIds = await this.spaceMemberRepo.getUserSpaceIds(opts.userId); + } else { + return { items: [] }; + } + if (spaceIds.length === 0) { + return { items: [] }; + } + + // --- Optional parentPageId subtree scope (inclusive). --------------------- + // Reuse the same recursive-descendants pattern used for share-scope. + let descendantIds: string[] | null = null; + if (searchParams.parentPageId) { + const descendants = await this.pageRepo.getPageAndDescendants( + searchParams.parentPageId, + { includeContent: false }, + ); + descendantIds = descendants.map((p: any) => p.id); + if (descendantIds.length === 0) { + return { items: [] }; + } + } + + // --- Candidate query: substring (title + text) UNION FTS. ----------------- + // We compute everything the ranker needs in SQL and pull only small columns + // (never the whole text_content) into Node: + // - titleExact / titleSub: tier signals + // - textMatchPos: 1-based position of the first text match (0 = none) + // - ftsRank: ts_rank for the FTS secondary signal (0 when no tsquery) + // - snippet: windowed ~500 chars around the first text match, or a leading + // text window (title-only hit), or an extended ts_headline fallback. + const N_BEFORE = 60; // chars of context before the first match + const SNIPPET_LEN = 500; + + let candidates = this.db + .selectFrom('pages') + .select([ + 'pages.id as id', + 'pages.slugId as slugId', + 'pages.title as title', + 'pages.parentPageId as parentPageId', + // Tier signals. + sql`LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}`.as( + 'titleExact', + ), + sql`LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\'`.as( + 'titleSub', + ), + // 1-based position of the first text match (0 = no text match). + sql`strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle})`.as( + 'textMatchPos', + ), + // FTS secondary signal (0 when the tsquery is empty). + hasTsQuery + ? sql`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`.as( + 'ftsRank', + ) + : sql`0`.as('ftsRank'), + // Windowed snippet, computed entirely in SQL. Priority: + // 1. window around the first text match; + // 2. otherwise (titleOnly: no snippet; else) a leading window of the + // page text (title-only hit); + // 3. otherwise an extended ts_headline for pure-FTS hits. + // + // #443 snippet-position fix: the match position (`strpos`) is computed in + // the LOWER(f_unaccent(...)) space, but f_unaccent is NOT length- + // preserving (ß→ss, æ→ae, …→..., ½→ 1/2, full-width forms), so slicing + // the ORIGINAL text at that position was misaligned — a single expanding + // char before the match shifted the window (or ran it past end → empty). + // We now slice from the SAME LOWER(f_unaccent(...)) string so position + // and slice share one coordinate space. DELIBERATE trade-off: the snippet + // loses original case/diacritics — acceptable for an agent-facing snippet + // (position accuracy over original-glyph fidelity). The ts_headline branch + // matches over the ORIGINAL text itself, so it is unaffected and kept as-is. + searchParams.titleOnly + ? sql`''`.as('snippet') + : sql` + coalesce( + case + when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) > 0 + then substring( + LOWER(f_unaccent(coalesce(pages.text_content, ''))) + from greatest(1, strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) - ${N_BEFORE}) + for ${SNIPPET_LEN} + ) + when coalesce(pages.text_content, '') <> '' + then substring(LOWER(f_unaccent(pages.text_content)) from 1 for 300) + ${ + hasTsQuery + ? sql`else ts_headline('english', coalesce(pages.text_content, ''), to_tsquery('english', f_unaccent(${tsQuery})), 'MinWords=25, MaxWords=40, MaxFragments=3')` + : sql`` + } + end, + '' + ) + `.as('snippet'), + ]) + .where('pages.deletedAt', 'is', null) + .where('pages.spaceId', 'in', spaceIds); + + if (descendantIds) { + candidates = candidates.where('pages.id', 'in', descendantIds); + } + + // Match predicate: title substring OR (unless titleOnly) text substring OR + // (unless titleOnly) FTS. The substring branch runs even when the tsquery is + // empty — that is the dotted/numeric-token case the FTS path misses. + candidates = candidates.where((eb) => { + const ors = [ + eb( + sql`LOWER(f_unaccent(coalesce(pages.title, '')))`, + 'like', + sql`${likePattern} ESCAPE '\\'`, + ), + ]; + if (!searchParams.titleOnly) { + ors.push( + eb( + sql`LOWER(f_unaccent(coalesce(pages.text_content, '')))`, + 'like', + sql`${likePattern} ESCAPE '\\'`, + ), + ); + if (hasTsQuery) { + ors.push( + sql`pages.tsv @@ to_tsquery('english', f_unaccent(${tsQuery}))` as any, + ); + } + } + return eb.or(ors); + }); + + // Pull a generous candidate set (before permission filtering + limit). + // Cap it so a pathological match set cannot blow up memory; 200 >> limit + // (max 50) leaves ample headroom for the post-permission truncation. + // + // #443 cap-ordering fix: the 200-cap MUST be deterministic and relevance- + // biased. Without an ORDER BY, Postgres returns an ARBITRARY 200 rows, so on + // a broad match set (common word / short substring) a strong TITLE_EXACT hit + // could be among the dropped rows while 200 low-tier TEXT hits fill the cap. + // We order by the SAME SQL tier proxies the Node ranker uses — title-exact, + // then title-substring, then fts-rank (nulls last), then earliest text-match + // position — so the cap keeps the strongest candidates. The Node-side final + // tier sort + slice(0, limit) below still runs and stays authoritative; this + // ORDER BY only decides WHICH candidates survive the 200-cap. + // NB: a BARE integer literal in ORDER BY is read by Postgres as an ordinal + // column position (`ORDER BY 0` → "position 0 is not in select list"), so the + // no-tsquery fallback is `0::float`, not `0`. + const ftsRankExpr = hasTsQuery + ? sql`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))` + : sql`0::float`; + const candidatesCapped = candidates + // Raw-SQL ORDER BY expressions: pass the full ` ` as ONE arg + // (the two-arg form treats a raw-SQL second arg as an ORDER BY position). + .orderBy( + sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}) desc`, + ) + .orderBy( + sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\') desc`, + ) + .orderBy(sql`${ftsRankExpr} desc nulls last`) + // Earlier text match first; strpos returns 0 for "no match", which would + // sort BEFORE a real (>=1) position under plain ASC, so push 0 to the end. + .orderBy( + sql`case when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) = 0 then 2147483647 else strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) end asc`, + ); + + let rows: any[] = await candidatesCapped.limit(200).execute(); + + if (rows.length === 0) { + return { items: [] }; + } + + // --- Permissions BEFORE limit. -------------------------------------------- + // Apply the existing page-level post-filter to the MERGED set, then rank and + // only THEN truncate to `limit` — never lose the permission filter. + if (opts.userId) { + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ + pageIds: rows.map((r) => r.id), + userId: opts.userId, + spaceId: searchParams.spaceId, + workspaceId: opts.workspaceId, + }); + const accessibleSet = new Set(accessibleIds); + rows = rows.filter((r) => accessibleSet.has(r.id)); + } + + if (rows.length === 0) { + return { items: [] }; + } + + // --- Tiered ranking + dedup. ---------------------------------------------- + // Rows are already unique by id (single pages scan), so no cross-branch + // dedup is needed here; the tier captures the strongest match reason. + const ranked = rows.map((r) => { + let tier: SearchLookupTier; + let secondary: number; + if (r.titleExact) { + tier = SearchLookupTier.TITLE_EXACT; + secondary = Number(r.ftsRank) || 0; + } else if (r.titleSub) { + tier = SearchLookupTier.TITLE_SUBSTRING; + secondary = Number(r.ftsRank) || 0; + } else { + tier = SearchLookupTier.TEXT; + // Prefer earlier text matches; map position → closeness in (0, 1]. + const pos = Number(r.textMatchPos) || 0; + secondary = + pos > 0 ? 1 / (1 + (pos - 1) / 100) : Number(r.ftsRank) || 0; + } + return { row: r, tier, score: computeLookupScore({ tier, secondary }) }; + }); + + ranked.sort((a, b) => b.score - a.score); + const top = ranked.slice(0, limit); + + // --- Batch ancestor path (ONE recursive CTE, not N+1). -------------------- + const pathById = await this.buildAncestorPaths(top.map((t) => t.row.id)); + + const items: SearchLookupResponseDto[] = top.map((t) => ({ + id: t.row.id, + slugId: t.row.slugId, + title: t.row.title, + parentPageId: t.row.parentPageId ?? null, + path: pathById.get(t.row.id) ?? [], + snippet: (t.row.snippet ?? '') + .replace(/\r\n|\r|\n/g, ' ') + .replace(/\s+/g, ' ') + .trim(), + score: t.score, + })); + + return { items }; + } + + /** + * Batch ancestor-titles helper (#443): ONE recursive CTE seeded with ALL hit + * ids, walking UP parentPageId. Returns a map hitId → ancestor titles ordered + * root → direct parent (the hit's own title is excluded). Root pages map to + * an empty array. Avoids the N+1 of a per-page breadcrumb call. + */ + private async buildAncestorPaths( + hitIds: string[], + ): Promise> { + const result = new Map(); + if (hitIds.length === 0) return result; + + // ancestry(hit_id, page_id, title, parent_page_id, depth): seed one row per + // hit at depth 0 (the hit itself), then walk to parents (increasing depth). + const rows = await this.db + .withRecursive('ancestry', (db) => + db + .selectFrom('pages') + .select([ + 'pages.id as hitId', + 'pages.id as pageId', + 'pages.title as title', + 'pages.parentPageId as parentPageId', + sql`0`.as('depth'), + ]) + .where('pages.id', 'in', hitIds) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .innerJoin('ancestry as a', 'p.id', 'a.parentPageId') + .select([ + 'a.hitId as hitId', + 'p.id as pageId', + 'p.title as title', + 'p.parentPageId as parentPageId', + sql`a.depth + 1`.as('depth'), + ]), + ), + ) + .selectFrom('ancestry') + .select(['hitId', 'title', 'depth']) + // depth 0 is the hit itself — excluded from the path. + .where('depth', '>', 0) + .orderBy('hitId') + // Larger depth = closer to the space root. Ordering DESC gives + // root → parent once collected. + .orderBy('depth', 'desc') + .execute(); + + for (const r of rows as any[]) { + const list = result.get(r.hitId) ?? []; + list.push(r.title); + result.set(r.hitId, list); + } + return result; + } + async searchSuggestions( suggestion: SearchSuggestionDTO, userId: string, diff --git a/apps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts b/apps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts new file mode 100644 index 00000000..4e00debf --- /dev/null +++ b/apps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts @@ -0,0 +1,56 @@ +import { type Kysely, sql } from 'kysely'; + +/** + * #443 — trigram indexes for the opt-in agent-lookup search mode. + * + * The lookup mode adds a substring branch that runs leading-wildcard + * `LOWER(f_unaccent(col)) LIKE '%q%'` predicates on pages.title and + * pages.text_content. A leading wildcard cannot use a b-tree index, so without a + * GIN trigram index each such predicate is a sequential scan. + * + * - TITLE: the title predicate is IDENTICAL to the one added for /search/suggest + * (#348), which already created `idx_pages_title_trgm` on + * `(LOWER(f_unaccent(title))) gin_trgm_ops`. We re-assert it here with + * `IF NOT EXISTS` so a fresh DB that somehow lacks it still gets it, and so + * this migration is self-describing. On an existing DB it is a no-op. + * + * - TEXT_CONTENT: NEW. The substring branch scans text_content when the query + * is not titleOnly. text_content is the large column, so a GIN trigram index + * on it is the meaningful acceleration for the lookup mode. The lookup search + * is ALWAYS space-scoped (spaceId or the user's member spaces), so on small + * instances a per-space sequential scan is tolerable — but the index turns the + * `%q%` text predicate into a Bitmap Index Scan and removes the only + * unbounded-per-space cost of the feature. We add it. The trade-off is disk + + * write amplification on page edits (GIN trigram indexes are larger and slower + * to update than b-trees); on the small instances this fork targets that cost + * is acceptable and the read win on agent lookups is the priority. + * + * DEPLOY-TIME LOCK WARNING: plain (non-CONCURRENT) CREATE INDEX — Kysely runs + * each migration in a transaction, so CONCURRENTLY is impossible. The build takes + * a SHARE lock that BLOCKS writes on `pages` for its duration. The text_content + * GIN build is the slow one and can take minutes on a large tenant. For large + * installations, run this in a maintenance window or build the index out-of-band + * with CREATE INDEX CONCURRENTLY before deploying (then `IF NOT EXISTS` no-ops + * here). Small/typical tenants are unaffected. + */ +export async function up(db: Kysely): Promise { + // Title trigram index — matches the lookup-mode title predicate exactly. + // Already present from #348 on existing DBs; re-asserted for freshness. + await sql` + CREATE INDEX IF NOT EXISTS idx_pages_title_trgm + ON pages USING gin ((LOWER(f_unaccent(title))) gin_trgm_ops) + `.execute(db); + + // text_content trigram index — accelerates the lookup-mode text substring + // predicate. Expression matches the predicate in search.service.ts. + await sql` + CREATE INDEX IF NOT EXISTS idx_pages_text_content_trgm + ON pages USING gin ((LOWER(f_unaccent(text_content))) gin_trgm_ops) + `.execute(db); +} + +export async function down(db: Kysely): Promise { + // Only drop the index this migration introduced. idx_pages_title_trgm is owned + // by the #348 perf-indexes migration, so leave it for that migration's down(). + await sql`DROP INDEX IF EXISTS idx_pages_text_content_trgm`.execute(db); +} diff --git a/apps/server/test/integration/search-lookup.int-spec.ts b/apps/server/test/integration/search-lookup.int-spec.ts new file mode 100644 index 00000000..51daa855 --- /dev/null +++ b/apps/server/test/integration/search-lookup.int-spec.ts @@ -0,0 +1,462 @@ +import { randomUUID } from 'node:crypto'; +import { Kysely } from 'kysely'; +import { SearchService } from 'src/core/search/search.service'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { + getTestDb, + destroyTestDb, + createWorkspace, + createSpace, +} from './db'; + +/** + * #443 — agent-lookup search mode, acceptance on the REAL DB schema. + * + * Exercises SearchService.searchPage(..., { substring: true }) against a + * migrated Postgres: substring matching of technical tokens the FTS tokenizer + * mangles (backup-srv.local, 10.0.12.5, WB-MGE-30D86B, "Теги: Docker"), the + * populated path + snippet, parentPageId subtree scoping, titleOnly, the empty + * result, LIKE-metacharacter escaping (`%`/`_` must NOT match everything), the + * permission post-filter applied BEFORE the limit, and the web-UI path staying + * on the legacy FTS shape when `substring` is absent. + * + * The tsv column is populated by the pages_tsvector_trigger on insert, so the + * FTS branch is exercised too. + */ +describe('SearchService agent-lookup mode [integration]', () => { + let db: Kysely; + let service: SearchService; + let workspaceId: string; + let spaceId: string; + + // Direct page insert (the shared createPage seeder omits text_content / + // parent_page_id, both of which this mode depends on). Returns the id. + async function insertPage(args: { + title: string; + textContent?: string; + parentPageId?: string | null; + spaceId?: string; + }): Promise { + const id = randomUUID(); + await db + .insertInto('pages') + .values({ + id, + slugId: `slug-${id.slice(0, 12)}`, + title: args.title, + textContent: args.textContent ?? null, + parentPageId: args.parentPageId ?? null, + spaceId: args.spaceId ?? spaceId, + workspaceId, + }) + .execute(); + return id; + } + + // Build a SearchService wired to the real DB + a real PageRepo (only its + // recursive-descendants method is used by this mode, and it needs only `db`), + // with lightweight stubs for the space-membership and permission repos so a + // test can drive scope + the permission post-filter explicitly. + function buildService(opts?: { + userSpaceIds?: string[]; + // ids to KEEP after the permission post-filter; undefined = keep all. + accessibleIds?: string[]; + }): SearchService { + const pageRepo = new PageRepo(db as any, null as any, null as any); + const spaceMemberRepo = { + getUserSpaceIds: async () => opts?.userSpaceIds ?? [spaceId], + }; + const pagePermissionRepo = { + filterAccessiblePageIds: async ({ pageIds }: { pageIds: string[] }) => + opts?.accessibleIds + ? pageIds.filter((id) => opts.accessibleIds!.includes(id)) + : pageIds, + }; + return new SearchService( + db as any, + pageRepo as any, + {} as any, // shareRepo — unused by the lookup path + spaceMemberRepo as any, + pagePermissionRepo as any, + ); + } + + beforeAll(async () => { + db = getTestDb(); + workspaceId = (await createWorkspace(db)).id; + spaceId = (await createSpace(db, workspaceId)).id; + service = buildService(); + }); + + afterAll(async () => { + await destroyTestDb(); + }); + + it('finds `backup-srv.local` by the fragment `srv.local`', async () => { + const pageId = await insertPage({ + title: 'backup-srv.local', + textContent: 'A backup server node.', + }); + + const { items } = (await service.searchPage( + { query: 'srv.local', spaceId, substring: true } as any, + { workspaceId }, + )) as any; + + expect(items.map((i: any) => i.id)).toContain(pageId); + const hit = items.find((i: any) => i.id === pageId); + expect(hit.title).toBe('backup-srv.local'); + // slugId must never be part of the server response shape. + expect('slugId' in hit).toBe(true); // server carries it; MCP strips it + }); + + it('finds a page whose TEXT contains `10.0.12.5` by the fragment `10.0.12` (empty-tsquery case)', async () => { + const pageId = await insertPage({ + title: 'Server inventory', + textContent: 'The backup box lives at IP: 10.0.12.5. Debian 12, backups.', + }); + + const { items } = (await service.searchPage( + { query: '10.0.12', spaceId, substring: true } as any, + { workspaceId }, + )) as any; + + const hit = items.find((i: any) => i.id === pageId); + expect(hit).toBeDefined(); + // The windowed snippet must include the matched text. + expect(hit.snippet).toContain('10.0.12.5'); + }); + + it('finds `WB-MGE-30D86B` (alphanumeric token with dashes) by title', async () => { + const pageId = await insertPage({ + title: 'WB-MGE-30D86B', + textContent: 'Device page.', + }); + + const { items } = (await service.searchPage( + { query: 'WB-MGE-30D86B', spaceId, substring: true } as any, + { workspaceId }, + )) as any; + + const hit = items.find((i: any) => i.id === pageId); + expect(hit).toBeDefined(); + // Exact title match → top tier (TITLE_EXACT=3) → score in [0.75, 1]. + expect(hit.score).toBeGreaterThanOrEqual(0.75); + // And it is the top-ranked hit of its own result set. + expect(items[0].id).toBe(pageId); + }); + + it('finds every page whose text literally contains `Теги: Docker`', async () => { + const a = await insertPage({ + title: 'Container host A', + textContent: 'Some notes.\nТеги: Docker, compose\nmore.', + }); + const b = await insertPage({ + title: 'Container host B', + textContent: 'Prelude.\nТеги: Docker\nepilogue.', + }); + const noise = await insertPage({ + title: 'Unrelated', + textContent: 'Теги: Kubernetes', + }); + + const { items } = (await service.searchPage( + { query: 'Теги: Docker', spaceId, substring: true, limit: 50 } as any, + { workspaceId }, + )) as any; + + const ids = items.map((i: any) => i.id); + expect(ids).toContain(a); + expect(ids).toContain(b); + expect(ids).not.toContain(noise); + }); + + it('populates a non-empty `path` for a nested hit and `[]` for a root hit', async () => { + const root = await insertPage({ title: 'Infrastructure' }); + const mid = await insertPage({ title: 'Datacenter A', parentPageId: root }); + const leaf = await insertPage({ + title: 'unique-nested-host', + parentPageId: mid, + }); + + const { items } = (await service.searchPage( + { query: 'unique-nested-host', spaceId, substring: true } as any, + { workspaceId }, + )) as any; + + const hit = items.find((i: any) => i.id === leaf); + expect(hit.path).toEqual(['Infrastructure', 'Datacenter A']); + + const rootHits = (await service.searchPage( + { query: 'Infrastructure', spaceId, substring: true } as any, + { workspaceId }, + )) as any; + const rootHit = rootHits.items.find((i: any) => i.id === root); + expect(rootHit.path).toEqual([]); + }); + + it('scopes to a subtree with parentPageId (cutting off sibling branches)', async () => { + const branchA = await insertPage({ title: 'BranchA-root' }); + const inA = await insertPage({ + title: 'scoped-target-xyz', + parentPageId: branchA, + }); + const branchB = await insertPage({ title: 'BranchB-root' }); + const inB = await insertPage({ + title: 'scoped-target-xyz', + parentPageId: branchB, + }); + + const { items } = (await service.searchPage( + { + query: 'scoped-target-xyz', + spaceId, + substring: true, + parentPageId: branchA, + } as any, + { workspaceId }, + )) as any; + + const ids = items.map((i: any) => i.id); + expect(ids).toContain(inA); + expect(ids).not.toContain(inB); + }); + + it('includes the parent page itself in the parentPageId subtree', async () => { + const parent = await insertPage({ title: 'self-included-parent' }); + await insertPage({ title: 'child-of-self', parentPageId: parent }); + + const { items } = (await service.searchPage( + { + query: 'self-included-parent', + spaceId, + substring: true, + parentPageId: parent, + } as any, + { workspaceId }, + )) as any; + + expect(items.map((i: any) => i.id)).toContain(parent); + }); + + it('titleOnly does NOT match on text_content', async () => { + const pageId = await insertPage({ + title: 'Plain title', + textContent: 'body mentions the-secret-token here', + }); + + const withText = (await service.searchPage( + { query: 'the-secret-token', spaceId, substring: true } as any, + { workspaceId }, + )) as any; + expect(withText.items.map((i: any) => i.id)).toContain(pageId); + + const titleOnly = (await service.searchPage( + { + query: 'the-secret-token', + spaceId, + substring: true, + titleOnly: true, + } as any, + { workspaceId }, + )) as any; + expect(titleOnly.items.map((i: any) => i.id)).not.toContain(pageId); + }); + + // #443 Fix #1 regression: f_unaccent is NOT length-preserving, so an + // expanding char (ß→ss, …→...) BEFORE the match shifted the strpos position + // relative to the ORIGINAL text and the snippet slice ran past end → empty. + // The position and the slice now share the LOWER(f_unaccent(...)) space, so + // the window is aligned and always contains the matched (unaccented) token. + it('returns a populated snippet when an unaccent-EXPANDING char precedes the match', async () => { + // 300 × `ß` (each f_unaccent-expands to `ss`) before the needle. Under the + // old code strpos returned a position ~593 in the expanded space but the + // slice ran over the ORIGINAL (~360 char) text → empty snippet, match lost. + const prefix = 'ß'.repeat(300); + const pageId = await insertPage({ + title: 'Expanding-unaccent page', + textContent: `${prefix} needle-token-xyz trailing.`, + }); + + const { items } = (await service.searchPage( + { query: 'needle-token-xyz', spaceId, substring: true } as any, + { workspaceId }, + )) as any; + + const hit = items.find((i: any) => i.id === pageId); + expect(hit).toBeDefined(); + // Snippet must be non-empty AND contain the matched token (unaccented form). + expect(hit.snippet.length).toBeGreaterThan(0); + expect(hit.snippet).toContain('needle-token-xyz'); + }); + + // #443 Fix #2 regression: >200 matching pages for a broad substring, with + // exactly ONE exact-title hit. Without an ORDER BY on the 200-cap the exact + // hit could be among the arbitrarily-dropped rows; the ORDER BY keeps the + // strongest candidates so it must survive the cap and rank at the top. + it('keeps an exact-title hit through the 200-cap on a >200-row match set', async () => { + const isoSpace = (await createSpace(db, workspaceId)).id; + const svc = buildService({ userSpaceIds: [isoSpace] }); + + // 250 low-tier TEXT hits: the shared substring `capword` appears only in the + // body, never the title, so each is a TEXT-tier match (weakest tier). + for (let i = 0; i < 250; i++) { + await insertPage({ + title: `filler-page-${i}`, + textContent: `body contains capword here #${i}`, + spaceId: isoSpace, + }); + } + // Exactly one EXACT-title hit for the same query token. + const exact = await insertPage({ + title: 'capword', + textContent: 'unrelated body text', + spaceId: isoSpace, + }); + + const { items } = (await svc.searchPage( + { query: 'capword', spaceId: isoSpace, substring: true, limit: 10 } as any, + { workspaceId }, + )) as any; + + const ids = items.map((i: any) => i.id); + // The exact-title hit must survive the 200-cap and appear in the top `limit`. + expect(ids).toContain(exact); + // And, being TITLE_EXACT, it must be the single strongest hit. + expect(items[0].id).toBe(exact); + }); + + // #443 Fix #3: titleOnly matches only the title, so it must not leak the page + // body as the snippet (the old "first 300 chars of text_content" fallback). + it('titleOnly does NOT return a text-body snippet', async () => { + const pageId = await insertPage({ + title: 'titleonly-snippet-page', + textContent: 'SECRET-BODY-CONTENT-NOT-IN-TITLE that must not leak.', + }); + + const { items } = (await service.searchPage( + { + query: 'titleonly-snippet-page', + spaceId, + substring: true, + titleOnly: true, + } as any, + { workspaceId }, + )) as any; + + const hit = items.find((i: any) => i.id === pageId); + expect(hit).toBeDefined(); + // The body text must not appear in the snippet; titleOnly → empty snippet. + expect(hit.snippet).not.toContain('SECRET-BODY-CONTENT-NOT-IN-TITLE'); + expect(hit.snippet).toBe(''); + }); + + it('returns [] (not an error) for a query that matches nothing', async () => { + const { items } = (await service.searchPage( + { + query: 'zzz-no-such-string-anywhere-42', + spaceId, + substring: true, + } as any, + { workspaceId }, + )) as any; + expect(items).toEqual([]); + }); + + it('a `%` query does NOT match everything (LIKE metacharacter escaped)', async () => { + // Fresh space so we can assert on total counts without cross-test noise. + const isoSpace = (await createSpace(db, workspaceId)).id; + const svc = buildService({ userSpaceIds: [isoSpace] }); + await insertPage({ title: 'alpha', spaceId: isoSpace }); + await insertPage({ title: 'beta', spaceId: isoSpace }); + const literal = await insertPage({ + title: '100%-coverage', + spaceId: isoSpace, + }); + + const { items } = (await svc.searchPage( + { query: '%', spaceId: isoSpace, substring: true, limit: 50 } as any, + { workspaceId }, + )) as any; + + const ids = items.map((i: any) => i.id); + // `%` is a literal → matches only the page that actually contains '%'. + expect(ids).toContain(literal); + expect(ids).not.toContain( + items.find((i: any) => i.title === 'alpha')?.id, + ); + expect(items.length).toBe(1); + }); + + it('an `_` query does NOT match everything (LIKE metacharacter escaped)', async () => { + const isoSpace = (await createSpace(db, workspaceId)).id; + const svc = buildService({ userSpaceIds: [isoSpace] }); + await insertPage({ title: 'gamma', spaceId: isoSpace }); + const literal = await insertPage({ + title: 'snake_case_name', + spaceId: isoSpace, + }); + + const { items } = (await svc.searchPage( + { query: '_', spaceId: isoSpace, substring: true, limit: 50 } as any, + { workspaceId }, + )) as any; + + const ids = items.map((i: any) => i.id); + expect(ids).toContain(literal); + expect(items.length).toBe(1); + }); + + it('applies the permission post-filter to the MERGED set BEFORE the limit', async () => { + const isoSpace = (await createSpace(db, workspaceId)).id; + const keep = await insertPage({ + title: 'perm-visible-target', + spaceId: isoSpace, + }); + const hidden = await insertPage({ + title: 'perm-hidden-target', + spaceId: isoSpace, + }); + + // Authenticated (userId set) so the permission filter runs; only `keep` is + // accessible. limit 1 must NOT be able to select `hidden`. + const svc = buildService({ + userSpaceIds: [isoSpace], + accessibleIds: [keep], + }); + const { items } = (await svc.searchPage( + { + query: 'perm-', + spaceId: isoSpace, + substring: true, + limit: 1, + } as any, + { userId: 'user-1', workspaceId }, + )) as any; + + const ids = items.map((i: any) => i.id); + expect(ids).toContain(keep); + expect(ids).not.toContain(hidden); + }); + + it('web-UI path (no `substring` flag) keeps the legacy FTS response shape', async () => { + await insertPage({ + title: 'legacy shape page', + textContent: 'searchable legacyword content', + }); + + const { items } = (await service.searchPage( + { query: 'legacyword', spaceId } as any, + { userId: 'user-1', workspaceId }, + )) as any; + + // Legacy hits carry rank + highlight + space, and NO path/snippet/score. + const hit = items[0]; + expect(hit).toBeDefined(); + expect('rank' in hit).toBe(true); + expect('highlight' in hit).toBe(true); + expect('path' in hit).toBe(false); + expect('snippet' in hit).toBe(false); + expect('score' in hit).toBe(false); + }); +}); diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index e1ac7fa2..1ebb725c 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -2830,14 +2830,28 @@ export class DocmostClient { return { success: true, removedShareId: share.shareId, pageId }; } - async search(query: string, spaceId?: string, limit?: number) { + async search( + query: string, + spaceId?: string, + limit?: number, + opts: { parentPageId?: string; titleOnly?: boolean } = {}, + ) { await this.ensureAuthenticated(); - const payload: Record = { 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. + // Opt into the #443 agent-lookup mode: `substring: true` turns on the hybrid + // substring + FTS branch that returns path + snippet + score. A stock + // upstream server strips these unknown DTO fields (whitelist:true) and + // silently degrades to plain FTS — see the tool-registration comment. + const payload: Record = { + query, + spaceId, + substring: true, + }; + if (opts.parentPageId) payload.parentPageId = opts.parentPageId; + if (opts.titleOnly) payload.titleOnly = true; + // Clamp an optional caller-supplied limit into the lookup range (1..50) + // before forwarding; omit it when not provided so the server default applies. if (limit !== undefined) { - payload.limit = Math.max(1, Math.min(100, limit)); + payload.limit = Math.max(1, Math.min(50, limit)); } const response = await this.client.post("/search", payload); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index c2a69446..88c5b305 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -435,30 +435,63 @@ server.registerTool( // Tool: search // INTENTIONAL per-transport divergence (not shared): the in-app `searchPages` // runs a semantic + keyword hybrid (RRF) with in-process access control and a -// different schema (limit 1-20); this transport is a plain REST full-text search -// (limit up to 100). Different behaviour AND schema, so kept per-layer. +// different schema; this transport is the #443 agent-lookup search — a hybrid +// substring + full-text search that also returns each hit's location (`path`) +// and a windowed `snippet`, so one call answers "where is it and what's in it". +// The in-app hybrid-RRF search is deliberately NOT touched. Different behaviour +// AND schema, so kept per-layer. +// +// STANDALONE-vs-STOCK-UPSTREAM: the client sends the opt-in `substring`/ +// `parentPageId`/`titleOnly` DTO fields. A stock upstream server validates the +// DTO with `whitelist: true` and silently strips these unknown fields, so the +// request degrades gracefully to plain FTS (no path/snippet, current shape). +// +// EE/TYPESENSE DEGRADATION (#443): on an instance whose SEARCH_DRIVER is +// `typesense`, the server routes this request to the Typesense backend, which +// does NOT implement agent-lookup — the substring/path/snippet/tiering is +// ignored and the response degrades to plain Typesense FTS. The rich lookup +// shape is only produced by the native Postgres search driver. server.registerTool( "search", { description: - "Full-text search for pages and content across the whole workspace. " + - "Results are bounded by `limit` (1-100; when omitted the server applies " + - "its own default).", + "Find pages by a fragment of a technical string (hostnames, IPs, IDs " + + "like `srv.local`, `10.0.12`, `WB-MGE-30D86B`) — one call returns each " + + "hit's location (`path`: ancestor titles root→parent) and a `snippet` " + + "around the first match, so you rarely need a follow-up get_page. " + + "Matches substrings literally (dots/dashes/digits are not tokenized) as " + + "well as full-text. Returns `{ pageId, title, path, snippet, score }` " + + "sorted by `score` (a per-response relevance float).", inputSchema: { query: z.string().min(1).describe("Search query"), + spaceId: z + .string() + .optional() + .describe("Restrict the search to a single space"), + parentPageId: z + .string() + .optional() + .describe( + "Restrict to a page and all its descendants (the page itself included)", + ), + titleOnly: z + .boolean() + .optional() + .describe("Match page titles only; skip page text"), limit: z .number() .int() .min(1) - .max(100) + .max(50) .optional() - .describe("Max results to return (max 100)"), + .describe("Max results to return (1-50, default 10)"), }, }, - 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); + async ({ query, spaceId, parentPageId, titleOnly, limit }) => { + const result = await docmostClient.search(query, spaceId, limit, { + parentPageId, + titleOnly, + }); return jsonContent(result); }, ); diff --git a/packages/mcp/src/lib/filters.ts b/packages/mcp/src/lib/filters.ts index c789ec3a..1f106387 100644 --- a/packages/mcp/src/lib/filters.ts +++ b/packages/mcp/src/lib/filters.ts @@ -83,16 +83,32 @@ export function filterComment(comment: any, markdownContent?: string) { }; } +// Map one server search hit to the MCP output contract (#443): +// { pageId, title, path, snippet, score } +// +// INVARIANT: the only page identifier exposed is `pageId` (the server `id` +// UUID). The server also carries `slugId` — it is NEVER surfaced. +// +// GRACEFUL DEGRADATION: against a stock upstream server the opt-in lookup DTO +// fields are stripped, so the response is the legacy FTS shape (no path/snippet/ +// score, a `highlight` + `rank` instead). We synthesize the contract from +// whatever is present: `snippet` falls back to the FTS `highlight`, `score` to +// the FTS `rank`, and `path` to [] (upstream has no path). This keeps the tool +// usable even when the server has not been upgraded. export function filterSearchResult(result: any) { return { - id: result.id, + pageId: result.id, title: result.title, - parentPageId: result.parentPageId, - createdAt: result.createdAt, - updatedAt: result.updatedAt, - rank: result.rank, - highlight: result.highlight, - spaceId: result.space?.id, - spaceName: result.space?.name, + path: Array.isArray(result.path) ? result.path : [], + snippet: + typeof result.snippet === "string" + ? result.snippet + : (result.highlight ?? ""), + score: + typeof result.score === "number" + ? result.score + : typeof result.rank === "number" + ? result.rank + : 0, }; } diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index 95e3f570..f79a330b 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -40,7 +40,7 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; */ export const ROUTING_PROSE = "Docmost editing guide — choose the tool by intent. The at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" + - "READ: find a page -> search (workspace-wide full-text); list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + + "READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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.\n" + "PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" + @@ -152,7 +152,7 @@ export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [ { name: "search", purpose: - "full-text search for pages and content across the whole workspace.", + "find pages by a fragment of a technical string (hybrid substring + full-text); returns each hit's path and a snippet.", }, { name: "docmostTransform", diff --git a/packages/mcp/test/unit/filters.test.mjs b/packages/mcp/test/unit/filters.test.mjs index 84d56812..100f8ee1 100644 --- a/packages/mcp/test/unit/filters.test.mjs +++ b/packages/mcp/test/unit/filters.test.mjs @@ -1,7 +1,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { filterComment, filterPage } from "../../build/lib/filters.js"; +import { + filterComment, + filterPage, + filterSearchResult, +} from "../../build/lib/filters.js"; test("filterComment includes resolvedAt/resolvedById as null when absent", () => { const result = filterComment({ @@ -171,3 +175,91 @@ test("filterPage includes both content and subpages together", () => { assert.equal(result.content, "body"); assert.deepEqual(result.subpages, [{ id: "s1", title: "Sub" }]); }); + +// --- filterSearchResult (#443 agent-lookup contract) ------------------------- + +test("filterSearchResult maps the lookup shape to {pageId,title,path,snippet,score}", () => { + const result = filterSearchResult({ + id: "0199aa-uuid", + slugId: "slug-secret", + title: "backup-srv.local", + parentPageId: "0199pp", + path: ["Infrastructure", "Datacenter A", "Servers"], + snippet: "…IP: 10.0.12.5. Debian 12…", + score: 0.92, + }); + + assert.deepEqual(result, { + pageId: "0199aa-uuid", + title: "backup-srv.local", + path: ["Infrastructure", "Datacenter A", "Servers"], + snippet: "…IP: 10.0.12.5. Debian 12…", + score: 0.92, + }); +}); + +test("filterSearchResult NEVER exposes slugId (pageId is the only identifier)", () => { + const result = filterSearchResult({ + id: "uuid-1", + slugId: "slug-1", + title: "t", + path: [], + snippet: "s", + score: 0.1, + }); + assert.equal("slugId" in result, false); + assert.equal("id" in result, false); + assert.equal(result.pageId, "uuid-1"); +}); + +test("filterSearchResult root page yields path: []", () => { + const result = filterSearchResult({ + id: "uuid-root", + title: "Root", + path: [], + snippet: "s", + score: 0.5, + }); + assert.deepEqual(result.path, []); +}); + +test("filterSearchResult degrades a legacy FTS hit (no lookup fields)", () => { + // Stock upstream stripped the opt-in DTO fields → legacy shape with + // highlight + rank and no path/snippet/score. + const result = filterSearchResult({ + id: "uuid-legacy", + slugId: "slug-legacy", + title: "Legacy", + parentPageId: null, + rank: 0.37, + highlight: "…matched text…", + space: { id: "sp1", name: "Space" }, + }); + + assert.equal(result.pageId, "uuid-legacy"); + assert.equal(result.title, "Legacy"); + // snippet falls back to highlight, score to rank, path to []. + assert.equal(result.snippet, "…matched text…"); + assert.equal(result.score, 0.37); + assert.deepEqual(result.path, []); + assert.equal("slugId" in result, false); +}); + +test("filterSearchResult is null-safe on missing snippet/score/path", () => { + const result = filterSearchResult({ id: "u", title: "t" }); + assert.equal(result.pageId, "u"); + assert.equal(result.snippet, ""); + assert.equal(result.score, 0); + assert.deepEqual(result.path, []); +}); + +test("filterSearchResult ignores a non-array path", () => { + const result = filterSearchResult({ + id: "u", + title: "t", + path: "not-an-array", + snippet: "s", + score: 1, + }); + assert.deepEqual(result.path, []); +}); -- 2.52.0 From f794ac6d6ca16d22288a5ed11ddf763abe1b986a Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 21:57:16 +0300 Subject: [PATCH 5/8] =?UTF-8?q?fix(search):=20=D0=BE=D0=B6=D0=B8=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20trgm-=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=20=E2=80=94=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20coalesce?= =?UTF-8?q?=20=D0=B8=D0=B7=20LIKE-=D0=BF=D1=80=D0=B5=D0=B4=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=B2=20(#443,=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревьюер поймал EXPLAIN'ом: lookup-query фильтровал по LOWER(f_unaccent(coalesce(col,''))), а GIN-trgm-индексы — на coalesce-free LOWER(f_unaccent(col)). Postgres берёт функциональный индекс только при точном совпадении выражения, так что обёртка coalesce отключала индекс → Seq Scan по pages на КАЖДЫЙ lookup (MCP-клиент всегда шлёт substring:true). Зелёный int-гейт это не ловил (проверял корректность на крошечном датасете, не использование индекса). - search.service.ts: убрал coalesce из двух index-driving LIKE-предикатов (title + text_content). Семантически эквивалентно: NULL LIKE '%q%' = NULL (falsy), NULL-страница просто не матчится — как пустая строка не матчит %q%. SELECT/ORDER BY оставлены с coalesce (индекс не выбирают, Node гардит NULL). - Миграция: убран избыточный ре-ассерт idx_pages_title_trgm — его создаёт #348 на том же coalesce-free выражении, теперь title-предикат его использует. idx_pages_text_content_trgm без изменений (уже coalesce-free), поправлены вводящие в заблуждение комментарии. down() дропает только text-индекс. - Новый тест search-lookup-explain.int-spec.ts (3): title→idx_pages_title_trgm, text→idx_pages_text_content_trgm (оба ассертят отсутствие Seq Scan on pages), + негативный контроль (coalesce-обёртка не может использовать индекс) против тихой ре-регрессии. Дискриминатор enable_seqscan=off. - CHANGELOG: две записи (opt-in substring lookup mode + смена shape MCP search). EXPLAIN на реальном PG: обе fixed-ветки → Bitmap Index Scan on trgm; buggy coalesce → Seq Scan (Disabled:true). Гейт: server jest src/core/search 27/27 (16 исходных int без изменений + 3 EXPLAIN + 8 unit), mcp node --test 708/708, tsc чисто. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 +++ apps/server/src/core/search/search.service.ts | 14 +- .../20260706T120000-search-lookup-trgm.ts | 25 ++-- .../search-lookup-explain.int-spec.ts | 123 ++++++++++++++++++ 4 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 apps/server/test/integration/search-lookup-explain.int-spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ad3d09..7b44878a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,6 +251,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 by physical key position and matched against the commands; genuine Cyrillic search terms keep priority over remapped candidates, and short wrong-layout prefixes match by command title. (#283, #285, #287) +- **Opt-in substring "lookup" search mode for agents.** `/api/search` gains an + additive, opt-in mode (guarded by a new `substring` flag) that matches literal + substrings of page titles and body text — so technical tokens the full-text + tokenizer mangles (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are found + even when the FTS query is empty. It returns a location `path`, a windowed + `snippet` and a per-response relevance `score`, supports `titleOnly` and a + `parentPageId` subtree scope, and applies the page-level permission filter + before the limit. The web UI never sets `substring`, so its full-text search + behaviour is byte-for-byte unchanged. The leading-wildcard `LIKE` predicates + are backed by GIN trigram indexes on `LOWER(f_unaccent(title))` and + `LOWER(f_unaccent(text_content))` so lookups use a bitmap index scan instead of + a sequential scan. (#443) +- **MCP `search` tool returns richer, agent-oriented results.** The external MCP + `search` response shape changes for the agent surface: each hit now carries + `pageId` (renamed from `id`), plus `path`, `snippet` and `score`; the + UI-oriented `spaceId`, `rank` and `highlight` fields are dropped. (#443) ### Changed diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 111c5938..0d125d58 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -378,10 +378,20 @@ export class SearchService { // Match predicate: title substring OR (unless titleOnly) text substring OR // (unless titleOnly) FTS. The substring branch runs even when the tsquery is // empty — that is the dotted/numeric-token case the FTS path misses. + // + // #443 dead-index fix: these two LIKE predicates MUST match the GIN trgm + // index expressions EXACTLY for Postgres to use them. The indexes are on the + // coalesce-FREE expressions `LOWER(f_unaccent(title))` (#348's + // idx_pages_title_trgm) and `LOWER(f_unaccent(text_content))` (this PR's + // idx_pages_text_content_trgm). A `coalesce(col,'')` wrapper here would make + // the query expression differ from the index expression and force a Seq Scan + // on pages for every lookup. Dropping coalesce is SEMANTICALLY EQUIVALENT: + // `NULL LIKE '%q%'` is NULL (falsy), so a NULL title/text simply doesn't + // match — exactly as an empty string wouldn't match `%q%`. candidates = candidates.where((eb) => { const ors = [ eb( - sql`LOWER(f_unaccent(coalesce(pages.title, '')))`, + sql`LOWER(f_unaccent(pages.title))`, 'like', sql`${likePattern} ESCAPE '\\'`, ), @@ -389,7 +399,7 @@ export class SearchService { if (!searchParams.titleOnly) { ors.push( eb( - sql`LOWER(f_unaccent(coalesce(pages.text_content, '')))`, + sql`LOWER(f_unaccent(pages.text_content))`, 'like', sql`${likePattern} ESCAPE '\\'`, ), diff --git a/apps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts b/apps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts index 4e00debf..c28f4cf0 100644 --- a/apps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts +++ b/apps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts @@ -8,11 +8,12 @@ import { type Kysely, sql } from 'kysely'; * pages.text_content. A leading wildcard cannot use a b-tree index, so without a * GIN trigram index each such predicate is a sequential scan. * - * - TITLE: the title predicate is IDENTICAL to the one added for /search/suggest - * (#348), which already created `idx_pages_title_trgm` on - * `(LOWER(f_unaccent(title))) gin_trgm_ops`. We re-assert it here with - * `IF NOT EXISTS` so a fresh DB that somehow lacks it still gets it, and so - * this migration is self-describing. On an existing DB it is a no-op. + * - TITLE: the lookup-mode title predicate is `LOWER(f_unaccent(title)) LIKE + * '%q%'` (coalesce-free, so it can use a functional index), which is IDENTICAL + * to the one added for /search/suggest (#348). #348's perf-indexes migration + * already created `idx_pages_title_trgm` on `(LOWER(f_unaccent(title))) + * gin_trgm_ops`, so the title predicate is already covered — we do NOT + * re-create that index here (it would be redundant). * * - TEXT_CONTENT: NEW. The substring branch scans text_content when the query * is not titleOnly. text_content is the large column, so a GIN trigram index @@ -34,15 +35,13 @@ import { type Kysely, sql } from 'kysely'; * here). Small/typical tenants are unaffected. */ export async function up(db: Kysely): Promise { - // Title trigram index — matches the lookup-mode title predicate exactly. - // Already present from #348 on existing DBs; re-asserted for freshness. - await sql` - CREATE INDEX IF NOT EXISTS idx_pages_title_trgm - ON pages USING gin ((LOWER(f_unaccent(title))) gin_trgm_ops) - `.execute(db); + // The title predicate is served by #348's idx_pages_title_trgm — see header. + // Only the text_content index is introduced here. - // text_content trigram index — accelerates the lookup-mode text substring - // predicate. Expression matches the predicate in search.service.ts. + // text_content trigram index. Its expression is coalesce-free — + // `LOWER(f_unaccent(text_content))` — to EXACTLY match the coalesce-free + // lookup-mode text substring predicate in search.service.ts, so Postgres can + // use it (a `coalesce(...)` mismatch would silently fall back to a Seq Scan). await sql` CREATE INDEX IF NOT EXISTS idx_pages_text_content_trgm ON pages USING gin ((LOWER(f_unaccent(text_content))) gin_trgm_ops) diff --git a/apps/server/test/integration/search-lookup-explain.int-spec.ts b/apps/server/test/integration/search-lookup-explain.int-spec.ts new file mode 100644 index 00000000..4a50e553 --- /dev/null +++ b/apps/server/test/integration/search-lookup-explain.int-spec.ts @@ -0,0 +1,123 @@ +import { randomUUID } from 'node:crypto'; +import { Kysely, sql } from 'kysely'; +import { + getTestDb, + destroyTestDb, + createWorkspace, + createSpace, +} from './db'; + +/** + * #443 dead-index guard — EXPLAIN on the REAL DB. + * + * The lookup mode's substring predicates run a leading-wildcard + * `LOWER(f_unaccent(col)) LIKE '%q%'`. Those are only fast when Postgres uses + * the GIN trigram indexes: + * - idx_pages_title_trgm on (LOWER(f_unaccent(title))) [#348] + * - idx_pages_text_content_trgm on (LOWER(f_unaccent(text_content))) [#443] + * + * Postgres uses a functional index ONLY when the query expression matches the + * index expression EXACTLY. The original lookup query wrapped the columns in + * `coalesce(col,'')`, which differs from the coalesce-FREE index expression and + * silently forced a Seq Scan on pages for EVERY lookup (the MCP client always + * sends substring:true). This test locks that in. + * + * Discriminator: `SET enable_seqscan = off` asks the planner "CAN this predicate + * use the index at all?" — which is exactly what the coalesce bug breaks. With + * seqscan disabled: + * - the coalesce-FREE (fixed) predicate plans a Bitmap Index Scan on the trgm + * index (no Seq Scan on pages); + * - the coalesce-WRAPPED (buggy) predicate cannot use the index and falls back + * to a Seq Scan on pages even though seqscan is disabled. + * We assert both to prove the fix and to keep the regression from silently + * returning. + */ +describe('SearchService agent-lookup EXPLAIN — trgm index is live [integration]', () => { + let db: Kysely; + let workspaceId: string; + let spaceId: string; + + async function insertPage(title: string, textContent: string): Promise { + const id = randomUUID(); + await db + .insertInto('pages') + .values({ + id, + slugId: `slug-${id.slice(0, 12)}`, + title, + textContent, + spaceId, + workspaceId, + }) + .execute(); + } + + // Run EXPLAIN (no ANALYZE — we only inspect the chosen plan) and return the + // concatenated plan text. + async function explain(query: string): Promise { + const rows = await sql<{ 'QUERY PLAN': string }>`EXPLAIN ${sql.raw(query)}`.execute( + db, + ); + return (rows.rows as any[]).map((r) => r['QUERY PLAN']).join('\n'); + } + + beforeAll(async () => { + db = getTestDb(); + workspaceId = (await createWorkspace(db)).id; + spaceId = (await createSpace(db, workspaceId)).id; + + // Seed enough rows that a trigram index is a plausible plan. The content is + // varied so the '%needle%' pattern is selective. + for (let i = 0; i < 200; i++) { + await insertPage( + `seed-title-${i}`, + `seed body content number ${i} lorem ipsum dolor sit amet ${i}`, + ); + } + await insertPage('backup-srv.local', 'the needle-token-xyz lives here'); + + // Keep the trgm indexes' stats fresh so the planner costs them correctly. + await sql`ANALYZE pages`.execute(db); + }); + + afterAll(async () => { + await destroyTestDb(); + }); + + // Force the planner to answer "can the index be used?" rather than "is it + // cheaper than a seq scan on this size?". Restored after each test. + beforeEach(async () => { + await sql`SET enable_seqscan = off`.execute(db); + }); + afterEach(async () => { + await sql`RESET enable_seqscan`.execute(db); + }); + + it('title predicate (coalesce-FREE, as fixed) uses idx_pages_title_trgm, not a Seq Scan', async () => { + const plan = await explain( + `SELECT id FROM pages WHERE LOWER(f_unaccent(title)) LIKE '%srv.local%'`, + ); + expect(plan).toContain('idx_pages_title_trgm'); + expect(plan).not.toMatch(/Seq Scan on pages/i); + }); + + it('text_content predicate (coalesce-FREE, as fixed) uses idx_pages_text_content_trgm, not a Seq Scan', async () => { + const plan = await explain( + `SELECT id FROM pages WHERE LOWER(f_unaccent(text_content)) LIKE '%needle-token%'`, + ); + expect(plan).toContain('idx_pages_text_content_trgm'); + expect(plan).not.toMatch(/Seq Scan on pages/i); + }); + + // Negative control: the OLD coalesce-wrapped predicate must NOT be able to use + // the index — even with seqscan disabled it can only Seq Scan pages. If this + // ever stops seq-scanning, the coalesce/index expressions have re-aligned and + // the guard above is no longer meaningful. + it('coalesce-WRAPPED text predicate (the bug) cannot use the index — falls to Seq Scan', async () => { + const plan = await explain( + `SELECT id FROM pages WHERE LOWER(f_unaccent(coalesce(text_content,''))) LIKE '%needle-token%'`, + ); + expect(plan).not.toContain('idx_pages_text_content_trgm'); + expect(plan).toMatch(/Seq Scan on pages/i); + }); +}); -- 2.52.0 From bfb4c8d8d08c9e2f7efb25a403085423178c60ce Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 21:58:32 +0300 Subject: [PATCH 6/8] =?UTF-8?q?feat(mcp):=20getTree=20=E2=80=94=20=D0=B8?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D1=80=D1=85=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D1=81=D1=82=D0=B2=D0=B0/=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=B0=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D0=BE=D0=BC?= =?UTF-8?q?=20(#443,=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=202/3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше единственный способ получить дерево — listPages tree:true: BFS по sidebar-эндпоинту, десятки-сотни HTTP-вызовов и молчаливая потеря страниц (#442). Бэкенд форка уже отдаёт всё одним POST /pages/tree (getSidebarPagesTree, merged #442/#451), так что это MCP-сторона + общий реестр. Только чтение. - lib/tree.ts: buildPageTree(nodes) аддитивно расширен до buildPageTree(nodes, options?) с {shape?: "lean"|"getTree"; maxDepth?}. Дефолт/{} — байт-идентичный lean {id,slugId,title,children?} (существующие вызыватели listPages tree:true и subtree-BFS не тронуты, есть регрессионный тест на точный набор полей). shape:"getTree" проецирует {pageId, title, children?, hasChildren?} (id→pageId; slugId/icon/position/parentPageId не утекают ни на одной глубине). - client.ts: getTree(spaceId, rootPageId?, maxDepth?) — тот же код-путь, что listPages tree:true: один enumerateSpacePages(spaceId, rootPageId) (единичный /pages/tree + cursor-BFS фолбэк для stock upstream) → buildPageTree(pages, {shape:"getTree", maxDepth}). listPages tree:true помечен DEPRECATED в JSDoc (BFS-фолбэк на месте, поведение не тронуто). - maxDepth/hasChildren: полное дерево строится, потом обрезается. Корни = глубина 1; maxDepth:1 → только корни; hasChildren:true ТОЛЬКО на срезанном узле с серверным hasChildren (source of truth), опущен у листьев и раскрытых узлов. Схема тула min(1) отбраковывает 0/отрицательные. - tool-specs.ts: shared-спек getTree (оба хоста через цикл реестра), DocmostClientLike Pick += getTree, listPages desc/catalogLine — про депрекацию. server-instructions.ts: getTree:"READ" + routing-проза. Loader DocmostClientMethod += getTree, compile-time client-call contract += getTree. Циклы/self-ref безопасны: project рекурсирует только от корней, циклический компонент недостижим из корней (нет бесконечной рекурсии). Orphan (родитель отфильтрован пермишенами) всплывает как корень. Тесты: tree.test.mjs +9 (nesting+порядок+no-leak, maxDepth:1/2 + hasChildren, orphan→root, seeded rootPageId, ≤0/нефинитный = без среза, lean-байт-идентичность), tool-specs.test.mjs (схема getTree + депрекация listPages). mcp node --test 718/718, tsc чисто; server jest (shared-tool-specs.contract + ai-chat) 263/263. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/tools/ai-chat-tools.service.ts | 1 + .../ai-chat/tools/docmost-client.loader.ts | 1 + packages/mcp/src/client.ts | 43 ++++- packages/mcp/src/lib/tree.ts | 93 +++++++++- packages/mcp/src/server-instructions.ts | 3 +- packages/mcp/src/tool-specs.ts | 55 +++++- packages/mcp/test/unit/tool-specs.test.mjs | 39 +++++ packages/mcp/test/unit/tree.test.mjs | 161 ++++++++++++++++++ 8 files changed, 380 insertions(+), 16 deletions(-) diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index d9fa8aa9..e3e0e1a7 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -59,6 +59,7 @@ function __assertClientCallContract(client: DocmostClientLike): void { void client.getWorkspace(); void client.getSpaces(); void client.listPages(s, n, true); + void client.getTree(s, s, n); void client.listSidebarPages(s, s); void client.getOutline(s); void client.getPageJson(s); diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index 2012e2b8..4dd453a9 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -23,6 +23,7 @@ type DocmostClientMethod = | 'getWorkspace' | 'getSpaces' | 'listPages' + | 'getTree' | 'listSidebarPages' | 'getOutline' | 'getPageJson' diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 1ebb725c..70190ff5 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -811,13 +811,17 @@ export class DocmostClient { * 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 + * Tree (`tree` true): DEPRECATED — prefer `getTree`, which shares this exact + * code path (a single `/pages/tree` request via `enumerateSpacePages` + + * `buildPageTree`) but returns the compact `{pageId, title, children?, + * hasChildren?}` shape and supports `rootPageId`/`maxDepth`. This tree mode is + * kept for backward compatibility; it REQUIRES `spaceId` (a page tree is * scoped to one space) and IGNORES `limit` — the whole hierarchy is returned. * It fetches the tree via `enumerateSpacePages`, which on the fork server * resolves to a single `/pages/tree` request returning the whole * permission-filtered flat page set (soft-deleted pages excluded - * server-side). + * server-side); the cursor-BFS in `enumerateSpacePages` is only a fallback for + * stock upstream servers that lack `/pages/tree`. */ async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) { await this.ensureAuthenticated(); @@ -841,6 +845,39 @@ export class DocmostClient { return items.map((page: any) => filterPage(page)); } + /** + * Fetch a space's page hierarchy (or one subtree) as a nested tree in a SINGLE + * request — the #443 `getTree` tool. Shares its whole code path with + * `listPages(tree:true)`: `enumerateSpacePages` issues one `POST /pages/tree` + * (with the cursor-BFS only as a fallback for stock upstream servers that lack + * the endpoint), then `buildPageTree` nests the flat, permission-filtered, + * position-ordered list. No second tree fetch, no per-node BFS. + * + * - `rootPageId` — restrict to that page's subtree; the server seeds the CTE + * with the page itself, so the result is exactly ONE root (the page and its + * descendants). Omit it for the whole space. + * - `maxDepth` — trim the response to that many levels (roots = depth 1) to + * save tokens; the server still returns everything in one request, the cut + * is applied in `buildPageTree` AFTER the full tree is built. A node whose + * children were cut carries `hasChildren: true` (source of truth = the flat + * item's server `hasChildren`) so the caller can descend with a follow-up + * `getTree(spaceId, rootPageId=that node)` call. + * + * Output nodes are `{pageId, title, children?, hasChildren?}` — only the UUID + * `pageId` is exposed (never `slugId`/`icon`/`position`). Requires `spaceId` + * (a page tree is scoped to one space). + */ + async getTree(spaceId: string, rootPageId?: string, maxDepth?: number) { + await this.ensureAuthenticated(); + if (!spaceId) { + throw new Error( + "getTree: spaceId is required (a page tree is scoped to one space).", + ); + } + const { pages } = await this.enumerateSpacePages(spaceId, rootPageId); + return buildPageTree(pages, { shape: "getTree", maxDepth }); + } + /** * 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 diff --git a/packages/mcp/src/lib/tree.ts b/packages/mcp/src/lib/tree.ts index 27607295..85da178f 100644 --- a/packages/mcp/src/lib/tree.ts +++ b/packages/mcp/src/lib/tree.ts @@ -1,11 +1,30 @@ +/** + * Options for `buildPageTree`. Fully OPTIONAL so the existing call form + * `buildPageTree(nodes)` keeps its historic behaviour (lean `{id, slugId, + * title, children?}` output, no depth cut) unchanged. + * + * - `shape: "getTree"` — emit the #443 `getTree` output node shape + * `{pageId, title, children?, hasChildren?}` instead of the lean + * `{id, slugId, title, children?}` shape. `slugId`/`icon`/`position` are + * never exposed (INVARIANT: only the UUID `pageId` leaves the MCP layer). + * - `maxDepth` — trim the built tree to this many levels (root nodes are + * depth 1). Only meaningful together with `shape: "getTree"` (the lean shape + * has no `hasChildren` to signal a cut). See the depth logic below. + */ +export interface BuildPageTreeOptions { + shape?: "lean" | "getTree"; + maxDepth?: number; +} + /** * Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced * by `enumerateSpacePages`) into a nested tree. * * Input: a flat array of nodes. Each node is expected to carry at least - * { id, slugId, title, position, parentPageId } (extra fields are ignored). + * { id, slugId, title, position, parentPageId } (extra fields are ignored), + * plus a server `hasChildren` boolean used by the `getTree` shape below. * - * Output: an array of ROOT nodes, each shaped as + * Output (default / `shape: "lean"`): an array of ROOT nodes, each shaped as * { id, slugId, title, children? } * where `children` is the array of child nodes (same shape, recursively). The * `children` key is OMITTED entirely when a node has no children — consistent @@ -13,6 +32,14 @@ * lean (nesting alone conveys the structure; parentPageId/position/hasChildren * are intentionally dropped from the output). * + * Output (`shape: "getTree"`, the #443 tool shape): each node is + * { pageId, title, children?, hasChildren? } + * — the server `id` is exposed as `pageId` (never `slugId`/`icon`/`position`). + * `children` is omitted for leaves and for nodes trimmed by `maxDepth`. + * `hasChildren: true` is set ONLY on a node whose children exist on the server + * (per the flat item's `hasChildren`) but were CUT by `maxDepth`; on leaves and + * on fully-expanded interior nodes the field is omitted (see `maxDepth` below). + * * Linking rule: a node is attached as a child of `parentPageId` only when that * parent id is actually present in the input. Otherwise — including a null / * undefined `parentPageId`, or a parent that was capped out of the bounded walk @@ -26,18 +53,42 @@ * fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined * `position` sort last. * + * maxDepth (getTree shape only): the tree is built in FULL first, then trimmed + * on the way out. Root nodes are depth 1. `maxDepth: N` keeps nodes at depth + * <= N and drops the `children` of any node AT depth N. A node whose children + * were dropped this way gets `hasChildren: true` when it actually had children + * in the flat input (source of truth = the server `hasChildren` flag), so the + * caller knows it can descend further with a follow-up `rootPageId` call. An + * absent/undefined `maxDepth` means no cut (whole tree). `maxDepth <= 0` is + * treated as "no cut" (defensive; the tool schema clamps to >= 1). + * * Pure: no I/O, no network, deterministic. */ -export function buildPageTree(nodes: any[]): any[] { - type OutputNode = { +export function buildPageTree( + nodes: any[], + options: BuildPageTreeOptions = {}, +): any[] { + const getTreeShape = options.shape === "getTree"; + // A finite, positive cut only; anything else means "no cut". + const maxDepth = + typeof options.maxDepth === "number" && + Number.isFinite(options.maxDepth) && + options.maxDepth > 0 + ? Math.floor(options.maxDepth) + : undefined; + + type InternalNode = { id: string; + // Retained internally for shaping; never all emitted at once. slugId: any; title: any; - children?: OutputNode[]; + hasServerChildren: boolean; + children?: InternalNode[]; }; - // Map id -> output node. Build the lean output shape up front. - const byId = new Map(); + // Map id -> internal node. Build up front; the output shape is projected at + // the very end so the maxDepth cut can consult `hasServerChildren`. + const byId = new Map(); // Preserve the original position string for sorting (kept off the output). const positionById = new Map(); @@ -49,6 +100,7 @@ export function buildPageTree(nodes: any[]): any[] { id: node.id, slugId: node.slugId, title: node.title, + hasServerChildren: node.hasChildren === true, }); positionById.set(node.id, node.position); } @@ -90,5 +142,30 @@ export function buildPageTree(nodes: any[]): any[] { } roots.sort(byPosition); - return roots.map((id) => byId.get(id)!); + const rootNodes = roots.map((id) => byId.get(id)!); + + // Project the internal nodes into the requested OUTPUT shape, applying the + // maxDepth cut for the getTree shape. `depth` is 1-based (roots = depth 1). + const project = (node: InternalNode, depth: number): any => { + if (getTreeShape) { + const out: any = { pageId: node.id, title: node.title }; + const atCut = maxDepth !== undefined && depth >= maxDepth; + if (!atCut && node.children && node.children.length > 0) { + out.children = node.children.map((c) => project(c, depth + 1)); + } else if (atCut && node.hasServerChildren) { + // Children exist on the server but were trimmed by maxDepth: signal it + // so the caller can descend with a follow-up rootPageId call. + out.hasChildren = true; + } + return out; + } + // Lean (historic) shape: cycle-safe, no depth cut, no hasChildren. + const out: any = { id: node.id, slugId: node.slugId, title: node.title }; + if (node.children && node.children.length > 0) { + out.children = node.children.map((c) => project(c, depth + 1)); + } + return out; + }; + + return rootNodes.map((n) => project(n, 1)); } diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index f79a330b..daf9ca84 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -40,7 +40,7 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; */ export const ROUTING_PROSE = "Docmost editing guide — choose the tool by intent. The at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" + - "READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + + "READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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.\n" + "PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" + @@ -82,6 +82,7 @@ const TOOL_FAMILY: Record = { // READ search: "READ", listPages: "READ", + getTree: "READ", listSpaces: "READ", getOutline: "READ", getNode: "READ", diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index 018a743a..8fc60c34 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -63,6 +63,7 @@ export type DocmostClientLike = Pick< | 'getSpaces' | 'listShares' | 'listPages' + | 'getTree' | 'getPage' | 'getPageJson' | 'getOutline' @@ -917,11 +918,13 @@ export const SHARED_TOOL_SPECS = { description: 'List the most recent pages (ordered by updatedAt, descending), ' + 'optionally scoped to a single space. 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.', + '50, max 100) — use search for lookups in large spaces. tree:true (with ' + + "spaceId) returns the space's full page hierarchy as a nested tree, but " + + 'is DEPRECATED — use getTree instead (leaner nodes, plus rootPageId / ' + + 'maxDepth).', tier: 'core', - catalogLine: "listPages — list recent pages, or a space's full page tree.", + catalogLine: + "listPages — list recent pages (tree:true is deprecated; use getTree for the hierarchy).", buildShape: (z) => ({ spaceId: z .string() @@ -954,6 +957,50 @@ export const SHARED_TOOL_SPECS = { ), }, + getTree: { + mcpName: 'getTree', + inAppKey: 'getTree', + description: + "Get a space's page hierarchy (or one subtree) as a nested tree in a " + + 'SINGLE request — completely and without loss. Each node is ' + + '`{ pageId, title, children? }`; children are ordered as in the sidebar. ' + + 'Pass rootPageId to return only that page and its descendants (exactly ' + + 'one root). Pass maxDepth to trim depth and save tokens (root nodes are ' + + 'depth 1, so maxDepth:1 returns only the roots); a node whose children ' + + 'were trimmed carries `hasChildren:true` so you can descend later with ' + + 'getTree(rootPageId=that page). Prefer this over listPages tree:true.', + tier: 'core', + catalogLine: + "getTree — a space's page hierarchy (or a subtree) as a nested tree in one request.", + buildShape: (z) => ({ + spaceId: z + .string() + .min(1) + .describe('The id of the space whose page tree to return.'), + rootPageId: z + .string() + .optional() + .describe( + 'Optional page id: return only this page and its descendants (one root).', + ), + maxDepth: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Optional depth cap (roots are depth 1). maxDepth:1 returns only the ' + + 'roots; trimmed nodes carry hasChildren:true.', + ), + }), + execute: (client, { spaceId, rootPageId, maxDepth }) => + client.getTree( + spaceId as string, + rootPageId as string | undefined, + maxDepth as number | undefined, + ), + }, + createPage: { mcpName: 'createPage', inAppKey: 'createPage', diff --git a/packages/mcp/test/unit/tool-specs.test.mjs b/packages/mcp/test/unit/tool-specs.test.mjs index e94ee222..42422e0d 100644 --- a/packages/mcp/test/unit/tool-specs.test.mjs +++ b/packages/mcp/test/unit/tool-specs.test.mjs @@ -165,6 +165,45 @@ test("insertNode spec exists, describes markdown+node XOR, builds the full ancho ); }); +// #443: getTree — a space's page hierarchy (or a subtree) in one request. +test("getTree spec exists on both hosts, builds { spaceId, rootPageId?, maxDepth? }", () => { + const spec = SHARED_TOOL_SPECS.getTree; + assert.ok(spec, "getTree spec missing"); + assert.equal(spec.mcpName, "getTree"); + assert.equal(spec.inAppKey, "getTree"); + // Shared spec: registered on BOTH hosts. + assert.notEqual(spec.inAppOnly, true); + assert.notEqual(spec.mcpOnly, true); + + const shape = spec.buildShape(z); + assert.deepEqual(Object.keys(shape).sort(), ["maxDepth", "rootPageId", "spaceId"]); + const schema = z.object(shape); + // spaceId required; rootPageId + maxDepth optional. + assert.doesNotThrow(() => schema.parse({ spaceId: "sp1" })); + assert.throws(() => schema.parse({})); + assert.doesNotThrow(() => + schema.parse({ spaceId: "sp1", rootPageId: "p1", maxDepth: 2 }), + ); + // maxDepth is an integer >= 1. + assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 0 })); + assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 1.5 })); + + // The description advertises the output node shape, rootPageId, maxDepth, and + // steers away from the deprecated listPages tree:true. + assert.match(spec.description, /pageId/); + assert.match(spec.description, /rootPageId/); + assert.match(spec.description, /maxDepth/); + assert.match(spec.description, /hasChildren/); + assert.match(spec.description, /listPages tree:true/); +}); + +// #443: listPages tree:true is deprecated in favour of getTree. +test("listPages description deprecates tree:true and points at getTree", () => { + const spec = SHARED_TOOL_SPECS.listPages; + assert.match(spec.description, /DEPRECATED/i); + assert.match(spec.description, /getTree/); +}); + test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => { for (const key of ["getWorkspace", "listSpaces", "listShares"]) { assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`); diff --git a/packages/mcp/test/unit/tree.test.mjs b/packages/mcp/test/unit/tree.test.mjs index 405464d4..a5d4b11f 100644 --- a/packages/mcp/test/unit/tree.test.mjs +++ b/packages/mcp/test/unit/tree.test.mjs @@ -137,3 +137,164 @@ test("buildPageTree output shape is lean (drops position/parentPageId/hasChildre assert.equal("hasChildren" in node, false); assert.equal("spaceId" in node, false); }); + +// --------------------------------------------------------------------------- +// #443 getTree output shape: { pageId, title, children?, hasChildren? } +// --------------------------------------------------------------------------- + +// A small representative space used across the getTree tests: +// r1 (Infrastructure) +// c1 (Datacenter A) +// g1 (Servers) [leaf] +// c2 (Datacenter B) [leaf] +// r2 (Notes) [leaf] +const SAMPLE = [ + { id: "r2", slugId: "s-r2", title: "Notes", position: "a1", icon: "📝", hasChildren: false }, + { id: "r1", slugId: "s-r1", title: "Infrastructure", position: "a0", icon: "🏢", hasChildren: true }, + { id: "c2", slugId: "s-c2", title: "Datacenter B", position: "b1", parentPageId: "r1", icon: "🅱️", hasChildren: false }, + { id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", parentPageId: "r1", icon: "🅰️", hasChildren: true }, + { id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", icon: "🖥️", hasChildren: false }, +]; + +test("getTree shape: correct nesting + order-by-position, only {pageId,title,children?}, no leak", () => { + const tree = buildPageTree(SAMPLE, { shape: "getTree" }); + + // Roots sorted by position: r1 (a0) before r2 (a1). + assert.deepEqual( + tree.map((n) => n.pageId), + ["r1", "r2"], + ); + // r1's children sorted by position: c1 (b0) before c2 (b1). + assert.deepEqual( + tree[0].children.map((n) => n.pageId), + ["c1", "c2"], + ); + // Deep nesting: g1 under c1. + assert.deepEqual( + tree[0].children[0].children.map((n) => n.pageId), + ["g1"], + ); + + // No slugId/icon/position/parentPageId/hasChildren leak on any node. + const walk = (nodes) => { + for (const n of nodes) { + assert.deepEqual( + Object.keys(n).sort(), + n.children ? ["children", "pageId", "title"] : ["pageId", "title"], + `unexpected keys on ${n.pageId}: ${Object.keys(n)}`, + ); + assert.equal("slugId" in n, false); + assert.equal("icon" in n, false); + assert.equal("position" in n, false); + assert.equal("parentPageId" in n, false); + // Fully-expanded tree (no maxDepth): hasChildren never set. + assert.equal("hasChildren" in n, false); + if (n.children) walk(n.children); + } + }; + walk(tree); +}); + +test("getTree maxDepth:1 returns roots only, each with hasChildren from the flat item", () => { + const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 1 }); + + assert.deepEqual( + tree.map((n) => n.pageId), + ["r1", "r2"], + ); + // No children arrays at depth 1 when maxDepth:1. + for (const n of tree) assert.equal("children" in n, false); + // r1 has children on the server -> hasChildren:true; r2 is a leaf -> omitted. + assert.equal(tree[0].hasChildren, true); + assert.equal("hasChildren" in tree[1], false); +}); + +test("getTree maxDepth:2 cuts grandchildren; hasChildren only on the cut interior node", () => { + const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 2 }); + + const r1 = tree[0]; + // Depth-1 node r1 was EXPANDED (its children are present) -> no hasChildren. + assert.equal("hasChildren" in r1, false); + assert.equal(r1.children.length, 2); + + const [c1, c2] = r1.children; + // c1 is at depth 2 (the cut) and has children on the server -> hasChildren:true, + // and its grandchild g1 is NOT present. + assert.equal(c1.pageId, "c1"); + assert.equal("children" in c1, false); + assert.equal(c1.hasChildren, true); + // c2 is at depth 2 but is a leaf on the server -> hasChildren omitted. + assert.equal(c2.pageId, "c2"); + assert.equal("children" in c2, false); + assert.equal("hasChildren" in c2, false); + + // r2 is a depth-1 leaf -> no hasChildren, no children. + assert.equal("hasChildren" in tree[1], false); +}); + +test("getTree hasChildren is set ONLY on depth-cut nodes (not leaves, not expanded interior nodes)", () => { + // Full tree (no cut): NO node anywhere carries hasChildren. + const full = buildPageTree(SAMPLE, { shape: "getTree" }); + const anyHasChildren = (nodes) => + nodes.some((n) => "hasChildren" in n || (n.children && anyHasChildren(n.children))); + assert.equal(anyHasChildren(full), false); +}); + +test("getTree orphan (parent filtered out) surfaces as a root, not dropped", () => { + const tree = buildPageTree( + [ + { id: "root", slugId: "s-root", title: "Root", position: "a0", hasChildren: true }, + // parentPageId points at an id NOT in the flat list (parent filtered by perms). + { id: "orphan", slugId: "s-orphan", title: "Orphan", position: "a1", parentPageId: "gone", hasChildren: false }, + ], + { shape: "getTree" }, + ); + + assert.deepEqual( + tree.map((n) => n.pageId).sort(), + ["orphan", "root"], + ); + const orphan = tree.find((n) => n.pageId === "orphan"); + assert.equal("children" in orphan, false); + assert.equal("hasChildren" in orphan, false); +}); + +test("getTree rootPageId path: a seeded single-root subtree keeps the getTree shape", () => { + // Simulate the server seeding the CTE with the subtree root c1: the flat list + // it returns contains c1 (now a root, parent absent) + its descendant g1. + const subtree = [ + { id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", hasChildren: true }, + { id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", hasChildren: false }, + ]; + const tree = buildPageTree(subtree, { shape: "getTree" }); + + assert.equal(tree.length, 1); + assert.equal(tree[0].pageId, "c1"); + assert.deepEqual( + tree[0].children.map((n) => n.pageId), + ["g1"], + ); + assert.equal("slugId" in tree[0], false); +}); + +test("getTree maxDepth<=0 / non-finite is treated as no cut (whole tree)", () => { + for (const bad of [0, -3, NaN, Infinity, undefined]) { + const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: bad }); + // Grandchild g1 present -> no cut applied. + assert.deepEqual( + tree[0].children[0].children.map((n) => n.pageId), + ["g1"], + `maxDepth=${bad} should not cut`, + ); + } +}); + +test("buildPageTree() with no options is byte-identical to the historic lean call", () => { + // Guard the existing callers: buildPageTree(pages) must be unchanged by the + // additive options param. + const withoutOpts = buildPageTree(SAMPLE); + const withEmptyOpts = buildPageTree(SAMPLE, {}); + assert.deepEqual(withoutOpts, withEmptyOpts); + // And it is the lean {id,slugId,title,children?} shape, not the getTree shape. + assert.deepEqual(Object.keys(withoutOpts[0]).sort(), ["children", "id", "slugId", "title"]); +}); -- 2.52.0 From 5dc7a2703f6e6acef985a60c3cbb51af95869135 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 22:19:25 +0300 Subject: [PATCH 7/8] =?UTF-8?q?feat(mcp):=20getPageContext=20=E2=80=94=20?= =?UTF-8?q?=C2=AB=D0=B3=D0=B4=D0=B5=20=D1=8F=20/=20=D1=87=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=BA=D1=80=D1=83=D0=B3=C2=BB=20=D0=BF=D0=BE=20pag?= =?UTF-8?q?eId=20=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D0=B2=D1=8B=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=BC=20(#443,=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?3/3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Финальная из трёх частей #443. Есть pageId (из поиска, из ссылки) — нужно понять местоположение и окружение. Раньше это get_page + полное дерево. Только чтение, только метаданные (без контента). closes #443. - client.ts getPageContext(pageId): resolvePageId (slugId→uuid, для uuid без round-trip) → POST /pages/breadcrumbs + listSidebarPages(spaceId, uuid). Два запроса для uuid-входа (spaceId берётся из ответа breadcrumbs), +1 для slugId. Выход {page:{pageId,title,spaceId}, breadcrumbs:[{pageId,title}] (root→parent; [] для корня), children:[{pageId,title,hasChildren}]}. - Порядок breadcrumbs выверен по серверному источнику (getPageBreadCrumbs: CTE сидится на childPageId, идёт вверх, .reverse() → root→page; страница — ПОСЛЕДНИЙ элемент). page = chain[last], breadcrumbs = chain.slice(0,-1); корень → []. Проекция явная — наружу только pageId (UUID), slugId/icon/position не утекают. Пустой chain / 404 / 403 → явная ошибка, не пустой объект. - children — прямые дети через listSidebarPages (cursor-пагинация #442/#451, страница с >20 детьми отдаёт всех без дублей, порядок по position, hasChildren). - Общий реестр: getPageContext на ОБОИХ хостах через цикл спеков (mcpName=== inAppKey==="getPageContext", camelCase); DocmostClientLike/DocmostClientMethod += it; compile-time contract; TOOL_FAMILY READ + routing-проза. Тесты: get-page-context.test.mjs +6 (3-й уровень split, no-slugId-leak + 2 запроса, корень→[], slugId-resolve, >20 детей без cap/дублей, плохой id→ошибка, пустой chain→throw), tool-specs +1. mcp node --test 725/725, tsc чисто; server jest (contract + ai-chat) 265/265. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/tools/ai-chat-tools.service.ts | 1 + .../ai-chat/tools/docmost-client.loader.ts | 1 + packages/mcp/src/client.ts | 68 ++++ packages/mcp/src/server-instructions.ts | 3 +- packages/mcp/src/tool-specs.ts | 30 ++ .../mcp/test/mock/get-page-context.test.mjs | 375 ++++++++++++++++++ packages/mcp/test/unit/tool-specs.test.mjs | 25 ++ 7 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/test/mock/get-page-context.test.mjs diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index e3e0e1a7..0cf17e03 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -60,6 +60,7 @@ function __assertClientCallContract(client: DocmostClientLike): void { void client.getSpaces(); void client.listPages(s, n, true); void client.getTree(s, s, n); + void client.getPageContext(s); void client.listSidebarPages(s, s); void client.getOutline(s); void client.getPageJson(s); diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index 4dd453a9..20c24780 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -24,6 +24,7 @@ type DocmostClientMethod = | 'getSpaces' | 'listPages' | 'getTree' + | 'getPageContext' | 'listSidebarPages' | 'getOutline' | 'getPageJson' diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 70190ff5..5838b61c 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -878,6 +878,74 @@ export class DocmostClient { return buildPageTree(pages, { shape: "getTree", maxDepth }); } + /** + * "Where am I / what's around" for a single page — the #443 `getPageContext` + * tool. Metadata only (no page content), using exactly TWO server requests: + * + * 1. `POST /pages/breadcrumbs` — a recursive CTE that walks UP from the page. + * The server returns the chain root->page order (it `.reverse()`s the + * child-first walk before responding), INCLUDING the page itself as the + * LAST element. So the last element is the page and everything before it + * is the ancestor chain root->parent. This carries the page's own title + * and spaceId, so no extra page-info fetch is needed for a UUID input. + * 2. `listSidebarPages(spaceId, pageId)` — the page's DIRECT children, + * cursor-paginated (a page with >20 children returns ALL of them, no + * dupes) and in sidebar `position` order, each carrying `hasChildren`. + * + * The input may be a slugId (agents copy them from URLs); it is run through + * `resolvePageId` first, exactly like the other page tools. A UUID input adds + * no request there (short-circuit), keeping the total at two; a slugId input + * adds one unavoidable resolve round-trip. + * + * INVARIANT: only the UUID `pageId` is exposed anywhere — server `id` is + * mapped to `pageId` and `slugId` is never leaked. A nonexistent/inaccessible + * pageId makes the server 404/403, which propagates as a clear tool error + * (never a hollow empty object). + */ + async getPageContext(pageId: string) { + await this.ensureAuthenticated(); + + // Resolve a possibly-slugId input to the canonical UUID (no round-trip for a + // UUID). Errors here (bad/inaccessible id) propagate as a clear tool error. + const pageUuid = await this.resolvePageId(pageId); + + // Request 1: the ancestor chain, root->page, page included as the LAST item. + const response = await this.client.post("/pages/breadcrumbs", { + pageId: pageUuid, + }); + const chain: any[] = (response.data?.data ?? response.data) ?? []; + if (!Array.isArray(chain) || chain.length === 0) { + // The endpoint always includes the page itself, so an empty chain means + // the page is gone/inaccessible — surface a clear error, not {}. + throw new Error(`getPageContext: page "${pageId}" not found or inaccessible`); + } + + // Split: the last element is the page, the rest (root->parent) are the + // breadcrumbs. A root page has no ancestors -> breadcrumbs is []. + const self = chain[chain.length - 1]; + const ancestors = chain.slice(0, -1); + + const page = { + pageId: self.id, + title: self.title, + spaceId: self.spaceId, + }; + const breadcrumbs = ancestors.map((n: any) => ({ + pageId: n.id, + title: n.title, + })); + + // Request 2: direct children in sidebar order, each with hasChildren. + const childItems = await this.listSidebarPages(self.spaceId, pageUuid); + const children = childItems.map((c: any) => ({ + pageId: c.id, + title: c.title, + hasChildren: Boolean(c.hasChildren), + })); + + return { page, breadcrumbs, children }; + } + /** * 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 diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index daf9ca84..e7ec58ed 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -40,7 +40,7 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; */ export const ROUTING_PROSE = "Docmost editing guide — choose the tool by intent. The at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" + - "READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + + "READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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.\n" + "PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" + @@ -83,6 +83,7 @@ const TOOL_FAMILY: Record = { search: "READ", listPages: "READ", getTree: "READ", + getPageContext: "READ", listSpaces: "READ", getOutline: "READ", getNode: "READ", diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index 8fc60c34..fec7e9ab 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -64,6 +64,7 @@ export type DocmostClientLike = Pick< | 'listShares' | 'listPages' | 'getTree' + | 'getPageContext' | 'getPage' | 'getPageJson' | 'getOutline' @@ -1001,6 +1002,35 @@ export const SHARED_TOOL_SPECS = { ), }, + getPageContext: { + mcpName: 'getPageContext', + inAppKey: 'getPageContext', + description: + 'Given a pageId, get its LOCATION and immediate surroundings (metadata ' + + 'only, no page content) in one call — answers "where am I / what is ' + + "around this page\". Returns `{ page: { pageId, title, spaceId }, " + + 'breadcrumbs: [{ pageId, title }], children: [{ pageId, title, ' + + 'hasChildren }] }`. `breadcrumbs` is the ancestor chain from the space ' + + 'root down to the PARENT (the parent is its last element; a root page ' + + 'has `breadcrumbs: []`). `children` are the direct children in sidebar ' + + 'order, each flagged `hasChildren` so you know which can be expanded ' + + '(descend with getTree(rootPageId=that child) or another getPageContext). ' + + 'Ids, titles and child order are consistent with getTree.', + tier: 'core', + catalogLine: + 'getPageContext — a page’s breadcrumbs + direct children (where-am-I) in one call.', + buildShape: (z) => ({ + pageId: z + .string() + .min(1) + .describe( + 'The id of the page to locate (a pageId/UUID, or a slugId from a URL).', + ), + }), + execute: (client, { pageId }) => + client.getPageContext(pageId as string), + }, + createPage: { mcpName: 'createPage', inAppKey: 'createPage', diff --git a/packages/mcp/test/mock/get-page-context.test.mjs b/packages/mcp/test/mock/get-page-context.test.mjs new file mode 100644 index 00000000..d1e72d3f --- /dev/null +++ b/packages/mcp/test/mock/get-page-context.test.mjs @@ -0,0 +1,375 @@ +// Mock-HTTP tests for DocmostClient.getPageContext — the #443 "where am I / +// what's around" read tool. A local http.createServer stands in for Docmost +// (same harness style as pagination-cursor.test.mjs) so everything is +// deterministic and offline. +// +// Contract pinned here: +// - Two requests: POST /pages/breadcrumbs (ancestor chain root->page, page +// INCLUDED as the LAST element) + listSidebarPages (direct children). +// - Split: last chain element -> `page`; the rest (root->parent) -> +// `breadcrumbs`. A ROOT page (chain length 1) -> breadcrumbs: []. +// - children: {pageId, title, hasChildren} in sidebar order. +// - INVARIANT: only the UUID `pageId` is exposed, never `slugId`. +// - A slugId input is resolved via /pages/info first (adds one request); a +// UUID input short-circuits (stays at two requests). +// - A bad/inaccessible pageId throws a CLEAR error, not an empty object. +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { DocmostClient } from "../../build/client.js"; + +function readBody(req) { + return new Promise((resolve) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => resolve(raw)); + }); +} + +function startServer(handler) { + return new Promise((resolve) => { + const server = http.createServer(handler); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ server, baseURL: `http://127.0.0.1:${port}/api` }); + }); + }); +} + +function closeServer(server) { + return new Promise((resolve) => server.close(resolve)); +} + +function sendJson(res, status, obj, extraHeaders = {}) { + res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders }); + res.end(JSON.stringify(obj)); +} + +const openServers = []; +async function spawn(handler) { + const { server, baseURL } = await startServer(handler); + openServers.push(server); + return { server, baseURL }; +} + +after(async () => { + await Promise.all(openServers.map((s) => closeServer(s))); +}); + +function handleLogin(req, res) { + if (req.url === "/api/auth/login") { + sendJson(res, 200, { success: true }, { + "Set-Cookie": "authToken=t; Path=/; HttpOnly", + }); + return true; + } + return false; +} + +// Two real UUIDs so resolvePageId short-circuits (no /pages/info round-trip). +const ROOT_UUID = "00000000-0000-4000-8000-000000000001"; +const MID_UUID = "00000000-0000-4000-8000-000000000002"; +const PAGE_UUID = "00000000-0000-4000-8000-000000000003"; +const CHILD_A = "00000000-0000-4000-8000-00000000000a"; +const CHILD_B = "00000000-0000-4000-8000-00000000000b"; + +// Build a breadcrumbs response as the server sends it: root->page order, page +// LAST, wrapped in the {data,success} envelope. slugId/icon/position are present +// on the wire (they must NOT leak into the tool output). +function breadcrumbsEnvelope(chain) { + return { success: true, data: chain }; +} + +// ----------------------------------------------------------------------------- +// 1) 3rd-level page: page = last chain element; breadcrumbs = the two ancestors +// root->parent; children mapped {pageId,title,hasChildren} in order; no leak; +// exactly two requests for a UUID input. +// ----------------------------------------------------------------------------- +test("getPageContext: 3rd-level page splits chain, maps children, no slugId leak, 2 requests", async () => { + let breadcrumbReqs = 0; + let sidebarReqs = 0; + let infoReqs = 0; + let breadcrumbBody = null; + + const { baseURL } = await spawn(async (req, res) => { + const raw = await readBody(req); + if (handleLogin(req, res)) return; + if (req.url === "/api/pages/info") { + infoReqs++; + sendJson(res, 404, {}); + return; + } + if (req.url === "/api/pages/breadcrumbs") { + breadcrumbReqs++; + breadcrumbBody = JSON.parse(raw || "{}"); + // root -> parent -> page (page LAST). slugId/icon/position on the wire. + sendJson( + res, + 200, + breadcrumbsEnvelope([ + { id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", position: "a", icon: null, parentPageId: null, hasChildren: true }, + { id: MID_UUID, slugId: "midSlug", title: "Datacenter A", spaceId: "sp1", position: "a", icon: null, parentPageId: ROOT_UUID, hasChildren: true }, + { id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", position: "b", icon: null, parentPageId: MID_UUID, hasChildren: true }, + ]), + ); + return; + } + if (req.url === "/api/pages/sidebar-pages") { + sidebarReqs++; + const body = JSON.parse(raw || "{}"); + assert.equal(body.pageId, PAGE_UUID, "children scoped to the page UUID"); + assert.equal(body.spaceId, "sp1", "children scoped to the page's space"); + sendJson(res, 200, { + success: true, + data: { + items: [ + { id: CHILD_A, slugId: "aSlug", title: "Servers", parentPageId: PAGE_UUID, hasChildren: true, position: "a" }, + { id: CHILD_B, slugId: "bSlug", title: "Network", parentPageId: PAGE_UUID, hasChildren: false, position: "b" }, + ], + meta: { hasNextPage: false, nextCursor: null }, + }, + }); + return; + } + sendJson(res, 404, {}); + }); + + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + const result = await client.getPageContext(PAGE_UUID); + + assert.equal(infoReqs, 0, "UUID input short-circuits resolvePageId (no /pages/info)"); + assert.equal(breadcrumbReqs, 1, "exactly one breadcrumbs request"); + assert.equal(sidebarReqs, 1, "exactly one sidebar request"); + assert.deepEqual(breadcrumbBody, { pageId: PAGE_UUID }, "breadcrumbs posts the UUID"); + + // page = the LAST chain element. + assert.deepEqual(result.page, { + pageId: PAGE_UUID, + title: "Rack 12", + spaceId: "sp1", + }); + // breadcrumbs = root->parent (the chain minus the page itself). + assert.deepEqual(result.breadcrumbs, [ + { pageId: ROOT_UUID, title: "Infrastructure" }, + { pageId: MID_UUID, title: "Datacenter A" }, + ]); + // children mapped in order, hasChildren coerced to boolean. + assert.deepEqual(result.children, [ + { pageId: CHILD_A, title: "Servers", hasChildren: true }, + { pageId: CHILD_B, title: "Network", hasChildren: false }, + ]); + + // No slugId anywhere in the output. + const dump = JSON.stringify(result); + assert.ok(!dump.includes("Slug"), "no slugId leaks into the output"); + assert.ok(!/\bslugId\b/.test(dump), "no slugId key in the output"); +}); + +// ----------------------------------------------------------------------------- +// 2) ROOT page: chain has ONE element (the page itself) -> breadcrumbs: []. +// ----------------------------------------------------------------------------- +test("getPageContext: a root page has breadcrumbs: []", async () => { + const { baseURL } = await spawn(async (req, res) => { + const raw = await readBody(req); + if (handleLogin(req, res)) return; + if (req.url === "/api/pages/breadcrumbs") { + // A root page: the CTE returns only the page itself. + sendJson( + res, + 200, + breadcrumbsEnvelope([ + { id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null, hasChildren: false }, + ]), + ); + return; + } + if (req.url === "/api/pages/sidebar-pages") { + sendJson(res, 200, { + success: true, + data: { items: [], meta: { hasNextPage: false, nextCursor: null } }, + }); + return; + } + sendJson(res, 404, {}); + }); + + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + const result = await client.getPageContext(ROOT_UUID); + + assert.deepEqual(result.page, { + pageId: ROOT_UUID, + title: "Infrastructure", + spaceId: "sp1", + }); + assert.deepEqual(result.breadcrumbs, [], "root page: no ancestors"); + assert.deepEqual(result.children, [], "no children"); +}); + +// ----------------------------------------------------------------------------- +// 3) A slugId input is resolved via /pages/info first (one extra request), then +// breadcrumbs/sidebar use the resolved UUID. +// ----------------------------------------------------------------------------- +test("getPageContext: a slugId input is resolved via /pages/info", async () => { + let infoReqs = 0; + let infoBody = null; + let breadcrumbBody = null; + + const { baseURL } = await spawn(async (req, res) => { + const raw = await readBody(req); + if (handleLogin(req, res)) return; + if (req.url === "/api/pages/info") { + infoReqs++; + infoBody = JSON.parse(raw || "{}"); + // getPageRaw: slugId -> canonical UUID. + sendJson(res, 200, { + success: true, + data: { id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1" }, + }); + return; + } + if (req.url === "/api/pages/breadcrumbs") { + breadcrumbBody = JSON.parse(raw || "{}"); + sendJson( + res, + 200, + breadcrumbsEnvelope([ + { id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null }, + { id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", parentPageId: ROOT_UUID }, + ]), + ); + return; + } + if (req.url === "/api/pages/sidebar-pages") { + sendJson(res, 200, { + success: true, + data: { items: [], meta: { hasNextPage: false, nextCursor: null } }, + }); + return; + } + sendJson(res, 404, {}); + }); + + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + const result = await client.getPageContext("pageSlug"); + + assert.equal(infoReqs, 1, "slugId resolved via one /pages/info"); + assert.deepEqual(infoBody, { pageId: "pageSlug" }, "resolve posts the raw slugId"); + assert.deepEqual( + breadcrumbBody, + { pageId: PAGE_UUID }, + "breadcrumbs posts the RESOLVED uuid, not the slugId", + ); + assert.equal(result.page.pageId, PAGE_UUID, "page.pageId is the UUID"); + assert.deepEqual(result.breadcrumbs, [ + { pageId: ROOT_UUID, title: "Infrastructure" }, + ]); +}); + +// ----------------------------------------------------------------------------- +// 4) >20 children: cursor pagination returns ALL of them, no dupes (regression +// on the #442 bug class — getPageContext must not re-introduce a cap). +// ----------------------------------------------------------------------------- +test("getPageContext: a page with >20 children returns ALL of them (no cap, no dupes)", async () => { + // 45 children spread over three cursor pages. + const all = Array.from({ length: 45 }, (_, i) => ({ + id: `child-${i}`, + slugId: `slug-${i}`, + title: `Child ${i}`, + parentPageId: PAGE_UUID, + hasChildren: i % 2 === 0, + })); + const PAGES = { + "": { items: all.slice(0, 20), nextCursor: "c1" }, + c1: { items: all.slice(20, 40), nextCursor: "c2" }, + c2: { items: all.slice(40), nextCursor: null }, + }; + + const { baseURL } = await spawn(async (req, res) => { + const raw = await readBody(req); + if (handleLogin(req, res)) return; + if (req.url === "/api/pages/breadcrumbs") { + sendJson( + res, + 200, + breadcrumbsEnvelope([ + { id: PAGE_UUID, slugId: "pageSlug", title: "Big Parent", spaceId: "sp1", parentPageId: null }, + ]), + ); + return; + } + if (req.url === "/api/pages/sidebar-pages") { + const body = JSON.parse(raw || "{}"); + const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null }; + sendJson(res, 200, { + success: true, + data: { + items: page.items, + meta: { hasNextPage: page.nextCursor != null, nextCursor: page.nextCursor }, + }, + }); + return; + } + sendJson(res, 404, {}); + }); + + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + const result = await client.getPageContext(PAGE_UUID); + + assert.equal(result.children.length, 45, "all 45 children returned"); + const ids = result.children.map((c) => c.pageId); + assert.equal(new Set(ids).size, 45, "no duplicate children"); + assert.deepEqual(ids, all.map((c) => c.id), "children in server order across cursor pages"); + assert.equal(result.children[0].hasChildren, true, "hasChildren preserved (child 0)"); + assert.equal(result.children[1].hasChildren, false, "hasChildren preserved (child 1)"); +}); + +// ----------------------------------------------------------------------------- +// 5) A nonexistent / inaccessible pageId -> a CLEAR error, NOT an empty object. +// ----------------------------------------------------------------------------- +test("getPageContext: a bad/inaccessible pageId throws a clear error (not {})", async () => { + const { baseURL } = await spawn(async (req, res) => { + await readBody(req); + if (handleLogin(req, res)) return; + if (req.url === "/api/pages/breadcrumbs") { + // Server rejects an unknown/forbidden page. + sendJson(res, 404, { message: "Page not found" }); + return; + } + sendJson(res, 404, {}); + }); + + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + await assert.rejects( + () => client.getPageContext(PAGE_UUID), + (err) => { + assert.ok(err instanceof Error, "throws an Error"); + return true; + }, + "a 404 from breadcrumbs propagates as a thrown error, not a hollow {}", + ); +}); + +// ----------------------------------------------------------------------------- +// 6) An empty breadcrumbs chain (should never happen — the endpoint always +// includes the page itself) is treated as not-found, not a hollow {page:...}. +// ----------------------------------------------------------------------------- +test("getPageContext: an empty breadcrumbs chain throws (defensive)", async () => { + const { baseURL } = await spawn(async (req, res) => { + await readBody(req); + if (handleLogin(req, res)) return; + if (req.url === "/api/pages/breadcrumbs") { + sendJson(res, 200, breadcrumbsEnvelope([])); + return; + } + sendJson(res, 404, {}); + }); + + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + await assert.rejects( + () => client.getPageContext(PAGE_UUID), + /not found or inaccessible/, + "an empty chain is a clear error, not {}", + ); +}); diff --git a/packages/mcp/test/unit/tool-specs.test.mjs b/packages/mcp/test/unit/tool-specs.test.mjs index 42422e0d..41f16606 100644 --- a/packages/mcp/test/unit/tool-specs.test.mjs +++ b/packages/mcp/test/unit/tool-specs.test.mjs @@ -197,6 +197,31 @@ test("getTree spec exists on both hosts, builds { spaceId, rootPageId?, maxDepth assert.match(spec.description, /listPages tree:true/); }); +// #443: getPageContext — a page's breadcrumbs + direct children in one call. +test("getPageContext spec exists on both hosts, builds { pageId }", () => { + const spec = SHARED_TOOL_SPECS.getPageContext; + assert.ok(spec, "getPageContext spec missing"); + assert.equal(spec.mcpName, "getPageContext"); + assert.equal(spec.inAppKey, "getPageContext"); + // Shared spec: registered on BOTH hosts. + assert.notEqual(spec.inAppOnly, true); + assert.notEqual(spec.mcpOnly, true); + + const shape = spec.buildShape(z); + assert.deepEqual(Object.keys(shape).sort(), ["pageId"]); + const schema = z.object(shape); + // pageId required. + assert.doesNotThrow(() => schema.parse({ pageId: "p1" })); + assert.throws(() => schema.parse({})); + + // The description advertises the output shape (page/breadcrumbs/children) and + // the root-page empty-breadcrumbs contract. + assert.match(spec.description, /breadcrumbs/); + assert.match(spec.description, /children/); + assert.match(spec.description, /hasChildren/); + assert.match(spec.description, /getTree/); +}); + // #443: listPages tree:true is deprecated in favour of getTree. test("listPages description deprecates tree:true and points at getTree", () => { const spec = SHARED_TOOL_SPECS.listPages; -- 2.52.0 From 2a951df09660e75b3f441fc17d74f2053e1e80b1 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 23:16:50 +0300 Subject: [PATCH 8/8] =?UTF-8?q?feat(mcp):=20drawio=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B4=D0=B8=D1=8F=203=20=E2=80=94=20=D1=81=D0=B5=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D1=82?= =?UTF-8?q?=D1=83=D0=BB=D1=8B=20drawioFromGraph/FromMermaid/EditCells=20(#?= =?UTF-8?q?425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сырой XML остаётся escape-hatch, но для 90% случаев модель не видит ни координат, ни style-строк — весь класс ошибок лейаута и иконок уходит by construction: эти решения принимает сервер, а не LLM. Стоит на #443, переиспользует конвейер #423 и shape-index/elkjs/линтер #424. - drawioFromGraph(pageId, where, graph, direction?, preset?, layout?): граф узлов/групп/связей → резолв иконок (shape-index #424; неизвестная → generic по kind с подписью, не пустой квадрат), стили из пресета (kind→палитра), elkjs-layered с compound-группами, ассемблер XML (линтер-чистый by construction: зазоры >=150, прозрачные контейнеры, относительные координаты детей, cross-container рёбра parent=1, эскейп меток). Хинты pinned/sameLayerAs/layer и layout none/full/incremental — детерминированным post-pass'ом (ELK-констрейнты оказались ненадёжны). Пресеты default/dark/colorblind-safe (Okabe-Ito) — данные. - drawioFromMermaid(pageId, where, mermaid): чистый парсер flowchart (без браузера/CLI) → graph → тот же конвейер. Формы/направление/пунктир=async/ subgraph→группы/цепочки; не-flowchart отвергает внятно. - drawioEditCells(pageId, node, operations, baseHash): ID-based add/update/delete, delete каскадит на детей контейнера и связанные рёбра; baseHash обязателен (optimistic lock как drawioUpdate); сентинелы 0/1 от delete защищены. DoS-границы (LLM-вход): MAX_GRAPH_EDGES=1000/GROUPS=500 в validateGraph (узлы уже 500), mermaid MAX_CHARS=200k/LINES=20k/GROUPS=500, chain 500 — все с быстрым throw ДО лейаута/ассемблера (ассемблер и маппер вне ELK-таймаута, иначе OOM воркера). incremental сохраняет неперечисленные существующие ячейки (mergeExisting Cells) — «добавь узел» не стирает ручную расстановку. sameLayerAs/layer после снапа раскладываются по перпендикулярной оси с зазором >=150 → 0 quality-warnings; pinned — точные пользовательские координаты (clamp>=0, могут дать warning, гарантия «0 by construction» относится к авто-лейауту). Регистрация: 3 shared-spec на оба хоста (camelCase, execute-in-spec, inlineBoth Hosts не понадобился), DocmostClientLike/Method += 3, contract, routing-проза (fromGraph→архитектуры/облака, fromMermaid→стандартные flowchart, raw xml→экзотика). Тесты: mcp node --test 782/782 (57 новых) — приёмка (15+ узлов/2 вложенные группы/ AWS-иконки→0 lint/0 warnings/иконки резолвятся, hints, incremental без сдвига, edit_cells update/cascade/stale-baseHash, снапшоты пресетов + colorblind-safe, mermaid ветвление+subgraph) + регрессии на DoS-границы. tsc чисто; server jest (contract + ai-chat) 290/290. closes #425. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/tools/ai-chat-tools.service.ts | 17 + .../ai-chat/tools/docmost-client.loader.ts | 4 + packages/mcp/data/drawio-presets.json | 63 ++ packages/mcp/src/client.ts | 257 +++++ packages/mcp/src/lib/drawio-cell-ops.ts | 168 ++++ packages/mcp/src/lib/drawio-graph.ts | 916 ++++++++++++++++++ packages/mcp/src/lib/drawio-mermaid.ts | 347 +++++++ packages/mcp/src/lib/drawio-presets.ts | 151 +++ packages/mcp/src/server-instructions.ts | 5 +- packages/mcp/src/tool-specs.ts | 231 +++++ .../mcp/test/mock/drawio-graph-tools.test.mjs | 272 ++++++ .../mcp/test/unit/drawio-cell-ops.test.mjs | 128 +++ packages/mcp/test/unit/drawio-graph.test.mjs | 380 ++++++++ .../mcp/test/unit/drawio-mermaid.test.mjs | 114 +++ .../mcp/test/unit/drawio-presets.test.mjs | 117 +++ .../unit/drawio-stage3-registration.test.mjs | 85 ++ 16 files changed, 3254 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/data/drawio-presets.json create mode 100644 packages/mcp/src/lib/drawio-cell-ops.ts create mode 100644 packages/mcp/src/lib/drawio-graph.ts create mode 100644 packages/mcp/src/lib/drawio-mermaid.ts create mode 100644 packages/mcp/src/lib/drawio-presets.ts create mode 100644 packages/mcp/test/mock/drawio-graph-tools.test.mjs create mode 100644 packages/mcp/test/unit/drawio-cell-ops.test.mjs create mode 100644 packages/mcp/test/unit/drawio-graph.test.mjs create mode 100644 packages/mcp/test/unit/drawio-mermaid.test.mjs create mode 100644 packages/mcp/test/unit/drawio-presets.test.mjs create mode 100644 packages/mcp/test/unit/drawio-stage3-registration.test.mjs diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index 0cf17e03..dc2270fd 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -123,6 +123,23 @@ function __assertClientCallContract(client: DocmostClientLike): void { void client.drawioGet(s, s, 'xml'); void client.drawioCreate(s, { position: 'append', anchorNodeId: s }, s, s, 'elk'); void client.drawioUpdate(s, s, s, s, 'elk'); + // --- draw.io high-level semantic tools (#425 stage 3) --- + void client.drawioEditCells(s, s, [{ op: 'delete', cellId: s }], s); + void client.drawioFromGraph( + s, + { position: 'append', anchorNodeId: s }, + { nodes: [{ id: s, label: s }] }, + 'LR', + s, + 'full', + s, + ); + void client.drawioFromMermaid( + s, + { position: 'append', anchorNodeId: s }, + s, + s, + ); // --- write (comment) --- void client.createComment(s, s, 'inline', s, s, s); void client.resolveComment(s, true); diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index 20c24780..24f16f6a 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -71,6 +71,10 @@ type DocmostClientMethod = | 'drawioGet' | 'drawioCreate' | 'drawioUpdate' + // --- draw.io high-level semantic tools (#425 stage 3) --- + | 'drawioEditCells' + | 'drawioFromGraph' + | 'drawioFromMermaid' // --- write (comment) --- | 'createComment' | 'resolveComment'; diff --git a/packages/mcp/data/drawio-presets.json b/packages/mcp/data/drawio-presets.json new file mode 100644 index 00000000..8e200a29 --- /dev/null +++ b/packages/mcp/data/drawio-presets.json @@ -0,0 +1,63 @@ +{ + "$comment": "Semantic palettes for drawioFromGraph (issue #425). DATA, not code: node `kind` -> fill/stroke slot, edge `kind` -> line-style props, per preset. The `default` node palette is the issue's base table; `dark` keeps the same hues on a dark canvas with lighter strokes/font; `colorblind-safe` maps every slot onto the Okabe-Ito qualitative palette (8 colours proven distinguishable for all common colour-vision deficiencies) so no two adjacent kinds collide. `fontColor`/`fillColor`/`strokeColor` are exact draw.io values. `edgeDefault` is the fallback line style; `group` is the (always-transparent) container stroke per preset.", + "presets": { + "default": { + "canvasDark": false, + "nodes": { + "service": { "fillColor": "#dae8fc", "strokeColor": "#6c8ebf", "fontColor": "#000000" }, + "db": { "fillColor": "#d5e8d4", "strokeColor": "#82b366", "fontColor": "#000000" }, + "queue": { "fillColor": "#fff2cc", "strokeColor": "#d6b656", "fontColor": "#000000" }, + "gateway": { "fillColor": "#ffe6cc", "strokeColor": "#d79b00", "fontColor": "#000000" }, + "error": { "fillColor": "#f8cecc", "strokeColor": "#b85450", "fontColor": "#000000" }, + "external": { "fillColor": "#f5f5f5", "strokeColor": "#666666", "fontColor": "#333333" }, + "security": { "fillColor": "#e1d5e7", "strokeColor": "#9673a6", "fontColor": "#000000" } + }, + "edges": { + "sync": { "props": "" }, + "async": { "props": "dashed=1;" }, + "error": { "props": "dashed=1;strokeColor=#DD344C;" } + }, + "edgeDefault": { "strokeColor": "#333333", "fontColor": "#333333" }, + "group": { "strokeColor": "#666666", "fontColor": "#333333" } + }, + "dark": { + "canvasDark": true, + "nodes": { + "service": { "fillColor": "#1a2a44", "strokeColor": "#7ea6e0", "fontColor": "#dae8fc" }, + "db": { "fillColor": "#1f331e", "strokeColor": "#97d077", "fontColor": "#d5e8d4" }, + "queue": { "fillColor": "#3a3218", "strokeColor": "#e5c15a", "fontColor": "#fff2cc" }, + "gateway": { "fillColor": "#3a2812", "strokeColor": "#ffb570", "fontColor": "#ffe6cc" }, + "error": { "fillColor": "#3a1c1b", "strokeColor": "#e08e8b", "fontColor": "#f8cecc" }, + "external": { "fillColor": "#2b2b2b", "strokeColor": "#999999", "fontColor": "#e0e0e0" }, + "security": { "fillColor": "#2c2338", "strokeColor": "#b39ddb", "fontColor": "#e1d5e7" } + }, + "edges": { + "sync": { "props": "" }, + "async": { "props": "dashed=1;" }, + "error": { "props": "dashed=1;strokeColor=#ff6b6b;" } + }, + "edgeDefault": { "strokeColor": "#cccccc", "fontColor": "#e0e0e0" }, + "group": { "strokeColor": "#aaaaaa", "fontColor": "#e0e0e0" } + }, + "colorblind-safe": { + "canvasDark": false, + "okabeIto": ["#000000", "#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"], + "nodes": { + "service": { "fillColor": "#D6E9F5", "strokeColor": "#0072B2", "fontColor": "#000000" }, + "db": { "fillColor": "#D6EFE4", "strokeColor": "#009E73", "fontColor": "#000000" }, + "queue": { "fillColor": "#FCF8CC", "strokeColor": "#F0E442", "fontColor": "#000000" }, + "gateway": { "fillColor": "#FBEBD0", "strokeColor": "#E69F00", "fontColor": "#000000" }, + "error": { "fillColor": "#F7DDCC", "strokeColor": "#D55E00", "fontColor": "#000000" }, + "external": { "fillColor": "#EDEDED", "strokeColor": "#000000", "fontColor": "#000000" }, + "security": { "fillColor": "#F3DEEB", "strokeColor": "#CC79A7", "fontColor": "#000000" } + }, + "edges": { + "sync": { "props": "" }, + "async": { "props": "dashed=1;" }, + "error": { "props": "dashed=1;strokeColor=#D55E00;" } + }, + "edgeDefault": { "strokeColor": "#000000", "fontColor": "#000000" }, + "group": { "strokeColor": "#000000", "fontColor": "#000000" } + } + } +} diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 5838b61c..b7d80b8b 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -64,6 +64,14 @@ import { } from "./lib/drawio-xml.js"; import { renderDiagramShapes } from "./lib/drawio-preview.js"; import { applyElkLayout } from "./lib/drawio-layout.js"; +import { + buildFromGraph, + type Graph, + type LayoutMode as GraphLayoutMode, +} from "./lib/drawio-graph.js"; +import { applyCellOps, type CellOp } from "./lib/drawio-cell-ops.js"; +import { mermaidToGraph } from "./lib/drawio-mermaid.js"; +import { parseCells as parseDrawioCells } from "./lib/drawio-xml.js"; import { applyTextEdits, TextEdit, @@ -4630,6 +4638,255 @@ export class DocmostClient { }; } + // --- draw.io high-level semantic tools (issue #425) --- + + /** + * ID-based targeted edits of an existing drawio diagram (add / update / delete + * cells) instead of resending the whole XML. Reads the CURRENT diagram, checks + * the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies + * the operations to the parsed model (a `delete` CASCADES to container children + * and to every edge whose source/target is deleted), then runs the SAME #423 + * pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment -> + * repoint the node). Ids are stable so diffs stay meaningful across edits. + */ + async drawioEditCells( + pageId: string, + node: string, + operations: CellOp[], + baseHash: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + if (typeof baseHash !== "string" || baseHash.length === 0) { + throw new Error( + "drawioEditCells: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash", + ); + } + if (!Array.isArray(operations) || operations.length === 0) { + throw new Error( + "drawioEditCells: operations must be a non-empty array of { op, ... }", + ); + } + + const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node); + const oldAttrs = drawio.attrs || {}; + const oldSrc = oldAttrs.src; + const nodeId = oldAttrs.id ?? ref; + if (!oldSrc) { + throw new Error( + `drawioEditCells: node "${node}" on page ${pageId} has no src to edit`, + ); + } + const currentSvg = await this.fetchAttachmentText(oldSrc); + const currentModel = decodeDrawioSvg(currentSvg); + const currentHash = mxHash(currentModel); + if (currentHash !== baseHash) { + throw new Error( + `drawioEditCells: conflict — the diagram changed since it was read ` + + `(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`, + ); + } + + // Apply the operations to the parsed model, then run the standard pipeline. + const editedModel = applyCellOps(currentModel, operations); + const prepared = prepareModel(editedModel); + const inner = renderDiagramShapes(prepared.cells, prepared.bbox); + const diagramTitle = oldAttrs.title || "Page-1"; + const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle); + + const att = await this.uploadAttachmentBuffer( + pageId, + Buffer.from(svg, "utf-8"), + "diagram.drawio.svg", + "image/svg+xml", + ); + const newSrc = `/api/files/${att.id}/${att.fileName}`; + + const collabToken = await this.getCollabTokenWithReauth(); + const pageUuid = await this.resolvePageId(pageId); + + let repointed = 0; + const mutation = await this.mutatePage( + pageUuid, + collabToken, + this.apiUrl, + (liveDoc) => { + repointed = 0; + const doc = + liveDoc && liveDoc.type === "doc" ? liveDoc : { type: "doc", content: [] }; + if (!Array.isArray(doc.content)) doc.content = []; + const hit = getNodeByRef(doc, ref); + if (!hit || hit.type !== "drawio") return null; + let target: any = doc; + for (const idx of hit.path) { + if (!target || !Array.isArray(target.content)) { + target = null; + break; + } + target = target.content[idx]; + } + if (!target || target.type !== "drawio") return null; + target.attrs = { + ...target.attrs, + src: newSrc, + attachmentId: att.id, + width: prepared.bbox.width, + height: prepared.bbox.height, + }; + repointed++; + return doc; + }, + ); + + if (repointed === 0) { + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: [ + ...prepared.warnings, + "target drawio node was removed concurrently; uploaded attachment is unreferenced", + ], + verify: mutation.verify, + }; + } + return { + success: true, + nodeId, + attachmentId: att.id, + warnings: prepared.warnings, + verify: mutation.verify, + }; + } + + /** + * The main high-level tool: build a diagram from a SEMANTIC graph (nodes with + * a `kind`/`icon`, groups, edges) — the model never supplies coordinates or + * style strings. The server resolves icons via the shape catalog (#424), + * assigns palette colors from the preset, runs ELK layered layout (honouring + * `direction` and the `layer`/`sameLayerAs`/`pinned` hints and compound groups), + * and assembles linter-clean XML, then inserts it through the SAME create + * pipeline as drawioCreate. `layout:"incremental"` is only meaningful when a + * target `node` is given (it preserves that diagram's existing coordinates and + * places only new cells); on a fresh insert it behaves like "full". + */ + async drawioFromGraph( + pageId: string, + where: { + position: "before" | "after" | "append"; + anchorNodeId?: string; + anchorText?: string; + }, + graph: Graph, + direction?: "LR" | "RL" | "TB" | "BT", + preset?: string, + layout?: GraphLayoutMode, + node?: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + iconsResolved: number; + iconsMissing: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + // Direction/preset supplied as separate params override the graph fields so + // both the flat tool schema and an inline graph can set them. + const merged: Graph = { + ...graph, + direction: direction ?? graph.direction, + preset: preset ?? graph.preset, + }; + const mode: GraphLayoutMode = layout ?? "full"; + + // Incremental into an EXISTING node: read its coords so ELK preserves them, + // and keep the full existing model so incremental MERGES (never drops) any + // cell the new graph doesn't re-list. + let existingCoords: Map | undefined; + let existingModelXml: string | undefined; + let editExisting = false; + let baseHash: string | undefined; + if (node && (mode === "incremental" || mode === "none")) { + const { node: drawio } = await this.resolveDrawioNode(pageId, node); + const src = (drawio.attrs || {}).src; + if (src) { + const svg = await this.fetchAttachmentText(src); + const model = decodeDrawioSvg(svg); + baseHash = mxHash(model); + existingModelXml = model; + existingCoords = new Map(); + for (const c of parseDrawioCells(model)) { + if (c.vertex && c.geometry.x != null && c.geometry.y != null) { + existingCoords.set(c.id, { x: c.geometry.x, y: c.geometry.y }); + } + } + editExisting = true; + } + } + + const built = await buildFromGraph( + merged, + mode, + existingCoords, + existingModelXml, + ); + + if (editExisting && node && baseHash) { + // Re-target the existing diagram: replace it with the assembled model. + const res = await this.drawioUpdate(pageId, node, built.modelXml, baseHash); + return { + ...res, + iconsResolved: built.iconsResolved, + iconsMissing: built.iconsMissing, + }; + } + + const res = await this.drawioCreate(pageId, where, built.modelXml); + return { + ...res, + iconsResolved: built.iconsResolved, + iconsMissing: built.iconsMissing, + }; + } + + /** + * Convert a Mermaid `flowchart` to a redactable draw.io diagram via a PURE + * parser (no Electron / draw.io CLI): mermaid text -> graph-JSON -> the + * drawioFromGraph pipeline. Only `flowchart`/`graph` is supported (the most + * common wiki case); other diagram types throw a clear error so the model can + * fall back to drawioFromGraph. + */ + async drawioFromMermaid( + pageId: string, + where: { + position: "before" | "after" | "append"; + anchorNodeId?: string; + anchorText?: string; + }, + mermaid: string, + preset?: string, + ): Promise<{ + success: boolean; + nodeId: string; + attachmentId: string; + warnings: string[]; + iconsResolved: number; + iconsMissing: string[]; + verify?: any; + }> { + await this.ensureAuthenticated(); + const graph = mermaidToGraph(mermaid); + if (preset) graph.preset = preset; + return this.drawioFromGraph(pageId, where, graph, graph.direction, graph.preset); + } + // --- Page history / diff / transform --- /** diff --git a/packages/mcp/src/lib/drawio-cell-ops.ts b/packages/mcp/src/lib/drawio-cell-ops.ts new file mode 100644 index 00000000..032a863a --- /dev/null +++ b/packages/mcp/src/lib/drawio-cell-ops.ts @@ -0,0 +1,168 @@ +// ID-based cell operations for `drawioEditCells` (issue #425, stage 3). +// +// Instead of resending the whole XML (whose diff is fragile — draw.io reorders +// attributes, and a {search,replace} text match breaks on it), the model sends +// targeted operations keyed by cell id: +// +// { op: "add", xml: "" } // append a new cell +// { op: "update", cellId: "n3", xml: "" } // replace that cell +// { op: "delete", cellId: "n5" } // + CASCADE +// +// `delete` CASCADES: it removes the cell, every descendant cell whose parent +// chain leads to it (container children), AND every edge whose source or target +// is any deleted cell. Ids are STABLE across edits so diffs stay meaningful. +// +// Operations apply to the parsed DOM of the current model; the caller re-lints +// and rebuilds the .drawio.svg through the existing #423 pipeline afterwards. + +import { JSDOM } from "jsdom"; + +let _window: any = null; +function xmlWindow(): any { + if (!_window) _window = new JSDOM("").window; + return _window; +} + +export type CellOp = + | { op: "add"; xml: string } + | { op: "update"; cellId: string; xml: string } + | { op: "delete"; cellId: string }; + +export class CellOpsError extends Error { + constructor(message: string) { + super(`drawioEditCells: ${message}`); + this.name = "CellOpsError"; + } +} + +// The mxGraph root sentinels. id="0" is the graph root; id="1" is the default +// layer that parents every real cell. A delete targeting either would cascade +// through the whole diagram body (every cell chains up to "1"), so such an op is +// rejected outright. +const SENTINEL_IDS = new Set(["0", "1"]); + +/** Parse a single `` fragment into an element, or throw. */ +function parseCellFragment(xml: string): any { + const parser = new (xmlWindow().DOMParser)(); + // Wrap so a self-closed or child-bearing single cell parses as one root. + const doc = parser.parseFromString(`${xml}`, "application/xml"); + if (doc.getElementsByTagName("parsererror").length > 0) { + throw new CellOpsError(`operation xml is not well-formed: ${xml.slice(0, 120)}`); + } + const cells = doc.getElementsByTagName("mxCell"); + if (cells.length !== 1) { + throw new CellOpsError( + `each add/update op must carry exactly one (got ${cells.length})`, + ); + } + return cells[0]; +} + +/** All ids reachable as descendants of `rootId` via the parent relation. */ +function collectDescendants( + rootId: string, + parentOf: Map, +): Set { + const doomed = new Set([rootId]); + let grew = true; + while (grew) { + grew = false; + for (const [id, parent] of parentOf) { + if (!doomed.has(id) && parent != null && doomed.has(parent)) { + doomed.add(id); + grew = true; + } + } + } + return doomed; +} + +/** + * Apply the operation list to a model XML string and return the new model XML. + * Uses the DOM so attribute order / formatting is preserved for untouched cells. + * Throws CellOpsError on an unknown target id or a malformed op fragment (so the + * model gets a precise error and nothing is half-applied). + */ +export function applyCellOps(modelXml: string, ops: CellOp[]): string { + if (!Array.isArray(ops) || ops.length === 0) { + throw new CellOpsError("operations must be a non-empty array"); + } + const parser = new (xmlWindow().DOMParser)(); + const doc = parser.parseFromString(modelXml, "application/xml"); + if (doc.getElementsByTagName("parsererror").length > 0) { + throw new CellOpsError("the current diagram XML is not well-formed"); + } + const root = doc.getElementsByTagName("root")[0]; + if (!root) throw new CellOpsError("the current diagram has no element"); + + const cellEls = () => Array.from(root.getElementsByTagName("mxCell")) as any[]; + const byId = () => { + const m = new Map(); + for (const el of cellEls()) m.set(el.getAttribute("id") ?? "", el); + return m; + }; + + for (const op of ops) { + if (op.op === "add") { + const frag = parseCellFragment(op.xml); + const id = frag.getAttribute("id"); + if (!id) throw new CellOpsError("an add op's is missing an id"); + if (byId().has(id)) + throw new CellOpsError(`add op id "${id}" already exists (use update)`); + root.appendChild(doc.importNode(frag, true)); + } else if (op.op === "update") { + const map = byId(); + const target = map.get(op.cellId); + if (!target) + throw new CellOpsError(`update target cell "${op.cellId}" does not exist`); + const frag = parseCellFragment(op.xml); + const newId = frag.getAttribute("id"); + if (newId && newId !== op.cellId) + throw new CellOpsError( + `update op cellId "${op.cellId}" != the id "${newId}" (ids are stable)`, + ); + // Replace the element in place so surrounding cells are untouched. + const imported = doc.importNode(frag, true); + target.parentNode.replaceChild(imported, target); + } else if (op.op === "delete") { + // Reject a sentinel-targeted delete BEFORE collecting descendants: "0"/"1" + // parent the entire diagram, so a cascade from either would wipe the whole + // model body (doomed.delete("0"/"1") only spared the sentinel itself, not + // its children). + if (SENTINEL_IDS.has(op.cellId)) + throw new CellOpsError( + `cannot delete sentinel cell "${op.cellId}" (the graph root/default layer)`, + ); + const map = byId(); + if (!map.has(op.cellId)) + throw new CellOpsError(`delete target cell "${op.cellId}" does not exist`); + // Build the parent relation over the CURRENT cells for the cascade. + const parentOf = new Map(); + for (const el of cellEls()) { + parentOf.set(el.getAttribute("id") ?? "", el.getAttribute("parent") ?? undefined); + } + const doomed = collectDescendants(op.cellId, parentOf); + // Cascade to edges whose source/target is any doomed cell. + for (const el of cellEls()) { + if (el.getAttribute("edge") !== "1") continue; + const src = el.getAttribute("source"); + const tgt = el.getAttribute("target"); + if ((src && doomed.has(src)) || (tgt && doomed.has(tgt))) { + doomed.add(el.getAttribute("id") ?? ""); + } + } + // Never delete the sentinels even if referenced by a malformed op. + doomed.delete("0"); + doomed.delete("1"); + for (const el of cellEls()) { + const id = el.getAttribute("id") ?? ""; + if (doomed.has(id)) el.parentNode.removeChild(el); + } + } else { + throw new CellOpsError(`unknown op "${(op as any).op}"`); + } + } + + const ser = new (xmlWindow().XMLSerializer)(); + return ser.serializeToString(doc.documentElement); +} diff --git a/packages/mcp/src/lib/drawio-graph.ts b/packages/mcp/src/lib/drawio-graph.ts new file mode 100644 index 00000000..5e8878bd --- /dev/null +++ b/packages/mcp/src/lib/drawio-graph.ts @@ -0,0 +1,916 @@ +// Semantic graph -> draw.io pipeline for `drawioFromGraph` (issue #425, stage 3). +// +// The model describes a diagram SEMANTICALLY — nodes with a `kind` and an +// optional `icon`, groups (containers), edges with a `kind` — and NEVER sees a +// coordinate or a style string. This module owns the whole server-side pipeline: +// +// 1. validateGraph — a hand-written validator (no zod dependency, so this +// lib stays importable by client.ts without coupling to +// a zod major) that rejects malformed graphs early. +// 2. resolveNodeStyle — `icon` -> exact style via the shape catalog (#424); +// an UNKNOWN icon degrades to a generic shape by `kind` +// WITH the label (never an empty square). `kind` -> the +// preset palette slot. +// 3. graphToElk — graph -> ELK-JSON, honouring the layout hints +// (`layer`/`sameLayerAs` -> layer constraints, `pinned` +// -> a fixed node) and compound group nodes. +// 4. assembleModel — graph + ELK coordinates -> a full mxGraphModel XML +// that satisfies the #423 linter BY CONSTRUCTION +// (sentinels, transparent containers, relative child +// coords, cross-container edges parent="1", >=150px +// gaps from ELK spacing, escaped labels). +// +// The `layout` mode: "full" re-lays everything; "incremental" fixes existing +// coordinates (ELK interactive mode) and places only new nodes; "none" keeps the +// caller-provided/prior coordinates untouched. + +import ELK from "elkjs/lib/elk.bundled.js"; +import { JSDOM } from "jsdom"; +import { + searchShapes, + awsServiceStyle, + type ShapeResult, +} from "./drawio-shapes.js"; +import { + getPreset, + genericNodeStyle, + iconNodeStyle, + edgeStyle, + groupStyle, + type PresetData, +} from "./drawio-presets.js"; +import { MIN_SHAPE_GAP } from "./drawio-xml.js"; + +// --- graph schema (plain TS + a hand validator) ---------------------------- + +export interface GraphNode { + id: string; + label: string; + kind?: string; + /** Icon reference, e.g. "aws:lambda" | "azure:cosmos" | "lambda". */ + icon?: string; + /** Group (container) id this node belongs to. */ + group?: string; + /** Layer hint (ELK layerChoiceConstraint): 0-based column/row index. */ + layer?: number; + /** Put this node in the same layer as another node id. */ + sameLayerAs?: string; + /** Fix this node at exact coordinates (an ELK fixed node). */ + pinned?: { x: number; y: number }; +} + +export interface GraphGroup { + id: string; + label: string; + kind?: string; + /** Parent group id — lets a group nest inside another group (e.g. subnet in VPC). */ + group?: string; +} + +export interface GraphEdge { + from: string; + to: string; + label?: string; + kind?: string; +} + +export interface Graph { + nodes: GraphNode[]; + groups?: GraphGroup[]; + edges?: GraphEdge[]; + direction?: "LR" | "RL" | "TB" | "BT"; + preset?: string; +} + +export type LayoutMode = "none" | "full" | "incremental"; + +/** A structured validation error (mirrors the drawio linter's shape loosely). */ +export class GraphValidationError extends Error { + issues: string[]; + constructor(issues: string[]) { + super(`drawioFromGraph: invalid graph — ${issues.join("; ")}`); + this.name = "GraphValidationError"; + this.issues = issues; + } +} + +const MAX_GRAPH_NODES = 500; // parity with drawio-layout's ELK_MAX_NODES. +// Edge/group caps mirror drawio-layout's ELK_MAX_EDGES. Without an edge cap a +// tiny node set with a huge edge list (e.g. 500 nodes / 200000 edges) passes +// node validation, then graphToElk/runElk exhausts the heap SYNCHRONOUSLY inside +// elk.bundled.js — before the 5s ELK timeout can fire and OUTSIDE it entirely +// for the mapper/assembler — crashing the worker on LLM-authored input. Reject +// the over-limit shape here, before any layout or assembly runs. +export const MAX_GRAPH_EDGES = 1000; // parity with drawio-layout's ELK_MAX_EDGES. +export const MAX_GRAPH_GROUPS = 500; // groups are compound ELK nodes; bound them too. + +/** + * Validate the graph structure BEFORE any layout/assembly so the model gets a + * precise, actionable error instead of a corrupt diagram. Throws + * GraphValidationError listing every problem. + */ +export function validateGraph(graph: Graph): void { + const issues: string[] = []; + if (!graph || typeof graph !== "object") { + throw new GraphValidationError(["graph must be an object"]); + } + if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) { + throw new GraphValidationError(["graph.nodes must be a non-empty array"]); + } + // Size caps FIRST (fail fast, before touching per-element loops) so an + // over-limit graph can never reach the layout engine and OOM the worker. + if (graph.nodes.length > MAX_GRAPH_NODES) { + throw new GraphValidationError([ + `graph has ${graph.nodes.length} nodes (max ${MAX_GRAPH_NODES})`, + ]); + } + if (Array.isArray(graph.edges) && graph.edges.length > MAX_GRAPH_EDGES) { + throw new GraphValidationError([ + `graph has ${graph.edges.length} edges (max ${MAX_GRAPH_EDGES})`, + ]); + } + if (Array.isArray(graph.groups) && graph.groups.length > MAX_GRAPH_GROUPS) { + throw new GraphValidationError([ + `graph has ${graph.groups.length} groups (max ${MAX_GRAPH_GROUPS})`, + ]); + } + + const nodeIds = new Set(); + const groupIds = new Set(); + for (const g of graph.groups ?? []) { + if (!g.id) issues.push("a group is missing its id"); + else if (groupIds.has(g.id)) issues.push(`duplicate group id "${g.id}"`); + groupIds.add(g.id); + } + for (const n of graph.nodes) { + if (!n.id) issues.push("a node is missing its id"); + else if (nodeIds.has(n.id)) issues.push(`duplicate node id "${n.id}"`); + else if (groupIds.has(n.id)) + issues.push(`node id "${n.id}" collides with a group id`); + nodeIds.add(n.id); + if (typeof n.label !== "string" || n.label === "") + issues.push(`node "${n.id}" is missing a label`); + if (n.group != null && !groupIds.has(n.group)) + issues.push(`node "${n.id}" references unknown group "${n.group}"`); + if (n.pinned != null) { + if ( + typeof n.pinned.x !== "number" || + typeof n.pinned.y !== "number" || + !Number.isFinite(n.pinned.x) || + !Number.isFinite(n.pinned.y) + ) + issues.push(`node "${n.id}" has an invalid pinned {x,y}`); + } + if (n.layer != null && (!Number.isInteger(n.layer) || n.layer < 0)) + issues.push(`node "${n.id}" has an invalid layer (must be a >=0 integer)`); + } + // sameLayerAs must reference an existing node (checked after all ids known). + for (const n of graph.nodes) { + if (n.sameLayerAs != null && !nodeIds.has(n.sameLayerAs)) + issues.push( + `node "${n.id}" sameLayerAs references unknown node "${n.sameLayerAs}"`, + ); + } + for (const e of graph.edges ?? []) { + if (!e.from || !e.to) { + issues.push("an edge is missing from/to"); + continue; + } + if (!nodeIds.has(e.from) && !groupIds.has(e.from)) + issues.push(`edge from "${e.from}" resolves to no node/group`); + if (!nodeIds.has(e.to) && !groupIds.has(e.to)) + issues.push(`edge to "${e.to}" resolves to no node/group`); + } + if (issues.length > 0) throw new GraphValidationError(issues); +} + +// --- icon resolution ------------------------------------------------------- + +/** + * Resolve a node's `icon` reference to a concrete style-string + size via the + * shape catalog. Accepts "aws:lambda", "azure:cosmos", or a bare "lambda". An + * AWS `resIcon` name is built directly (exact service-icon template). Anything + * else goes through searchShapes. Returns null when nothing resolves — the + * caller then falls back to a generic shape by kind (never an empty box). + */ +export function resolveIcon(icon: string): ShapeResult | null { + const raw = icon.trim(); + if (raw === "") return null; + let provider = ""; + let name = raw; + const colon = raw.indexOf(":"); + if (colon !== -1) { + provider = raw.slice(0, colon).trim().toLowerCase(); + name = raw.slice(colon + 1).trim(); + } + + if (provider === "aws") { + // Prefer an exact resIcon match from the catalog (carries the right size and + // any rebrand/blocklist note); if the underscore/space name doesn't hit, + // build the canonical service-icon style directly so it is never an empty box. + const results = searchShapes(name.replace(/_/g, " "), { limit: 5 }); + const aws4 = results.find((r) => r.style.includes("mxgraph.aws4")); + if (aws4) return aws4; + return { + style: awsServiceStyle(name.replace(/\s+/g, "_")), + w: 78, + h: 78, + title: name, + type: "vertex", + }; + } + + // Non-AWS or bare name: fuzzy search the catalog. Take the top vertex hit, + // but REJECT a weak match (the fuzzy scorer can prefix-match an unrelated + // stencil, e.g. "not..." -> "Notebook"); require the hit's title to actually + // share a meaningful token with the query, otherwise degrade to generic-by-kind. + const q = provider ? `${provider} ${name}` : name; + const results = searchShapes(q, { limit: 8 }); + const hit = results.find((r) => r.type !== "edge") ?? results[0]; + if (!hit) return null; + if (!isRelevantMatch(name, hit.title)) return null; + return hit; +} + +/** + * Whether a resolved stencil is a genuine match for the requested icon name (as + * opposed to a loose prefix hit on an unrelated shape). True if any 3+ char + * token of the query appears in the stencil title, or vice-versa. + */ +function isRelevantMatch(name: string, title: string): boolean { + const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + const qTokens = norm(name).split(/\s+/).filter((t) => t.length >= 3); + if (qTokens.length === 0) return true; // very short names: trust the scorer + const t = norm(title); + const tTokens = new Set(t.split(/\s+/)); + for (const qt of qTokens) { + if (tTokens.has(qt)) return true; + if (t.includes(qt)) return true; + for (const tt of tTokens) if (tt.length >= 3 && qt.includes(tt)) return true; + } + return false; +} + +/** + * Decide the final style + size for a node. When `icon` resolves, use the icon + * style (overlaid with a dark-preset font fix); otherwise a GENERIC shape by + * `kind` carrying the label. `resolved` reports whether an icon was found (used + * by the acceptance test that asserts no empty squares). + */ +export function resolveNodeStyle( + preset: PresetData, + node: GraphNode, +): { style: string; w: number; h: number; iconResolved: boolean } { + if (node.icon) { + const shape = resolveIcon(node.icon); + if (shape) { + return { + style: iconNodeStyle(preset, shape.style), + w: shape.w, + h: shape.h, + iconResolved: true, + }; + } + } + // Generic shape by kind, sized to the label so a long label never overflows. + const w = Math.max(120, estimateLabelWidth(node.label) + 32); + return { style: genericNodeStyle(preset, node.kind), w, h: 60, iconResolved: false }; +} + +/** Rough rendered width of the longest label line at 12px (~0.6em/glyph). */ +function estimateLabelWidth(label: string): number { + const lines = label.split(/\r?\n| |/i); + let longest = 0; + for (const l of lines) longest = Math.max(longest, l.trim().length); + return Math.ceil(longest * 12 * 0.6); +} + +// --- graph -> ELK-JSON ----------------------------------------------------- + +interface ElkNode { + id: string; + width?: number; + height?: number; + x?: number; + y?: number; + children?: ElkNode[]; + layoutOptions?: Record; +} +interface ElkEdge { + id: string; + sources: string[]; + targets: string[]; +} +interface ElkGraph extends ElkNode { + edges?: ElkEdge[]; +} + +const ELK_DIRECTION: Record = { + LR: "RIGHT", + RL: "LEFT", + TB: "DOWN", + BT: "UP", +}; + +/** Sizes resolved per node id (from resolveNodeStyle), fed to the ELK mapper. */ +export interface NodeSize { + w: number; + h: number; +} + +/** + * Build the ELK graph from the semantic graph + resolved node sizes. Compound + * group nodes nest their members (a group may itself nest in another group). + * `only` restricts the graph to a subset of node ids (used by the incremental + * path to lay out ONLY the new nodes). Layout HINTS (`layer`/`sameLayerAs`/ + * `pinned`) are NOT encoded as ELK constraints here — ELK's constraint knobs are + * unreliable across versions — they are enforced deterministically AFTER layout + * by applyHints, which is exact and testable. + */ +export function graphToElk( + graph: Graph, + sizes: Map, + opts: { only?: Set } = {}, +): ElkGraph { + const direction = ELK_DIRECTION[graph.direction ?? "LR"] ?? "RIGHT"; + const only = opts.only; + const include = (id: string) => !only || only.has(id); + + const makeNode = (n: GraphNode): ElkNode => { + const size = sizes.get(n.id) ?? { w: 140, h: 60 }; + return { id: n.id, width: size.w, height: size.h }; + }; + + // Group children nest under their group node; ungrouped nodes are roots. + const groupNode = new Map(); + const usedGroups = new Set(); + for (const g of graph.groups ?? []) { + const size = sizes.get(g.id) ?? { w: 200, h: 150 }; + groupNode.set(g.id, { + id: g.id, + width: size.w, + height: size.h, + children: [], + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": direction, + "elk.padding": "[top=40,left=30,bottom=30,right=30]", + "elk.layered.spacing.nodeNodeBetweenLayers": "170", + "elk.spacing.nodeNode": "170", + }, + }); + } + const roots: ElkNode[] = []; + for (const n of graph.nodes) { + if (!include(n.id)) continue; + const en = makeNode(n); + if (n.group && groupNode.has(n.group)) { + groupNode.get(n.group)!.children!.push(en); + usedGroups.add(n.group); + } else { + roots.push(en); + } + } + // Nest group nodes into their parent group (a subnet inside a VPC); groups + // with no parent group become roots. Only groups that hold an included node. + const groupIdSet = new Set((graph.groups ?? []).map((g) => g.id)); + for (const g of graph.groups ?? []) { + if (only && !usedGroups.has(g.id)) continue; + const en = groupNode.get(g.id)!; + if (g.group && groupIdSet.has(g.group) && g.group !== g.id && (!only || usedGroups.has(g.group))) { + groupNode.get(g.group)!.children!.push(en); + } else { + roots.push(en); + } + } + + // Edges: endpoints may be nodes or groups; INCLUDE_CHILDREN spans the nesting. + const validIds = new Set([ + ...graph.nodes.filter((n) => include(n.id)).map((n) => n.id), + ...(graph.groups ?? []).map((g) => g.id), + ]); + const edges: ElkEdge[] = []; + (graph.edges ?? []).forEach((e, i) => { + if (!validIds.has(e.from) || !validIds.has(e.to)) return; + edges.push({ id: `e${i}`, sources: [e.from], targets: [e.to] }); + }); + + const rootOptions: Record = { + "elk.algorithm": "layered", + "elk.direction": direction, + "elk.hierarchyHandling": "INCLUDE_CHILDREN", + "elk.layered.spacing.nodeNodeBetweenLayers": "170", + "elk.spacing.nodeNode": "170", + "elk.spacing.edgeNode": "40", + "elk.spacing.edgeEdge": "30", + "elk.padding": "[top=20,left=20,bottom=20,right=20]", + }; + + return { id: "root", layoutOptions: rootOptions, children: roots, edges }; +} + +/** + * Enforce the layout hints DETERMINISTICALLY on ELK's output (mutates `geo`): + * - `sameLayerAs`: snap the dependent node's LAYER-AXIS coordinate to its + * anchor's, so the pair lands in the same layer (x for LR/RL, y for TB/BT). + * `layer` groups nodes with the same index onto the same anchor coordinate. + * - `pinned`: override the node's coordinate with the exact pinned {x,y}. + * Applied only to top-level (ungrouped) nodes, whose ELK coords are absolute. + */ +export function applyHints( + graph: Graph, + geo: Map, +): void { + const dir = graph.direction ?? "LR"; + const layerAxis: "x" | "y" = dir === "TB" || dir === "BT" ? "y" : "x"; + // The perpendicular (cross-layer) axis: members snapped onto one layer must be + // spread along THIS axis so they don't stack onto the same point. + const crossAxis: "x" | "y" = layerAxis === "x" ? "y" : "x"; + const crossSize: "w" | "h" = crossAxis === "x" ? "w" : "h"; + const grouped = new Set( + graph.nodes.filter((n) => n.group).map((n) => n.id), + ); + + // sameLayerAs / layer: co-assign the layer-axis coordinate. + // Build the effective layer key per node, then pick a representative coord. + const layerKeyOf = new Map(); + const explicitLayer = new Map(); + for (const n of graph.nodes) if (n.layer != null) explicitLayer.set(n.id, n.layer); + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const resolveKey = (n: GraphNode): string | null => { + if (explicitLayer.has(n.id)) return `L${explicitLayer.get(n.id)}`; + const seen = new Set([n.id]); + let cur: GraphNode | undefined = n; + while (cur && cur.sameLayerAs != null && !seen.has(cur.sameLayerAs)) { + seen.add(cur.sameLayerAs); + const t = byId.get(cur.sameLayerAs); + if (!t) break; + if (explicitLayer.has(t.id)) return `L${explicitLayer.get(t.id)}`; + cur = t; + } + // A sameLayerAs chain with no explicit layer: key on the chain's root id. + if (n.sameLayerAs != null) { + let root = n.id; + const s2 = new Set([n.id]); + let c: GraphNode | undefined = n; + while (c && c.sameLayerAs != null && !s2.has(c.sameLayerAs)) { + s2.add(c.sameLayerAs); + root = c.sameLayerAs; + c = byId.get(c.sameLayerAs); + } + return `C${root}`; + } + return null; + }; + for (const n of graph.nodes) { + if (grouped.has(n.id)) continue; // group children are relative — skip + const key = resolveKey(n); + if (key) layerKeyOf.set(n.id, key); + } + // Group members of each layer key so we can snap AND spread them together. + const membersOf = new Map(); + for (const n of graph.nodes) { + const key = layerKeyOf.get(n.id); + if (key == null) continue; + if (!geo.has(n.id)) continue; + (membersOf.get(key) ?? membersOf.set(key, []).get(key)!).push(n.id); + } + // For each layer key: snap every member to the FIRST member's layer-axis coord, + // then SPREAD them along the perpendicular (cross-layer) axis with a >= + // MIN_SHAPE_GAP gap. Without the spread, a sameLayerAs chain whose nodes ELK + // happened to give the same cross-axis coordinate would collapse onto one point + // -> shape-overlap + edge-through-shape quality warnings (breaking the + // "0 warnings by construction" guarantee for these AUTO-positioned hints). We + // start from the members' minimum cross-axis coord and stack them with a gap + // of MIN_SHAPE_GAP beyond each shape's cross-axis size. + for (const [key, members] of membersOf) { + if (members.length === 0) continue; + // Snap layer-axis coord to the first member. + const repCoord = geo.get(members[0])![layerAxis]; + // Preserve the members' existing relative order along the cross axis so the + // spread stays visually stable, then re-lay them contiguously. + const sorted = [...members].sort( + (a, b) => geo.get(a)![crossAxis] - geo.get(b)![crossAxis], + ); + let cursor = geo.get(sorted[0])![crossAxis]; + for (const id of sorted) { + const g = geo.get(id)!; + g[layerAxis] = repCoord; + g[crossAxis] = cursor; + cursor += g[crossSize] + MIN_SHAPE_GAP; + } + void key; + } + + // pinned: exact override (wins over any layer snap). Explicit user coordinates + // are user intent, but CLAMP to non-negative so an out-of-bounds pin (e.g. + // x:-500) never renders off-canvas. Two user-pinned nodes at the same point is + // user error the server can't silently relocate — the assembler docstring + // documents that explicit pins are user-directed and MAY warn (see #423/#425 + // acceptance: the "0 quality-warnings by construction" guarantee is for + // AUTO-LAYOUT, not for coordinates the user pinned by hand). + for (const n of graph.nodes) { + if (!n.pinned) continue; + const px = Math.max(0, n.pinned.x); + const py = Math.max(0, n.pinned.y); + const g = geo.get(n.id); + if (g) { + g.x = px; + g.y = py; + } else { + const sz = { w: 140, h: 60 }; + geo.set(n.id, { x: px, y: py, w: sz.w, h: sz.h }); + } + } +} + +/** + * Incremental variant of applyHints: apply `pinned` only to NEW nodes (those + * absent from `existing`); an existing node's coordinates are NEVER changed + * (acceptance #3). sameLayerAs/layer snapping is intentionally skipped in the + * incremental path — moving a new node's layer axis could still be desired, but + * it must never move an existing cell, so we keep the incremental contract + * simple: existing cells are frozen, new pinned nodes honour their pin. + */ +export function applyHintsForNew( + graph: Graph, + geo: Map, + existing: Map, +): void { + for (const n of graph.nodes) { + if (existing.has(n.id)) continue; // never move an existing cell + if (!n.pinned) continue; + const px = Math.max(0, n.pinned.x); // clamp out-of-bounds pins non-negative + const py = Math.max(0, n.pinned.y); + const g = geo.get(n.id); + if (g) { + g.x = px; + g.y = py; + } else { + geo.set(n.id, { x: px, y: py, w: 140, h: 60 }); + } + } +} + +// --- layout runner --------------------------------------------------------- + +const ELK_TIMEOUT_MS = 5000; + +/** + * Run ELK over the mapped graph and return computed geometry per id (coords are + * parent-relative, matching mxGraph's convention for container children). On any + * ELK failure/timeout the returned map is empty and the caller falls back to a + * deterministic grid placement (so the write never fails on a layout hiccup). + */ +export async function runElk( + elk: ElkGraph, +): Promise> { + const geo = new Map(); + let timer: ReturnType | undefined; + try { + const Ctor: any = (ELK as any).default ?? ELK; + const inst = new Ctor(); + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("ELK timed out")), ELK_TIMEOUT_MS); + }); + const laid = (await Promise.race([inst.layout(elk as any), timeout])) as ElkGraph; + const walk = (n: ElkNode) => { + if (n.id !== "root") { + geo.set(n.id, { + x: Math.round(n.x ?? 0), + y: Math.round(n.y ?? 0), + w: Math.round(n.width ?? 140), + h: Math.round(n.height ?? 60), + }); + } + for (const c of n.children ?? []) walk(c); + }; + walk(laid); + } catch { + return new Map(); // best-effort: empty -> caller uses fallback grid. + } finally { + if (timer) clearTimeout(timer); + } + return geo; +} + +// --- XML assembler --------------------------------------------------------- + +/** Order groups so a parent group always precedes its nested children. */ +function topoSortGroups(groups: GraphGroup[], groupIds: Set): GraphGroup[] { + const byId = new Map(groups.map((g) => [g.id, g])); + const out: GraphGroup[] = []; + const done = new Set(); + const visit = (g: GraphGroup, stack: Set) => { + if (done.has(g.id)) return; + if (stack.has(g.id)) return; // cycle guard + stack.add(g.id); + if (g.group && groupIds.has(g.group) && g.group !== g.id) { + const parent = byId.get(g.group); + if (parent) visit(parent, stack); + } + stack.delete(g.id); + if (!done.has(g.id)) { + done.add(g.id); + out.push(g); + } + }; + for (const g of groups) visit(g, new Set()); + return out; +} + +function xmlEscapeAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/\r\n|\r|\n/g, " "); // literal newline -> the linter-approved entity +} + +export interface AssembleResult { + modelXml: string; + iconsResolved: number; + iconsMissing: string[]; +} + +/** + * Assemble the final mxGraphModel XML from the graph + resolved styles/coords. + * Guarantees BY CONSTRUCTION that the #423 linter passes: + * - id=0 and id=1(parent=0) sentinels; + * - each node/group is vertex="1" (containers get container=1 via the style); + * - each edge is edge="1" with a child ; + * - group children set parent= and RELATIVE coords; an edge between + * two different parents is parent="1"; + * - labels are XML-escaped and any newline is . + * `geo` may be empty (ELK failed) — then a deterministic grid is used so the + * output is still valid and non-overlapping (>=170px stride). + * + * QUALITY-WARNING GUARANTEE: the "0 quality-warnings by construction" promise + * holds for AUTO-LAYOUT — ELK spacing plus applyHints' cross-axis spread for the + * server-positioned `layer`/`sameLayerAs` hints keep shapes >=MIN_SHAPE_GAP + * apart. It does NOT extend to explicit `pinned` coordinates: those are + * user-directed, so two nodes the user pins to the same/overlapping point are + * user error the server honours verbatim (only clamped non-negative) and MAY + * therefore produce a quality warning. + */ +export function assembleModel( + graph: Graph, + opts: { + preset: PresetData; + styles: Map; + geo: Map; + }, +): AssembleResult { + const { preset, styles, geo } = opts; + const groupIds = new Set((graph.groups ?? []).map((g) => g.id)); + const nodeById = new Map(graph.nodes.map((n) => [n.id, n])); + + // Fallback grid when ELK produced nothing: lay ungrouped nodes on a grid with + // a 190px stride (>150 gap). Grouped nodes/groups are placed inside their group. + const fallback = geo.size === 0; + const gridPos = (i: number) => ({ x: 40 + (i % 5) * 200, y: 40 + Math.floor(i / 5) * 140 }); + + const cells: string[] = ['', '']; + + // Groups first (they are parents of their members). A nested group sets + // parent=; emit parents before children so parent-exists holds. + let gi = 0; + const groupGeo = new Map(); + const orderedGroups = topoSortGroups(graph.groups ?? [], groupIds); + for (const g of orderedGroups) { + const gg = geo.get(g.id) ?? { ...gridPos(gi++), w: 320, h: 220 }; + groupGeo.set(g.id, gg); + const style = groupStyle(preset); + const gParent = g.group && groupIds.has(g.group) && g.group !== g.id ? g.group : "1"; + cells.push( + `` + + ``, + ); + } + + // Nodes. A grouped node's coords are RELATIVE to its group (ELK already + // returns child coords relative to the parent; for the fallback grid we place + // children on a small in-group grid). + let ungrouped = (graph.groups?.length ?? 0); + const inGroupIndex = new Map(); + let iconsResolved = 0; + const iconsMissing: string[] = []; + for (const n of graph.nodes) { + const st = styles.get(n.id)!; + if (n.icon) { + if (st.iconResolved) iconsResolved++; + else iconsMissing.push(n.id); + } + let x: number; + let y: number; + const g = geo.get(n.id); + if (g && !fallback) { + x = g.x; + y = g.y; + } else if (n.group && groupIds.has(n.group)) { + const k = inGroupIndex.get(n.group) ?? 0; + inGroupIndex.set(n.group, k + 1); + x = 30 + (k % 3) * 180; + y = 40 + Math.floor(k / 3) * 120; + } else { + const p = gridPos(ungrouped++); + x = p.x; + y = p.y; + } + const parent = n.group && groupIds.has(n.group) ? n.group : "1"; + cells.push( + `` + + ``, + ); + } + + // Edges. parent="1" whenever the two endpoints have different container + // parents (or either is a group); otherwise the shared group id. + (graph.edges ?? []).forEach((e, i) => { + const style = edgeStyle(preset, e.kind); + const fromNode = nodeById.get(e.from); + const toNode = nodeById.get(e.to); + const fromParent = fromNode?.group && groupIds.has(fromNode.group) ? fromNode.group : "1"; + const toParent = toNode?.group && groupIds.has(toNode.group) ? toNode.group : "1"; + const parent = fromParent === toParent ? fromParent : "1"; + const label = e.label ? ` value="${xmlEscapeAttr(e.label)}"` : ""; + cells.push( + `` + + ``, + ); + }); + + const modelAttrs = + 'dx="0" dy="0" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100" adaptiveColors="auto"'; + const modelXml = `${cells.join("")}`; + return { modelXml, iconsResolved, iconsMissing }; +} + +// --- incremental merge ----------------------------------------------------- + +let _mergeWindow: any = null; +function mergeWindow(): any { + if (!_mergeWindow) _mergeWindow = new JSDOM("").window; + return _mergeWindow; +} + +/** + * Merge the freshly-assembled graph XML with the EXISTING diagram model so an + * incremental "add a node" call never drops a hand-placed cell. `assembleModel` + * emits ONLY the passed graph's cells; on its own it would replace the whole + * model, wiping any existing cell the caller didn't re-list. This splices every + * existing cell that the graph does NOT re-list (preserved verbatim: coords, + * style, edges) into the assembled root: + * - id in the graph -> the graph's (re-laid) cell wins (already assembled; + * coords are frozen for existing ids via the incremental geo path); + * - id NOT in the graph -> the existing cell is preserved verbatim; + * - a graph node absent from the existing model -> added (offset clear). + * The sentinels ("0"/"1") come from the assembled model and are never doubled. + */ +function mergeExistingCells( + assembledXml: string, + existingModelXml: string, + graph: Graph, +): string { + const win = mergeWindow(); + const parser = new win.DOMParser(); + const existingDoc = parser.parseFromString(existingModelXml, "application/xml"); + if (existingDoc.getElementsByTagName("parsererror").length > 0) { + // Existing model unreadable: fall back to the assembled model alone (still a + // valid diagram — better than throwing on a corrupt prior file). + return assembledXml; + } + const assembledDoc = parser.parseFromString(assembledXml, "application/xml"); + const root = assembledDoc.getElementsByTagName("root")[0]; + if (!root) return assembledXml; + + // Ids the assembled model already emitted (graph nodes/groups/edges + sentinels). + const assembledIds = new Set(); + for (const el of Array.from(root.getElementsByTagName("mxCell")) as any[]) { + const id = el.getAttribute("id"); + if (id) assembledIds.add(id); + } + // The graph's own ids: any existing cell with one of these is superseded by the + // assembled version and must NOT be re-imported. + const graphIds = new Set([ + ...graph.nodes.map((n) => n.id), + ...(graph.groups ?? []).map((g) => g.id), + ]); + + const existingCells = Array.from( + existingDoc.getElementsByTagName("mxCell"), + ) as any[]; + for (const el of existingCells) { + const id = el.getAttribute("id") ?? ""; + if (id === "0" || id === "1") continue; // sentinels come from the assembled model + if (graphIds.has(id)) continue; // graph re-lists it -> assembled version wins + if (assembledIds.has(id)) continue; // id collision guard -> keep assembled + root.appendChild(assembledDoc.importNode(el, true)); + assembledIds.add(id); + } + + const ser = new win.XMLSerializer(); + return ser.serializeToString(assembledDoc.documentElement); +} + +// --- top-level: graph -> mxGraphModel XML ---------------------------------- + +export interface BuildFromGraphResult { + modelXml: string; + iconsResolved: number; + iconsMissing: string[]; + layout: LayoutMode; +} + +/** + * The full server-side pipeline: validate -> resolve styles/icons -> map to ELK + * -> run ELK (or fall back) -> assemble linter-clean XML. `existingCoords` is + * supplied for `layout:"incremental"` (the coordinates of the diagram's current + * cells, so they are preserved and only new nodes are placed). `existingModelXml` + * is the current diagram's full model XML — in incremental mode every existing + * cell the graph does NOT re-list is MERGED back in verbatim so a hand-placed + * cell is never dropped (WARNING #4). Pure — no network. + */ +export async function buildFromGraph( + graph: Graph, + layout: LayoutMode = "full", + existingCoords?: Map, + existingModelXml?: string, +): Promise { + validateGraph(graph); + const preset = getPreset(graph.preset); + + // Resolve every node's style + size (icon or generic-by-kind). + const styles = new Map< + string, + { style: string; w: number; h: number; iconResolved: boolean } + >(); + const sizes = new Map(); + for (const n of graph.nodes) { + const s = resolveNodeStyle(preset, n); + styles.set(n.id, s); + sizes.set(n.id, { w: s.w, h: s.h }); + } + // Group sizes: seed a min box; ELK computes the real size when it lays out. + for (const g of graph.groups ?? []) sizes.set(g.id, { w: 240, h: 180 }); + + let geo = new Map(); + + if (layout === "incremental" && existingCoords && existingCoords.size > 0) { + // INCREMENTAL: keep every existing cell's coords VERBATIM (acceptance #3 — + // never move a hand-arranged cell) and lay out ONLY the new nodes, then + // offset that block clear of the existing bbox so nothing overlaps. + for (const [id, c] of existingCoords) { + const sz = sizes.get(id) ?? { w: 140, h: 60 }; + geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h }); + } + const newIds = new Set( + graph.nodes.filter((n) => !existingCoords.has(n.id)).map((n) => n.id), + ); + if (newIds.size > 0) { + const elk = graphToElk(graph, sizes, { only: newIds }); + const laid = await runElk(elk); + // Place the new block below the existing content (a clear >=170px gap). + let maxY = 0; + for (const c of existingCoords.values()) maxY = Math.max(maxY, c.y); + const offsetY = maxY + 200; + for (const [id, g] of laid) { + if (newIds.has(id)) geo.set(id, { ...g, y: g.y + offsetY }); + else if (!geo.has(id)) geo.set(id, g); // a new group container + } + } + // Hints still apply to NEW pinned nodes only (existing ones stay put). + applyHintsForNew(graph, geo, existingCoords); + } else if (layout !== "none") { + const elk = graphToElk(graph, sizes); + geo = await runElk(elk); + applyHints(graph, geo); + } else if (existingCoords) { + // layout:"none" with prior coords -> keep them verbatim. + for (const [id, c] of existingCoords) { + const sz = sizes.get(id) ?? { w: 140, h: 60 }; + geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h }); + } + } + + const assembled = assembleModel(graph, { preset, styles, geo }); + // In incremental mode, splice back every existing cell the graph didn't + // re-list so an "add one node" call preserves the user's manual layout. + let modelXml = assembled.modelXml; + if ( + layout === "incremental" && + existingModelXml && + existingCoords && + existingCoords.size > 0 + ) { + modelXml = mergeExistingCells(modelXml, existingModelXml, graph); + } + return { + modelXml, + iconsResolved: assembled.iconsResolved, + iconsMissing: assembled.iconsMissing, + layout, + }; +} diff --git a/packages/mcp/src/lib/drawio-mermaid.ts b/packages/mcp/src/lib/drawio-mermaid.ts new file mode 100644 index 00000000..1d61947d --- /dev/null +++ b/packages/mcp/src/lib/drawio-mermaid.ts @@ -0,0 +1,347 @@ +// Pure Mermaid `flowchart` -> graph-JSON parser for `drawioFromMermaid` (issue +// #425, stage 3, OPTIONAL). The escape clause in the issue: convert WITHOUT +// Electron/draw.io-CLI, so a pure text parser only. It handles the common wiki +// flowchart subset — node shapes, labelled/dashed edges, subgraphs (-> groups), +// and the direction header — and emits a Graph the drawioFromGraph pipeline +// renders as an EDITABLE draw.io diagram. Anything beyond flowchart (sequence / +// class / state) throws a clear error so the model falls back to drawioFromGraph. +// +// DELIBERATELY NARROW: this is not a full Mermaid grammar (Mermaid's own parser +// is a 100KB+ browser dependency). It covers `flowchart`/`graph` with the node +// shapes and edge arrows that show up in practice; unusual syntax is skipped +// rather than mis-parsed, and a diagram that yields no nodes throws. + +import type { Graph, GraphNode, GraphEdge, GraphGroup } from "./drawio-graph.js"; + +export class MermaidParseError extends Error { + constructor(message: string) { + super(`drawioFromMermaid: ${message}`); + this.name = "MermaidParseError"; + } +} + +// Input-size bounds applied BEFORE parsing. Without them a pathological mermaid +// string (e.g. 300000 connection lines, or 20000 nested `subgraph`s) builds a +// huge intermediate node/edge/group structure that OOM-crashes the worker — the +// downstream validateGraph caps in drawio-graph can't help because the parser +// exhausts the heap constructing the intermediate FIRST. These caps reject the +// over-limit input fast, before a single line is parsed. +const MAX_MERMAID_CHARS = 200_000; // ~200 KB of source is far beyond any real diagram. +const MAX_MERMAID_LINES = 20_000; +const MAX_MERMAID_GROUPS = 500; // parity with drawio-graph's MAX_GRAPH_GROUPS. +// Per connection line, the number of chained nodes we will expand (`A-->B-->C`). +const MAX_CHAIN_NODES = 500; + +const DIRECTIONS: Record = { + LR: "LR", + RL: "RL", + TB: "TB", + TD: "TB", + BT: "BT", +}; + +/** + * Node-shape delimiters -> a semantic `kind`. Mermaid encodes shape in the + * bracket style; we map the common ones to the palette kinds so the diagram is + * colored meaningfully (a decision/diamond -> queue, a database cylinder -> db, + * a rounded/stadium -> service, a subroutine/hexagon -> gateway, default rect -> + * service). The label text lives between the delimiters. + */ +interface ShapeDef { + open: string; + close: string; + kind: string; +} +// Order matters: longer/multi-char delimiters first so "([" beats "(". +const SHAPES: ShapeDef[] = [ + { open: "([", close: "])", kind: "service" }, // stadium + { open: "[[", close: "]]", kind: "gateway" }, // subroutine + { open: "[(", close: ")]", kind: "db" }, // cylinder-ish / database + { open: "((", close: "))", kind: "external" }, // circle + { open: "{{", close: "}}", kind: "gateway" }, // hexagon + { open: "[", close: "]", kind: "service" }, // rectangle + { open: "(", close: ")", kind: "service" }, // rounded + { open: "{", close: "}", kind: "queue" }, // rhombus / decision + { open: ">", close: "]", kind: "external" }, // asymmetric flag +]; + +/** Strip Mermaid label quoting/escapes and normalise whitespace. */ +function cleanLabel(raw: string): string { + let s = raw.trim(); + if ( + (s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'")) + ) { + s = s.slice(1, -1); + } + return s.replace(//gi, " ").replace(/\s+/g, " ").trim(); +} + +/** A single edge-arrow spec: its regex and the resulting edge `kind`. */ +interface ArrowDef { + re: RegExp; + kind: string; +} +// Dotted arrows (`-.->`) -> async; thick (`==>`) stay sync; normal `-->`/`---`. +// Each captures an optional `|label|` OR inline label between the two arrow +// halves. Applied to the segment between two node tokens. +const ARROWS: ArrowDef[] = [ + { re: /-\.->|-\.-/, kind: "async" }, + { re: /==>|===/, kind: "sync" }, + { re: /-->|---/, kind: "sync" }, +]; + +interface ParsedRef { + id: string; + node?: GraphNode; +} + +/** + * Parse a single node token like `A`, `A[Label]`, `db[(Orders)]`, `d{Choose}`. + * Returns the id and, when the token declares a shape/label, a GraphNode. + */ +function parseNodeToken(token: string): ParsedRef | null { + const t = token.trim(); + if (t === "") return null; + for (const shape of SHAPES) { + const oi = t.indexOf(shape.open); + if (oi <= 0) continue; + if (!t.endsWith(shape.close)) continue; + const id = t.slice(0, oi).trim(); + const label = cleanLabel(t.slice(oi + shape.open.length, t.length - shape.close.length)); + if (!id) return null; + return { id, node: { id, label: label || id, kind: shape.kind } }; + } + // Bare id (no shape declared here — may be defined elsewhere). + if (/^[A-Za-z0-9_.-]+$/.test(t)) return { id: t }; + return null; +} + +/** + * Split a connection line into [leftToken, arrowSegment, rightToken]. Returns + * null if the line has no arrow. The arrow segment may embed a label as + * `-->|text|` or `-- text -->`. + */ +function splitConnection( + line: string, +): { left: string; right: string; kind: string; label?: string } | null { + for (const arrow of ARROWS) { + // Find the arrow occurrence. Support a mid-arrow label: `A -- text --> B`. + const m = arrow.re.exec(line); + if (!m) continue; + const idx = m.index; + let left = line.slice(0, idx).trim(); + let rest = line.slice(idx + m[0].length).trim(); + let label: string | undefined; + // Pipe label: `-->|HTTPS| B`. + const pipe = /^\|([^|]*)\|\s*(.*)$/.exec(rest); + if (pipe) { + label = cleanLabel(pipe[1]); + rest = pipe[2].trim(); + } + // Mid-arrow label on the left side: `A -- text` before the arrow half. + const midLeft = /^(.*?)\s*--\s*(.+)$/.exec(left); + if (!label && midLeft && /-\.|--|==/.test(line.slice(0, idx))) { + // Only treat as a label when there's clearly text after `--`. + if (!/[\[\](){}]/.test(midLeft[2])) { + left = midLeft[1].trim(); + label = cleanLabel(midLeft[2]); + } + } + if (!left || !rest) return null; + return { left, right: rest, kind: arrow.kind, label }; + } + return null; +} + +/** + * Parse Mermaid flowchart text into a Graph. Handles the header + * (`flowchart LR` / `graph TD`), `subgraph [title] … end` blocks (-> groups), + * node declarations, and connection lines. Throws MermaidParseError for a + * non-flowchart diagram or when nothing parses. + */ +export function mermaidToGraph(mermaid: string): Graph { + if (typeof mermaid !== "string" || mermaid.trim() === "") { + throw new MermaidParseError("empty mermaid input"); + } + // Size guards FIRST — bound the raw input before building any intermediate. + if (mermaid.length > MAX_MERMAID_CHARS) { + throw new MermaidParseError( + `input is ${mermaid.length} chars (max ${MAX_MERMAID_CHARS}); split the diagram or use drawioFromGraph`, + ); + } + const rawLines = mermaid.split(/\r?\n/); + if (rawLines.length > MAX_MERMAID_LINES) { + throw new MermaidParseError( + `input has ${rawLines.length} lines (max ${MAX_MERMAID_LINES}); split the diagram or use drawioFromGraph`, + ); + } + const nodes = new Map(); + const groups: GraphGroup[] = []; + const edges: GraphEdge[] = []; + let direction: Graph["direction"] = "LR"; + let sawHeader = false; + + // Stack of active subgraph ids (nesting); the top is the current group. + const groupStack: string[] = []; + let anonGroup = 0; + + const ensureNode = (ref: ParsedRef) => { + const existing = nodes.get(ref.id); + if (ref.node) { + if (existing) { + // Fill in a label/kind if this token declared a shape and the prior didn't. + if (existing.label === existing.id && ref.node.label !== ref.node.id) + existing.label = ref.node.label; + if (!existing.kind) existing.kind = ref.node.kind; + } else { + nodes.set(ref.id, { ...ref.node }); + } + } else if (!existing) { + nodes.set(ref.id, { id: ref.id, label: ref.id, kind: "service" }); + } + // Assign to the current subgraph if inside one and not yet grouped. + const cur = groupStack[groupStack.length - 1]; + const n = nodes.get(ref.id)!; + if (cur && n.group == null) n.group = cur; + }; + + for (const raw of rawLines) { + let line = raw.trim(); + if (line === "" || line.startsWith("%%")) continue; // blank / comment + + // Header. + const header = /^(flowchart|graph)\s+([A-Za-z]{2})\b/.exec(line); + if (header) { + sawHeader = true; + const dir = DIRECTIONS[header[2].toUpperCase()]; + if (dir) direction = dir; + continue; + } + if (/^(sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|journey)\b/.test(line)) { + throw new MermaidParseError( + `only 'flowchart'/'graph' is supported (got '${line.split(/\s+/)[0]}'); use drawioFromGraph instead`, + ); + } + + // Subgraph open: `subgraph id [Title]` or `subgraph Title`. + const sg = /^subgraph\s+(.+)$/.exec(line); + if (sg) { + const spec = sg[1].trim(); + let id: string; + let label: string; + const bracket = /^([A-Za-z0-9_.-]+)\s*\[(.+)\]$/.exec(spec); + if (bracket) { + id = bracket[1]; + label = cleanLabel(bracket[2]); + } else if (/^[A-Za-z0-9_.-]+$/.test(spec)) { + id = spec; + label = spec; + } else { + id = `sg${anonGroup++}`; + label = cleanLabel(spec); + } + if (!groups.some((g) => g.id === id)) { + if (groups.length >= MAX_MERMAID_GROUPS) { + throw new MermaidParseError( + `too many subgraphs (max ${MAX_MERMAID_GROUPS}); use drawioFromGraph for a diagram this large`, + ); + } + groups.push({ id, label, kind: "group" }); + } + groupStack.push(id); + continue; + } + if (/^end\b/.test(line)) { + groupStack.pop(); + continue; + } + // `direction LR` inside a subgraph — apply to the top-level direction. + const innerDir = /^direction\s+([A-Za-z]{2})\b/.exec(line); + if (innerDir) { + const dir = DIRECTIONS[innerDir[1].toUpperCase()]; + if (dir) direction = dir; + continue; + } + // Style/class/click directives: ignore (no visual mapping in our palette). + if (/^(style|classDef|class|click|linkStyle)\b/.test(line)) continue; + + // Strip a trailing semicolon. + if (line.endsWith(";")) line = line.slice(0, -1).trim(); + + // Connection line (possibly chained: A --> B --> C). + const conn = splitConnection(line); + if (conn) { + // Handle a simple chain by re-splitting the right side. + let leftTok = conn.left; + let seg: typeof conn | null = conn; + let guard = 0; + while (seg) { + if (guard++ >= MAX_CHAIN_NODES) { + // Don't silently drop the tail of an over-long chain — surface it so + // the model knows the diagram was too large rather than getting a + // quietly-truncated result. + throw new MermaidParseError( + `a single connection chain exceeds ${MAX_CHAIN_NODES} nodes; split it or use drawioFromGraph`, + ); + } + const leftRef = parseNodeToken(leftTok); + // The right side may itself contain another arrow (a chain). + const nextSeg = splitConnection(seg.right); + const rightTokenStr = nextSeg ? seg.right.slice(0, splitIndex(seg.right)) : seg.right; + const rightRef = parseNodeToken(nextSeg ? nextSeg.left : seg.right); + if (leftRef && rightRef) { + ensureNode(leftRef); + ensureNode(rightRef); + edges.push({ + from: leftRef.id, + to: rightRef.id, + label: seg.label, + kind: seg.kind, + }); + } + if (!nextSeg) break; + leftTok = nextSeg.left; + seg = nextSeg; + void rightTokenStr; + } + continue; + } + + // Standalone node declaration `A[Label]` OR a bare member ref `C` inside a + // subgraph (which claims that node for the current group). + const nodeRef = parseNodeToken(line); + if (nodeRef && (nodeRef.node || groupStack.length > 0)) { + ensureNode(nodeRef); + continue; + } + // Unknown line: skip silently (robustness over strictness). + } + + if (!sawHeader && nodes.size === 0) { + throw new MermaidParseError( + "input does not look like a mermaid flowchart (no 'flowchart'/'graph' header and no nodes)", + ); + } + if (nodes.size === 0) { + throw new MermaidParseError("no nodes parsed from the flowchart"); + } + + const graph: Graph = { + nodes: Array.from(nodes.values()), + direction, + }; + if (groups.length > 0) graph.groups = groups; + if (edges.length > 0) graph.edges = edges; + return graph; +} + +/** Index of the first arrow in a segment (for chain splitting). */ +function splitIndex(s: string): number { + let best = -1; + for (const arrow of ARROWS) { + const m = arrow.re.exec(s); + if (m && (best === -1 || m.index < best)) best = m.index; + } + return best === -1 ? s.length : best; +} diff --git a/packages/mcp/src/lib/drawio-presets.ts b/packages/mcp/src/lib/drawio-presets.ts new file mode 100644 index 00000000..c95068d8 --- /dev/null +++ b/packages/mcp/src/lib/drawio-presets.ts @@ -0,0 +1,151 @@ +// Semantic color/line presets for the graph tools (issue #425, stage 3). The +// PALETTE is DATA (packages/mcp/data/drawio-presets.json), not code: a node +// `kind` maps to a { fillColor, strokeColor, fontColor } slot and an edge `kind` +// maps to line-style props, per named preset (`default` / `dark` / +// `colorblind-safe`). This module only loads that data and turns a slot into a +// draw.io style fragment. The INVARIANT of the graph tools is that the model +// never sees a style string — it names a `kind`, the server picks the slot. +// +// Loading mirrors drawio-shapes.ts: the JSON is read once via `import.meta.url` +// relative to the built module. That is why this module (and drawio-graph.ts +// which imports it) is reached ONLY through client.ts's ESM build and never +// value-imported into the zod-agnostic tool-specs.ts (which the in-app server +// type-checks under module:commonjs, where `import.meta` is a TS1343 error). + +import { readFileSync } from "node:fs"; + +/** A node color slot: the three draw.io color values for a `kind`. */ +export interface NodeSlot { + fillColor: string; + strokeColor: string; + fontColor: string; +} + +/** An edge line style: the extra style props appended for an edge `kind`. */ +export interface EdgeStyle { + props: string; +} + +export interface PresetData { + canvasDark: boolean; + okabeIto?: string[]; + nodes: Record; + edges: Record; + edgeDefault: { strokeColor: string; fontColor: string }; + group: { strokeColor: string; fontColor: string }; +} + +interface PresetsFile { + presets: Record; +} + +/** The three shipped preset names. */ +export const PRESET_NAMES = ["default", "dark", "colorblind-safe"] as const; +export type PresetName = (typeof PRESET_NAMES)[number]; + +/** Every node `kind` the base palette defines (also the generic-shape kinds). */ +export const NODE_KINDS = [ + "service", + "db", + "queue", + "gateway", + "error", + "external", + "security", +] as const; +export type NodeKind = (typeof NODE_KINDS)[number]; + +/** Edge `kind`s the palette styles; anything else falls back to `sync`. */ +export const EDGE_KINDS = ["sync", "async", "error"] as const; +export type EdgeKind = (typeof EDGE_KINDS)[number]; + +let _presets: Record | null = null; + +function presetsPath(): URL { + // build/lib/drawio-presets.js -> ../../data/drawio-presets.json + return new URL("../../data/drawio-presets.json", import.meta.url); +} + +/** Load + parse the bundled preset table once, then cache it. */ +export function loadPresets(): Record { + if (_presets) return _presets; + const json = readFileSync(presetsPath(), "utf-8"); + const parsed = JSON.parse(json) as PresetsFile; + _presets = parsed.presets; + return _presets; +} + +/** Resolve a preset by name, defaulting to `default` for an unknown name. */ +export function getPreset(name?: string): PresetData { + const presets = loadPresets(); + if (name && presets[name]) return presets[name]; + return presets["default"]; +} + +/** The slot for a node `kind` in a preset, falling back to `service`. */ +export function nodeSlot(preset: PresetData, kind?: string): NodeSlot { + if (kind && preset.nodes[kind]) return preset.nodes[kind]; + return preset.nodes["service"]; +} + +/** + * Build the draw.io style string for a GENERIC (no-icon) node of a given kind. + * A rounded rectangle carrying the slot's fill/stroke/font. `whiteSpace=wrap` + * and `html=1` let a long label wrap inside the shape (the assembler also sizes + * the shape to the label, so the linter's label-overflow warning never fires). + */ +export function genericNodeStyle(preset: PresetData, kind?: string): string { + const s = nodeSlot(preset, kind); + return ( + `rounded=1;whiteSpace=wrap;html=1;` + + `fillColor=${s.fillColor};strokeColor=${s.strokeColor};fontColor=${s.fontColor};` + ); +} + +/** + * Overlay the preset's node slot colors onto a resolved ICON style-string + * (from the shape catalog). An AWS/Azure icon carries its OWN mandatory + * fill/stroke (the category color / white outline) that MUST NOT be recolored, + * so for an icon we only ensure a readable fontColor when the preset is dark; + * otherwise the icon style is returned verbatim. Keeping the icon's own colors + * is deliberate: recoloring an AWS service icon breaks its category semantics. + */ +export function iconNodeStyle(preset: PresetData, iconStyle: string): string { + if (!preset.canvasDark) return iconStyle; + // On a dark canvas an icon's fontColor is usually a dark ink that vanishes; + // append a light fontColor (icons put their label BELOW the glyph, so this + // only affects the caption, never the glyph fill). + if (/fontColor=/.test(iconStyle)) { + return iconStyle.replace(/fontColor=[^;]*/, "fontColor=#e0e0e0"); + } + return iconStyle + (iconStyle.endsWith(";") ? "" : ";") + "fontColor=#e0e0e0;"; +} + +/** + * Build the draw.io style for an edge of a given `kind`. Base is an orthogonal + * connector (edgeStyle=orthogonalEdgeStyle) with rounded corners and an open + * arrowhead, plus the preset's default stroke/font, then the kind's extra props + * (dashed / colored) overlaid. An unknown kind falls back to `sync` (solid). + */ +export function edgeStyle(preset: PresetData, kind?: string): string { + const k = kind && preset.edges[kind] ? kind : "sync"; + const base = + `edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;endArrow=open;` + + `strokeColor=${preset.edgeDefault.strokeColor};fontColor=${preset.edgeDefault.fontColor};`; + return base + preset.edges[k].props; +} + +/** + * Group (container) style: ALWAYS transparent (`fillColor=none;container=1;`) + * per the spec, carrying the preset's group stroke/font. `dropTarget=1` marks it + * a drop target in the editor; `verticalAlign=top;align=left;spacingLeft=8;` puts + * the group label in the top-left like draw.io's own boundary containers. + */ +export function groupStyle(preset: PresetData): string { + return ( + `rounded=0;whiteSpace=wrap;html=1;` + + `fillColor=none;container=1;dropTarget=1;collapsible=0;` + + `strokeColor=${preset.group.strokeColor};fontColor=${preset.group.fontColor};` + + `verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;` + ); +} diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index e7ec58ed..5eeb130e 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -41,7 +41,7 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; export const ROUTING_PROSE = "Docmost editing guide — choose the tool by intent. The at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" + "READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" + - "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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.\n" + + "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: 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.\n" + "PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" + "HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown."; @@ -109,6 +109,9 @@ const TOOL_FAMILY: Record = { drawioGet: "EDIT", drawioCreate: "EDIT", drawioUpdate: "EDIT", + drawioEditCells: "EDIT", + drawioFromGraph: "EDIT", + drawioFromMermaid: "EDIT", drawioShapes: "EDIT", drawioGuide: "EDIT", docmostTransform: "EDIT", diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index fec7e9ab..44534ed1 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -100,6 +100,9 @@ export type DocmostClientLike = Pick< | 'drawioGet' | 'drawioCreate' | 'drawioUpdate' + | 'drawioEditCells' + | 'drawioFromGraph' + | 'drawioFromMermaid' | 'createComment' | 'resolveComment' >; @@ -2013,6 +2016,234 @@ export const SHARED_TOOL_SPECS = { ), }, + drawioEditCells: { + mcpName: 'drawioEditCells', + inAppKey: 'drawioEditCells', + description: + 'Make TARGETED, id-based edits to an existing draw.io diagram instead of ' + + 'resending the whole XML (a full-XML diff is fragile — draw.io reorders ' + + 'attributes). `operations` is an ordered list of: ' + + '{ op:"add", xml:"" } (append a new cell), ' + + '{ op:"update", cellId:"n3", xml:"" } (replace that ' + + 'cell; the id MUST stay the same), or { op:"delete", cellId:"n5" } — a ' + + 'delete CASCADES to the cell\'s container children AND to every edge whose ' + + 'source/target is deleted. Ids are STABLE across edits so diffs stay ' + + 'meaningful. `baseHash` is MANDATORY: pass the hash from the drawioGet you ' + + 'based the edit on; if the diagram changed since, the edit is refused with ' + + 'a conflict error — re-read with drawioGet and retry. The edited model goes ' + + 'through the same lint + quality-warning pipeline as drawioUpdate. `node` is ' + + 'the drawio node attrs.id or "#". Use this to tweak a diagram (move ' + + 'or restyle a few cells, add/remove nodes); to (re)generate a whole diagram ' + + 'from a description use drawioFromGraph.' + + DRAWIO_HARD_RULES, + tier: 'deferred', + catalogLine: + 'drawioEditCells — id-based add/update/delete edits to a draw.io diagram (cascade delete).', + buildShape: (z) => ({ + pageId: z.string().min(1), + node: z + .string() + .min(1) + .describe('The drawio node attrs.id, or "#" for a top-level block.'), + operations: z + .array( + z.object({ + op: z.enum(['add', 'update', 'delete']), + cellId: z + .string() + .optional() + .describe('Target cell id (required for update/delete).'), + xml: z + .string() + .optional() + .describe('The element (required for add/update).'), + }), + ) + .describe('Ordered add/update/delete operations keyed by cell id.'), + baseHash: z + .string() + .min(1) + .describe('The meta.hash from the drawioGet this edit is based on.'), + }), + execute: (client, { pageId, node, operations, baseHash }) => + client.drawioEditCells( + pageId as string, + node as string, + operations as any, + baseHash as string, + ), + }, + + drawioFromGraph: { + mcpName: 'drawioFromGraph', + inAppKey: 'drawioFromGraph', + description: + 'Build a draw.io diagram from a SEMANTIC graph — you describe nodes, groups ' + + 'and edges by MEANING and the server picks every coordinate, color and icon ' + + 'so the whole class of layout/icon mistakes (overlaps, edges through shapes, ' + + 'empty-box stencils) cannot happen. This is the PREFERRED tool for ' + + 'architecture / cloud / network diagrams. `graph` = { nodes:[{ id, label, ' + + 'kind?, icon?, group?, layer?, sameLayerAs?, pinned? }], groups?:[{ id, ' + + 'label, kind? }], edges?:[{ from, to, label?, kind? }] }. Node `kind` picks ' + + 'a palette color (service/db/queue/gateway/error/external/security); `icon` ' + + '(e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos") resolves to the exact ' + + 'verified stencil — an unknown icon degrades to a labelled generic shape, ' + + 'never an empty box. Edge `kind` sets the line style (sync=solid, ' + + 'async=dashed, error=red-dashed). Groups are TRANSPARENT containers. ' + + '`direction` (LR/RL/TB/BT) and `preset` (default/dark/colorblind-safe) tune ' + + 'the layout/palette. Layout hints: `layer` (column index), `sameLayerAs` ' + + '(align two nodes), `pinned:{x,y}` (fix a node). `layout`: "full" (default, ' + + 'auto-place everything), "incremental" (with `node`: keep the existing ' + + 'diagram\'s coordinates, place only new cells), "none" (no auto-layout). The ' + + 'result reports { iconsResolved, iconsMissing } so you can verify all icons ' + + 'resolved. For standard flowcharts you can also write Mermaid and call ' + + 'drawioFromMermaid; for exotic/wireframe diagrams use raw XML via drawioCreate.', + tier: 'deferred', + catalogLine: + 'drawioFromGraph — build a draw.io diagram from a semantic node/group/edge graph (server picks layout+icons).', + buildShape: (z) => { + const node = z.object({ + id: z.string().min(1), + label: z.string().min(1), + kind: z + .string() + .optional() + .describe( + 'Palette slot: service/db/queue/gateway/error/external/security.', + ), + icon: z + .string() + .optional() + .describe('Icon ref, e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos".'), + group: z.string().optional().describe('Id of the group (container) it sits in.'), + layer: z.number().optional().describe('Layer/column index hint (>=0).'), + sameLayerAs: z + .string() + .optional() + .describe('Put this node in the same layer as another node id.'), + pinned: z + .object({ x: z.number(), y: z.number() }) + .optional() + .describe('Fix the node at these exact coordinates.'), + }); + const group = z.object({ + id: z.string().min(1), + label: z.string().min(1), + kind: z.string().optional(), + }); + const edge = z.object({ + from: z.string().min(1), + to: z.string().min(1), + label: z.string().optional(), + kind: z + .string() + .optional() + .describe('sync (solid), async (dashed), error (red-dashed).'), + }); + return { + pageId: z.string().min(1), + graph: z + .object({ + nodes: z.array(node), + groups: z.array(group).optional(), + edges: z.array(edge).optional(), + direction: z.enum(['LR', 'RL', 'TB', 'BT']).optional(), + preset: z.enum(['default', 'dark', 'colorblind-safe']).optional(), + }) + .describe('The semantic graph: nodes, groups, edges.'), + position: z + .enum(['before', 'after', 'append']) + .describe('Where to insert relative to the anchor.'), + anchorNodeId: z.string().optional().describe('Anchor block id (for before/after).'), + anchorText: z.string().optional().describe('Anchor text fragment (for before/after).'), + direction: z + .enum(['LR', 'RL', 'TB', 'BT']) + .optional() + .describe('Layout direction (overrides graph.direction).'), + preset: z + .enum(['default', 'dark', 'colorblind-safe']) + .optional() + .describe('Color preset (overrides graph.preset).'), + layout: z + .enum(['none', 'full', 'incremental']) + .optional() + .describe( + '"full" (default) auto-places all; "incremental" (with node) keeps ' + + 'existing coords and places only new cells; "none" no auto-layout.', + ), + node: z + .string() + .optional() + .describe( + 'An existing diagram to (re)build into — required for layout:"incremental".', + ), + }; + }, + execute: ( + client, + { pageId, graph, position, anchorNodeId, anchorText, direction, preset, layout, node }, + ) => + client.drawioFromGraph( + pageId as string, + { + position: position as 'before' | 'after' | 'append', + anchorNodeId: anchorNodeId as string | undefined, + anchorText: anchorText as string | undefined, + }, + graph as any, + direction as 'LR' | 'RL' | 'TB' | 'BT' | undefined, + preset as string | undefined, + layout as 'none' | 'full' | 'incremental' | undefined, + node as string | undefined, + ), + }, + + drawioFromMermaid: { + mcpName: 'drawioFromMermaid', + inAppKey: 'drawioFromMermaid', + description: + 'Convert Mermaid `flowchart` text into an EDITABLE draw.io diagram (LLMs ' + + 'write Mermaid reliably). Best for STANDARD flowcharts/decision trees: ' + + 'write the mermaid, the server parses it (pure parser — no browser/CLI), ' + + 'maps it to the same semantic pipeline as drawioFromGraph, and inserts a ' + + 'real draw.io diagram you can then refine with drawioEditCells. Node shapes ' + + 'map to palette colors (a `{decision}` -> yellow, a `[(db)]` -> green, etc.); ' + + '`subgraph … end` becomes a transparent group; dotted `-.->` edges become ' + + 'dashed. ONLY flowchart/graph is supported — for sequence/class diagrams, or ' + + 'for cloud/architecture diagrams with real service icons, use drawioFromGraph ' + + 'instead. `where` positions the block like insertNode.', + tier: 'deferred', + catalogLine: + 'drawioFromMermaid — turn Mermaid flowchart text into an editable draw.io diagram.', + buildShape: (z) => ({ + pageId: z.string().min(1), + mermaid: z + .string() + .min(1) + .describe('Mermaid flowchart source (flowchart/graph LR|TB|...).'), + position: z + .enum(['before', 'after', 'append']) + .describe('Where to insert relative to the anchor.'), + anchorNodeId: z.string().optional().describe('Anchor block id (for before/after).'), + anchorText: z.string().optional().describe('Anchor text fragment (for before/after).'), + preset: z + .enum(['default', 'dark', 'colorblind-safe']) + .optional() + .describe('Color preset.'), + }), + execute: (client, { pageId, mermaid, position, anchorNodeId, anchorText, preset }) => + client.drawioFromMermaid( + pageId as string, + { + position: position as 'before' | 'after' | 'append', + anchorNodeId: anchorNodeId as string | undefined, + anchorText: anchorText as string | undefined, + }, + mermaid as string, + preset as string | undefined, + ), + }, + drawioShapes: { mcpName: 'drawioShapes', inAppKey: 'drawioShapes', diff --git a/packages/mcp/test/mock/drawio-graph-tools.test.mjs b/packages/mcp/test/mock/drawio-graph-tools.test.mjs new file mode 100644 index 00000000..e4f05f25 --- /dev/null +++ b/packages/mcp/test/mock/drawio-graph-tools.test.mjs @@ -0,0 +1,272 @@ +// Contract tests for the stage-3 drawio client methods (issue #425): +// drawioEditCells / drawioFromGraph / drawioFromMermaid. Same seam-override +// pattern as drawio-tools.test.mjs: a DocmostClient subclass stubs the I/O seams +// so the tool logic runs without a live Docmost / collab socket. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { DocmostClient } from "../../build/client.js"; +import { + buildDrawioSvg, + normalizeXml, + mxHash, + decodeDrawioSvg, + parseCells, +} from "../../build/lib/drawio-xml.js"; + +const DRAWIO_SCHEMA_ATTRS = new Set([ + "src", "title", "alt", "width", "height", "size", "aspectRatio", "align", "attachmentId", +]); +function applyDrawioSchemaDrop(node) { + if (!node || typeof node !== "object") return; + if (node.type === "drawio" && node.attrs && typeof node.attrs === "object") { + for (const key of Object.keys(node.attrs)) + if (!DRAWIO_SCHEMA_ATTRS.has(key)) delete node.attrs[key]; + } + if (Array.isArray(node.content)) for (const c of node.content) applyDrawioSchemaDrop(c); +} + +function svgFor(model, bbox = { width: 400, height: 300 }) { + return buildDrawioSvg(normalizeXml(model), "", bbox); +} + +function makeClient({ pageDoc, attachmentSvg } = {}) { + const calls = { uploads: [], mutations: [] }; + class TestClient extends DocmostClient { + async ensureAuthenticated() {} + async getCollabTokenWithReauth() { + return "collab-token"; + } + async resolvePageId(pageId) { + return `uuid-${pageId}`; + } + async getPageRaw(pageId) { + return { + id: pageId, slugId: "s", title: "P", spaceId: "sp", + content: pageDoc ?? { type: "doc", content: [] }, + }; + } + async uploadAttachmentBuffer(pageId, buffer, fileName) { + const id = `att-${calls.uploads.length + 1}`; + calls.uploads.push({ pageId, fileName, svg: buffer.toString("utf-8") }); + return { id, fileName, fileSize: buffer.length }; + } + async fetchAttachmentText() { + return attachmentSvg; + } + mutatePage(pageId, token, apiUrl, transform) { + const clone = structuredClone(pageDoc ?? { type: "doc", content: [] }); + const doc = transform(clone); + if (doc) applyDrawioSchemaDrop(doc); + calls.mutations.push({ pageId, doc }); + return Promise.resolve({ doc, verify: { changed: doc != null } }); + } + } + const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw"); + return { client, calls }; +} + +function findDrawio(node, acc = []) { + if (!node || typeof node !== "object") return acc; + if (node.type === "drawio") acc.push(node); + if (Array.isArray(node.content)) for (const c of node.content) findDrawio(c, acc); + return acc; +} + +// A stored diagram: a group with two children and an edge. +const STORED = + "" + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ""; + +function drawioPageDoc() { + return { + type: "doc", + content: [ + { + type: "drawio", + attrs: { + id: "d1", src: "/api/files/att-1/diagram.drawio.svg", + attachmentId: "att-1", width: 400, height: 300, + }, + }, + ], + }; +} + +// --- drawioEditCells -------------------------------------------------------- + +test("drawioEditCells: applies ops and repoints the node (current baseHash)", async () => { + const { client, calls } = makeClient({ + pageDoc: drawioPageDoc(), + attachmentSvg: svgFor(STORED), + }); + const baseHash = mxHash(normalizeXml(STORED)); + const res = await client.drawioEditCells( + "page1", + "d1", + [ + { + op: "update", + cellId: "a", + xml: + '' + + '', + }, + ], + baseHash, + ); + assert.equal(res.success, true); + assert.equal(calls.uploads.length, 1); + const written = decodeDrawioSvg(calls.uploads[0].svg); + const cells = parseCells(written); + assert.equal(cells.find((c) => c.id === "a").value, "Renamed"); + assert.equal(cells.find((c) => c.id === "b").value, "B"); // untouched + const n = findDrawio(calls.mutations[0].doc)[0]; + // The stub numbers uploads from 1; this edit is the first upload -> att-1. + assert.equal(n.attrs.attachmentId, "att-1"); +}); + +test("drawioEditCells: delete of the container cascades to children + edge", async () => { + const { client, calls } = makeClient({ + pageDoc: drawioPageDoc(), + attachmentSvg: svgFor(STORED), + }); + const baseHash = mxHash(normalizeXml(STORED)); + await client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "grp" }], baseHash); + const written = decodeDrawioSvg(calls.uploads[0].svg); + const ids = parseCells(written).filter((c) => c.id !== "0" && c.id !== "1").map((c) => c.id); + assert.deepEqual(ids, [], "grp + a + b + edge all cascaded away"); +}); + +test("drawioEditCells: stale baseHash -> conflict, no upload", async () => { + const { client, calls } = makeClient({ + pageDoc: drawioPageDoc(), + attachmentSvg: svgFor(STORED), + }); + await assert.rejects( + () => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], "stale"), + /conflict/, + ); + assert.equal(calls.uploads.length, 0); +}); + +test("drawioEditCells: baseHash is mandatory", async () => { + const { client } = makeClient({ pageDoc: drawioPageDoc(), attachmentSvg: svgFor(STORED) }); + await assert.rejects( + () => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], ""), + /baseHash is mandatory/, + ); +}); + +// --- drawioFromGraph -------------------------------------------------------- + +test("drawioFromGraph: builds a diagram from a graph and inserts a node", async () => { + const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }] }; + const { client, calls } = makeClient({ pageDoc }); + const res = await client.drawioFromGraph( + "page1", + { position: "append" }, + { + nodes: [ + { id: "api", label: "API", kind: "gateway", icon: "aws:api_gateway", group: "vpc" }, + { id: "fn", label: "Handler", kind: "service", icon: "aws:lambda", group: "vpc" }, + { id: "db", label: "Orders", kind: "db", icon: "aws:dynamodb" }, + ], + groups: [{ id: "vpc", label: "VPC" }], + edges: [{ from: "api", to: "fn", kind: "sync" }, { from: "fn", to: "db", kind: "async" }], + }, + "LR", + "default", + ); + assert.equal(res.success, true); + assert.equal(res.nodeId, "#1"); + assert.equal(res.iconsMissing.length, 0, `unresolved: ${res.iconsMissing}`); + assert.equal(res.iconsResolved, 3); + // The uploaded model decodes back and carries the group + nodes. + const written = decodeDrawioSvg(calls.uploads[0].svg); + const cells = parseCells(written); + assert.ok(cells.some((c) => c.id === "vpc")); + assert.ok(cells.some((c) => c.id === "api")); + // Group is transparent. + const vpc = cells.find((c) => c.id === "vpc"); + assert.equal(vpc.styleMap.fillColor, "none"); + assert.equal(vpc.styleMap.container, "1"); +}); + +test("drawioFromGraph: an invalid graph throws before any upload", async () => { + const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } }); + await assert.rejects( + () => client.drawioFromGraph("page1", { position: "append" }, { nodes: [] }), + /non-empty/, + ); + assert.equal(calls.uploads.length, 0); +}); + +test("drawioFromGraph incremental into an existing node keeps prior coords", async () => { + // The stored diagram has a,b at known coords; add a new node c incrementally. + const { client, calls } = makeClient({ + pageDoc: drawioPageDoc(), + attachmentSvg: svgFor(STORED), + }); + const res = await client.drawioFromGraph( + "page1", + { position: "append" }, + { + nodes: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "c", label: "C new" }, + ], + edges: [{ from: "b", to: "c" }], + }, + undefined, + undefined, + "incremental", + "d1", // target the existing diagram + ); + assert.equal(res.success, true); + const written = decodeDrawioSvg(calls.uploads[0].svg); + const cells = parseCells(written); + const a = cells.find((c) => c.id === "a"); + const b = cells.find((c) => c.id === "b"); + // Existing coords preserved (the stored a/b absolute coords from STORED). + assert.equal(a.geometry.x, 10); + assert.equal(a.geometry.y, 10); + assert.equal(b.geometry.x, 10); + assert.equal(b.geometry.y, 90); + assert.ok(cells.some((c) => c.id === "c"), "new node c added"); +}); + +// --- drawioFromMermaid ------------------------------------------------------ + +test("drawioFromMermaid: converts a flowchart and inserts a diagram", async () => { + const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }] }; + const { client, calls } = makeClient({ pageDoc }); + const res = await client.drawioFromMermaid( + "page1", + { position: "append" }, + "flowchart LR\n A[Start] --> B{Choose}\n B -->|yes| C[Done]\n B -->|no| D[Stop]", + ); + assert.equal(res.success, true); + const written = decodeDrawioSvg(calls.uploads[0].svg); + const cells = parseCells(written); + for (const id of ["A", "B", "C", "D"]) { + assert.ok(cells.some((c) => c.id === id), `node ${id} present`); + } +}); + +test("drawioFromMermaid: a non-flowchart is rejected, no upload", async () => { + const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } }); + await assert.rejects( + () => client.drawioFromMermaid("page1", { position: "append" }, "sequenceDiagram\n A->>B: x"), + /only 'flowchart'\/'graph' is supported/, + ); + assert.equal(calls.uploads.length, 0); +}); diff --git a/packages/mcp/test/unit/drawio-cell-ops.test.mjs b/packages/mcp/test/unit/drawio-cell-ops.test.mjs new file mode 100644 index 00000000..cbfaa6d0 --- /dev/null +++ b/packages/mcp/test/unit/drawio-cell-ops.test.mjs @@ -0,0 +1,128 @@ +// Unit tests for the drawioEditCells operations (issue #425, acceptance #4): +// add / update / delete applied to the parsed model, with a cascade delete that +// removes container children AND every connected edge. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { applyCellOps, CellOpsError } from "../../build/lib/drawio-cell-ops.js"; +import { parseCells } from "../../build/lib/drawio-xml.js"; + +const MODEL = + "" + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ""; + +const ids = (xml) => + parseCells(xml) + .filter((c) => c.id !== "0" && c.id !== "1") + .map((c) => c.id) + .sort(); + +test("update changes ONLY the targeted cell", () => { + const out = applyCellOps(MODEL, [ + { + op: "update", + cellId: "c1", + xml: + '' + + '', + }, + ]); + const cells = parseCells(out); + assert.equal(cells.find((c) => c.id === "c1").value, "Renamed"); + // Every OTHER cell is untouched. + assert.equal(cells.find((c) => c.id === "c2").value, "Child2"); + assert.equal(cells.find((c) => c.id === "out").value, "Outside"); + assert.deepEqual(ids(out), ids(MODEL)); +}); + +test("delete of a container removes its children AND the connected edges", () => { + const out = applyCellOps(MODEL, [{ op: "delete", cellId: "grp" }]); + // grp + c1 + c2 gone (cascade to children); e1 (c1->out) and e2 (out->c2) + // gone (cascade to connected edges); "out" survives. + assert.deepEqual(ids(out), ["out"]); +}); + +test("delete of a leaf only cascades to its connected edges, not siblings", () => { + const out = applyCellOps(MODEL, [{ op: "delete", cellId: "c1" }]); + // c1 gone + e1 (c1->out) gone; c2, out, grp, e2 survive. + assert.deepEqual(ids(out), ["c2", "e2", "grp", "out"]); +}); + +test("add appends a new cell", () => { + const out = applyCellOps(MODEL, [ + { + op: "add", + xml: + '' + + '', + }, + ]); + assert.ok(parseCells(out).some((c) => c.id === "new1")); +}); + +test("delete never removes the sentinels", () => { + const out = applyCellOps(MODEL, [{ op: "delete", cellId: "out" }]); + const cells = parseCells(out); + assert.ok(cells.some((c) => c.id === "0")); + assert.ok(cells.some((c) => c.id === "1")); +}); + +test("errors: unknown update/delete target, duplicate add id, id mismatch", () => { + assert.throws( + () => applyCellOps(MODEL, [{ op: "update", cellId: "ghost", xml: '' }]), + /does not exist/, + ); + assert.throws( + () => applyCellOps(MODEL, [{ op: "delete", cellId: "ghost" }]), + /does not exist/, + ); + assert.throws( + () => applyCellOps(MODEL, [{ op: "add", xml: '' }]), + /already exists/, + ); + assert.throws( + () => + applyCellOps(MODEL, [ + { op: "update", cellId: "c1", xml: '' }, + ]), + /ids are stable/, + ); + assert.throws(() => applyCellOps(MODEL, []), CellOpsError); +}); + +test("an add op with two cells or a missing id is rejected", () => { + assert.throws( + () => applyCellOps(MODEL, [{ op: "add", xml: '' }]), + /exactly one /, + ); + assert.throws( + () => applyCellOps(MODEL, [{ op: "add", xml: '' }]), + /missing an id/, + ); +}); + +// --- SUGGESTION #5: sentinel cells are protected from delete ------------------ + +test("delete targeting a sentinel id is rejected (no wipe of the diagram body)", () => { + for (const sid of ["0", "1"]) { + assert.throws( + () => applyCellOps(MODEL, [{ op: "delete", cellId: sid }]), + (e) => e instanceof CellOpsError && /cannot delete sentinel cell/.test(e.message), + ); + } + // A normal delete still works and the sentinels remain intact. + const out = applyCellOps(MODEL, [{ op: "delete", cellId: "out" }]); + const remaining = parseCells(out).map((c) => c.id); + assert.ok(remaining.includes("0") && remaining.includes("1"), "sentinels survive"); +}); diff --git a/packages/mcp/test/unit/drawio-graph.test.mjs b/packages/mcp/test/unit/drawio-graph.test.mjs new file mode 100644 index 00000000..7a473b26 --- /dev/null +++ b/packages/mcp/test/unit/drawio-graph.test.mjs @@ -0,0 +1,380 @@ +// Unit tests for the drawioFromGraph pipeline (issue #425, stage 3): the +// semantic graph -> ELK -> linter-clean XML assembler, plus the layout hints +// (pinned / sameLayerAs / layer) and incremental layout. Pure — no client, no +// network — so they run under `node --test` against the built lib. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildFromGraph, + validateGraph, + resolveNodeStyle, + GraphValidationError, +} from "../../build/lib/drawio-graph.js"; +import { getPreset } from "../../build/lib/drawio-presets.js"; +import { + prepareModel, + parseCells, + computeQualityWarnings, +} from "../../build/lib/drawio-xml.js"; + +function cellsOf(xml) { + return parseCells(xml).filter((c) => c.id !== "0" && c.id !== "1"); +} +function byId(xml) { + const m = new Map(); + for (const c of parseCells(xml)) m.set(c.id, c); + return m; +} + +// --- Acceptance #1: 15-node graph, 2 nested groups, AWS icons --------------- + +test("from_graph: 15+ nodes, 2 nested groups, AWS icons -> 0 lint errors, 0 warnings, all icons resolve", async () => { + const awsIcons = [ + "aws:lambda", "aws:dynamodb", "aws:api_gateway", "aws:s3", "aws:sqs", + "aws:sns", "aws:ec2", "aws:rds", "aws:cloudfront", "aws:elasticache", + "aws:kinesis", "aws:cognito", "aws:secrets_manager", "aws:cloudwatch", + ]; + const kinds = [ + "service", "db", "gateway", "service", "queue", "queue", "service", "db", + "gateway", "db", "queue", "security", "security", "external", + ]; + const nodes = []; + for (let i = 0; i < 14; i++) { + nodes.push({ + id: "n" + i, + label: "Node " + i, + kind: kinds[i], + icon: awsIcons[i], + group: i < 6 ? "sub1" : i < 10 ? "vpc1" : undefined, + }); + } + nodes.push({ id: "ext1", label: "External Service", kind: "external" }); + const graph = { + nodes, + groups: [ + { id: "vpc1", label: "VPC 10.0.0.0/16", kind: "vpc" }, + { id: "sub1", label: "Private Subnet", kind: "subnet", group: "vpc1" }, // NESTED + ], + edges: [ + { from: "n0", to: "n1", kind: "sync" }, + { from: "n1", to: "n2", kind: "async" }, + { from: "n2", to: "n3" }, + { from: "n3", to: "n7", kind: "sync" }, + { from: "n7", to: "n8" }, + { from: "n8", to: "ext1", kind: "error" }, + { from: "n4", to: "n5" }, + { from: "n10", to: "n11" }, + ], + direction: "LR", + preset: "default", + }; + const r = await buildFromGraph(graph, "full"); + + assert.equal(graph.nodes.length >= 15, true, "at least 15 nodes"); + // All icons resolved — NO empty squares. + assert.equal(r.iconsMissing.length, 0, `unresolved icons: ${r.iconsMissing}`); + assert.equal(r.iconsResolved, 14); + + // 0 lint errors + 0 quality-warnings. + const prepared = prepareModel(r.modelXml); + assert.equal(prepared.warnings.length, 0, prepared.warnings.join("\n")); + + // Groups are TRANSPARENT containers. + const cells = byId(r.modelXml); + for (const gid of ["vpc1", "sub1"]) { + const g = cells.get(gid); + assert.ok(g, `${gid} present`); + assert.equal(g.styleMap.container, "1", `${gid} container=1`); + assert.equal(g.styleMap.fillColor, "none", `${gid} fillColor=none`); + assert.equal(g.styleMap.dropTarget, "1", `${gid} dropTarget=1`); + } + // The nested group sub1's parent IS vpc1 (nesting honoured). + assert.equal(cells.get("sub1").parent, "vpc1"); + // A grouped node's parent is its group (relative coords). + assert.equal(cells.get("n0").parent, "sub1"); +}); + +test("from_graph: an UNKNOWN icon degrades to a labelled generic shape (never empty)", async () => { + const graph = { + nodes: [ + { id: "a", label: "Mystery", kind: "service", icon: "not:a-real-icon-xyz" }, + { id: "b", label: "Plain", kind: "db" }, + ], + edges: [{ from: "a", to: "b" }], + }; + const r = await buildFromGraph(graph, "full"); + const cells = byId(r.modelXml); + // The node still carries its label and a real (non-empty) style with a fill. + assert.equal(cells.get("a").value, "Mystery"); + assert.match(cells.get("a").style, /fillColor=/); + // It is reported as missing so the model can see the degradation. + assert.ok(r.iconsMissing.includes("a")); +}); + +// --- Acceptance #2: hints ----------------------------------------------------- + +test("from_graph: a pinned node stays at its exact coordinates", async () => { + const graph = { + nodes: [ + { id: "a", label: "A", pinned: { x: 40, y: 900 } }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + ], + edges: [{ from: "a", to: "b" }, { from: "b", to: "c" }], + }; + const a = byId((await buildFromGraph(graph, "full")).modelXml).get("a"); + assert.equal(a.geometry.x, 40); + assert.equal(a.geometry.y, 900); +}); + +test("from_graph: a sameLayerAs pair lands in the same layer (equal layer-axis coord)", async () => { + const graph = { + nodes: [ + { id: "x", label: "X" }, + { id: "y", label: "Y", sameLayerAs: "x" }, + { id: "z", label: "Z" }, + ], + edges: [{ from: "z", to: "x" }, { from: "z", to: "y" }], + direction: "LR", // layer axis = x + }; + const cells = byId((await buildFromGraph(graph, "full")).modelXml); + assert.equal(cells.get("x").geometry.x, cells.get("y").geometry.x); +}); + +test("from_graph: an explicit layer index co-aligns nodes on the layer axis", async () => { + const graph = { + nodes: [ + { id: "p", label: "P", layer: 0 }, + { id: "q", label: "Q", layer: 0 }, + { id: "r", label: "R", layer: 1 }, + ], + edges: [{ from: "p", to: "r" }], + direction: "LR", + }; + const cells = byId((await buildFromGraph(graph, "full")).modelXml); + assert.equal(cells.get("p").geometry.x, cells.get("q").geometry.x); +}); + +test("from_graph: TB direction snaps sameLayerAs on the Y axis", async () => { + const graph = { + nodes: [ + { id: "x", label: "X" }, + { id: "y", label: "Y", sameLayerAs: "x" }, + { id: "z", label: "Z" }, + ], + edges: [{ from: "z", to: "x" }, { from: "z", to: "y" }], + direction: "TB", // layer axis = y + }; + const cells = byId((await buildFromGraph(graph, "full")).modelXml); + assert.equal(cells.get("x").geometry.y, cells.get("y").geometry.y); +}); + +// --- Acceptance #3: incremental never moves existing cells -------------------- + +test("from_graph incremental: adding a node does NOT move existing cells", async () => { + const existing = new Map([ + ["a", { x: 100, y: 100 }], + ["b", { x: 400, y: 100 }], + ]); + const graph = { + nodes: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "cnew", label: "New" }, + ], + edges: [{ from: "a", to: "b" }, { from: "b", to: "cnew" }], + }; + const cells = byId((await buildFromGraph(graph, "incremental", existing)).modelXml); + assert.deepEqual( + [cells.get("a").geometry.x, cells.get("a").geometry.y], + [100, 100], + ); + assert.deepEqual( + [cells.get("b").geometry.x, cells.get("b").geometry.y], + [400, 100], + ); + // The new node was placed and does not overlap the frozen block. + const cn = cells.get("cnew"); + assert.ok(cn.geometry.y >= 200, "new node placed clear of the existing block"); +}); + +// --- direction honoured ------------------------------------------------------- + +test("from_graph: LR vs RL flip the layout axis order", async () => { + const mk = (dir) => ({ + nodes: [{ id: "s", label: "S" }, { id: "t", label: "T" }], + edges: [{ from: "s", to: "t" }], + direction: dir, + }); + const lr = byId((await buildFromGraph(mk("LR"), "full")).modelXml); + // In LR the target sits to the RIGHT of the source. + assert.ok(lr.get("t").geometry.x > lr.get("s").geometry.x, "LR: t right of s"); + const rl = byId((await buildFromGraph(mk("RL"), "full")).modelXml); + assert.ok(rl.get("t").geometry.x < rl.get("s").geometry.x, "RL: t left of s"); +}); + +// --- validation --------------------------------------------------------------- + +test("validateGraph: rejects duplicate node ids, unknown group/edge refs", () => { + assert.throws( + () => validateGraph({ nodes: [{ id: "a", label: "A" }, { id: "a", label: "B" }] }), + GraphValidationError, + ); + assert.throws( + () => validateGraph({ nodes: [{ id: "a", label: "A", group: "ghost" }] }), + /unknown group/, + ); + assert.throws( + () => + validateGraph({ + nodes: [{ id: "a", label: "A" }], + edges: [{ from: "a", to: "ghost" }], + }), + /resolves to no node/, + ); + assert.throws(() => validateGraph({ nodes: [] }), /non-empty/); +}); + +test("resolveNodeStyle: kind maps to the preset palette slot for a generic node", () => { + const preset = getPreset("default"); + const s = resolveNodeStyle(preset, { id: "d", label: "DB", kind: "db" }); + assert.equal(s.iconResolved, false); + assert.match(s.style, /fillColor=#d5e8d4;strokeColor=#82b366/); +}); + +// --- the assembled XML is always linter-clean -------------------------------- + +test("from_graph: a plain cross-container-edge graph is linter-clean", async () => { + const graph = { + nodes: [ + { id: "a", label: "A", group: "g1" }, + { id: "b", label: "B", group: "g2" }, + ], + groups: [ + { id: "g1", label: "G1" }, + { id: "g2", label: "G2" }, + ], + edges: [{ from: "a", to: "b", label: "x" }], + }; + const r = await buildFromGraph(graph, "full"); + const prepared = prepareModel(r.modelXml); // throws on any lint error + assert.equal(prepared.warnings.length, 0, prepared.warnings.join("\n")); + // A cross-container edge is parented at the layer sentinel "1". + const edge = cellsOf(r.modelXml).find((c) => c.edge); + assert.equal(edge.parent, "1"); +}); + +// --- CRITICAL #1: edge / group caps reject FAST (no layout, no OOM) ----------- + +test("validateGraph: an over-limit EDGE count is rejected before any layout", () => { + // 2 nodes, 200000 edges: passes node validation, would OOM graphToElk/runElk. + const edges = []; + for (let i = 0; i < 200_000; i++) edges.push({ from: "a", to: "b" }); + const graph = { nodes: [{ id: "a", label: "A" }, { id: "b", label: "B" }], edges }; + const t0 = Date.now(); + assert.throws( + () => validateGraph(graph), + (e) => e instanceof GraphValidationError && /200000 edges .*max 1000/.test(e.message), + ); + assert.ok(Date.now() - t0 < 1000, "must reject in well under a second (no layout)"); +}); + +test("validateGraph: an over-limit GROUP count is rejected fast", () => { + const groups = []; + for (let i = 0; i < 600; i++) groups.push({ id: "g" + i, label: "G" }); + const t0 = Date.now(); + assert.throws( + () => validateGraph({ nodes: [{ id: "a", label: "A" }], groups }), + (e) => e instanceof GraphValidationError && /600 groups .*max 500/.test(e.message), + ); + assert.ok(Date.now() - t0 < 1000); +}); + +test("validateGraph: exactly-at-cap edges/groups are accepted", () => { + const edges = []; + for (let i = 0; i < 1000; i++) edges.push({ from: "a", to: "b" }); + assert.doesNotThrow(() => + validateGraph({ nodes: [{ id: "a", label: "A" }, { id: "b", label: "B" }], edges }), + ); +}); + +// --- WARNING #3: sameLayerAs spread -> 0 quality-warnings by construction ----- + +test("from_graph: a sameLayerAs chain of 5 yields 0 quality-warnings (cross-axis spread)", async () => { + const graph = { + nodes: [ + { id: "a", label: "A" }, + { id: "b", label: "B", sameLayerAs: "a" }, + { id: "c", label: "C", sameLayerAs: "b" }, + { id: "d", label: "D", sameLayerAs: "c" }, + { id: "e", label: "E", sameLayerAs: "d" }, + ], + edges: [{ from: "a", to: "b" }], + direction: "LR", + }; + const r = await buildFromGraph(graph, "full"); + const cells = parseCells(r.modelXml); + const warnings = computeQualityWarnings(cells); + assert.equal(warnings.length, 0, warnings.join("\n")); + // The four chained dependents share one layer-axis (x) coordinate... + const by = new Map(cells.map((c) => [c.id, c])); + const xs = ["b", "c", "d", "e"].map((id) => by.get(id).geometry.x); + assert.equal(new Set(xs).size, 1, "chained nodes must share the layer axis"); + // ...but are spread on the cross axis (y) with distinct coordinates. + const ys = ["b", "c", "d", "e"].map((id) => by.get(id).geometry.y); + assert.equal(new Set(ys).size, 4, "chained nodes must not stack on the cross axis"); +}); + +test("from_graph: pinned coords are honored verbatim; a negative pin is clamped non-negative", async () => { + const graph = { + nodes: [ + { id: "p1", label: "P1", pinned: { x: 100, y: 100 } }, + // Two user-pinned nodes at (nearly) the same point: user intent, honored. + { id: "p2", label: "P2", pinned: { x: -500, y: 100 } }, // out-of-bounds x -> clamped to 0 + ], + direction: "LR", + }; + const r = await buildFromGraph(graph, "full"); + const by = new Map(parseCells(r.modelXml).map((c) => [c.id, c])); + assert.equal(by.get("p1").geometry.x, 100); + assert.equal(by.get("p1").geometry.y, 100); + assert.equal(by.get("p2").geometry.x, 0, "negative pin x clamped to 0"); + assert.equal(by.get("p2").geometry.y, 100, "pin y honored"); + // A pinned overlap MAY warn — that's user-directed and documented; we only + // assert the coords are honored (the guarantee softening), not warning count. +}); + +// --- WARNING #4: incremental MERGE preserves unlisted existing cells ---------- + +test("from_graph incremental: an existing cell not in the new graph SURVIVES the add", async () => { + const existingModelXml = + '' + + '' + + '' + + '' + + '' + + ""; + const existingCoords = new Map([ + ["manual", { x: 900, y: 900 }], + ["old1", { x: 40, y: 40 }], + ]); + // The model sends ONLY the re-listed old1 + the new node — NOT "manual". + const graph = { + nodes: [ + { id: "old1", label: "Old One" }, + { id: "new1", label: "Added Node" }, + ], + edges: [{ from: "old1", to: "new1" }], + direction: "LR", + }; + const r = await buildFromGraph(graph, "incremental", existingCoords, existingModelXml); + const by = new Map(parseCells(r.modelXml).map((c) => [c.id, c])); + // The unlisted hand-placed cell survives, verbatim coords. + assert.ok(by.has("manual"), "unlisted existing cell must not be dropped"); + assert.equal(by.get("manual").geometry.x, 900); + assert.equal(by.get("manual").geometry.y, 900); + // The re-listed existing cell keeps its frozen coords; the new node is added. + assert.equal(by.get("old1").geometry.x, 40); + assert.equal(by.get("old1").geometry.y, 40); + assert.ok(by.has("new1"), "the newly added node is present"); +}); diff --git a/packages/mcp/test/unit/drawio-mermaid.test.mjs b/packages/mcp/test/unit/drawio-mermaid.test.mjs new file mode 100644 index 00000000..88da72dd --- /dev/null +++ b/packages/mcp/test/unit/drawio-mermaid.test.mjs @@ -0,0 +1,114 @@ +// Unit tests for the Mermaid flowchart -> graph parser (issue #425, acceptance +// #6, OPTIONAL). Verifies a flowchart with a branch + a subgraph parses to a +// graph the from_graph pipeline renders as valid, editable drawio, and that a +// non-flowchart diagram is rejected with a clear error. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mermaidToGraph, MermaidParseError } from "../../build/lib/drawio-mermaid.js"; +import { buildFromGraph } from "../../build/lib/drawio-graph.js"; +import { prepareModel } from "../../build/lib/drawio-xml.js"; + +test("flowchart with a branch + subgraph -> a valid, editable drawio", async () => { + const mm = `flowchart LR + A[Start] --> B{Decision} + B -->|yes| C[(Database)] + B -->|no| D[End] + subgraph backend [Backend Services] + C + E([Cache]) + end + D -.-> E`; + const graph = mermaidToGraph(mm); + + // Direction + nodes + a group + labelled/dashed edges parsed. + assert.equal(graph.direction, "LR"); + const nodeIds = graph.nodes.map((n) => n.id).sort(); + assert.deepEqual(nodeIds, ["A", "B", "C", "D", "E"]); + // The decision `{}` maps to the queue palette; the `[(db)]` to db. + assert.equal(graph.nodes.find((n) => n.id === "B").kind, "queue"); + assert.equal(graph.nodes.find((n) => n.id === "C").kind, "db"); + // The subgraph became a group and claimed its members. + assert.equal(graph.groups.length, 1); + assert.equal(graph.groups[0].id, "backend"); + assert.equal(graph.nodes.find((n) => n.id === "C").group, "backend"); + assert.equal(graph.nodes.find((n) => n.id === "E").group, "backend"); + // A pipe label and a dotted (async) edge. + const yes = graph.edges.find((e) => e.from === "B" && e.to === "C"); + assert.equal(yes.label, "yes"); + const dotted = graph.edges.find((e) => e.from === "D" && e.to === "E"); + assert.equal(dotted.kind, "async"); + + // The whole thing renders linter-clean. + const built = await buildFromGraph(graph, "full"); + const prepared = prepareModel(built.modelXml); + assert.equal(prepared.warnings.length, 0, prepared.warnings.join("\n")); +}); + +test("graph TD header sets a top-down direction", () => { + const g = mermaidToGraph("graph TD\n X --> Y"); + assert.equal(g.direction, "TB"); + assert.deepEqual(g.nodes.map((n) => n.id).sort(), ["X", "Y"]); +}); + +test("a chained connection A --> B --> C yields two edges", () => { + const g = mermaidToGraph("flowchart LR\n A[a] --> B[b] --> C[c]"); + const pairs = g.edges.map((e) => `${e.from}->${e.to}`).sort(); + assert.deepEqual(pairs, ["A->B", "B->C"]); +}); + +test("a non-flowchart diagram is rejected with a clear error", () => { + assert.throws( + () => mermaidToGraph("sequenceDiagram\n Alice->>Bob: Hi"), + /only 'flowchart'\/'graph' is supported/, + ); + assert.throws(() => mermaidToGraph(""), MermaidParseError); +}); + +// --- CRITICAL #2 / NIT: input-size bounds reject FAST (no OOM) ---------------- + +test("mermaidToGraph: an over-length input is rejected before parsing (fast)", () => { + const huge = "flowchart LR\n" + "A-->B\n".repeat(60_000); // ~360 KB > 200 KB cap + const t0 = Date.now(); + assert.throws( + () => mermaidToGraph(huge), + (e) => e instanceof MermaidParseError && /max 200000/.test(e.message), + ); + assert.ok(Date.now() - t0 < 1000, "must reject in well under a second (no parse)"); +}); + +test("mermaidToGraph: an over-line-count input is rejected fast", () => { + const many = "flowchart LR\n" + "A\n".repeat(25_000); // > 20000 line cap + const t0 = Date.now(); + assert.throws( + () => mermaidToGraph(many), + (e) => e instanceof MermaidParseError && /max 20000/.test(e.message), + ); + assert.ok(Date.now() - t0 < 1000); +}); + +test("mermaidToGraph: too many subgraphs is rejected", () => { + let src = "flowchart LR\n"; + for (let i = 0; i < 600; i++) src += `subgraph s${i}\nend\n`; + assert.throws( + () => mermaidToGraph(src), + (e) => e instanceof MermaidParseError && /too many subgraphs .*max 500/.test(e.message), + ); +}); + +test("mermaidToGraph: an over-long connection chain throws (NON-silent truncation)", () => { + const chain = + "flowchart LR\n" + + Array.from({ length: 600 }, (_, i) => "N" + i).join("-->"); + assert.throws( + () => mermaidToGraph(chain), + (e) => e instanceof MermaidParseError && /chain exceeds 500 nodes/.test(e.message), + ); +}); + +test("mermaidToGraph: a chain of 60 nodes parses (no silent 50-node truncation)", () => { + const chain = + "flowchart LR\n" + + Array.from({ length: 60 }, (_, i) => "N" + i).join("-->"); + const g = mermaidToGraph(chain); + assert.equal(g.nodes.length, 60, "all 60 chained nodes are kept"); +}); diff --git a/packages/mcp/test/unit/drawio-presets.test.mjs b/packages/mcp/test/unit/drawio-presets.test.mjs new file mode 100644 index 00000000..84a958e7 --- /dev/null +++ b/packages/mcp/test/unit/drawio-presets.test.mjs @@ -0,0 +1,117 @@ +// Snapshot + invariant tests for the semantic presets (issue #425, acceptance +// #5): every node kind has a style-string per preset, and colorblind-safe uses +// only the Okabe-Ito palette (no problematic color pairs). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + getPreset, + genericNodeStyle, + edgeStyle, + groupStyle, + NODE_KINDS, + EDGE_KINDS, + PRESET_NAMES, +} from "../../build/lib/drawio-presets.js"; + +// The Okabe-Ito qualitative palette (8 colours distinguishable under the common +// colour-vision deficiencies). colorblind-safe MUST draw its strokes from here. +const OKABE_ITO = [ + "#000000", "#e69f00", "#56b4e9", "#009e73", + "#f0e442", "#0072b2", "#d55e00", "#cc79a7", +].map((s) => s.toLowerCase()); + +// The exact base-palette fills from the issue's table (a snapshot: a change to +// the default palette is a deliberate, reviewed edit — this catches accidents). +const DEFAULT_FILLS = { + service: "#dae8fc", + db: "#d5e8d4", + queue: "#fff2cc", + gateway: "#ffe6cc", + error: "#f8cecc", + external: "#f5f5f5", + security: "#e1d5e7", +}; +const DEFAULT_STROKES = { + service: "#6c8ebf", + db: "#82b366", + queue: "#d6b656", + gateway: "#d79b00", + error: "#b85450", + external: "#666666", + security: "#9673a6", +}; + +test("every node kind has a style-string in every preset", () => { + for (const p of PRESET_NAMES) { + const preset = getPreset(p); + for (const kind of NODE_KINDS) { + const style = genericNodeStyle(preset, kind); + assert.match(style, /fillColor=#[0-9a-fA-F]{6}/, `${p}/${kind} has a fill`); + assert.match(style, /strokeColor=#[0-9a-fA-F]{6}/, `${p}/${kind} has a stroke`); + assert.match(style, /fontColor=#[0-9a-fA-F]{6}/, `${p}/${kind} has a font`); + } + } +}); + +test("default preset matches the issue's palette snapshot", () => { + const preset = getPreset("default"); + for (const kind of NODE_KINDS) { + const style = genericNodeStyle(preset, kind); + assert.ok( + style.includes(`fillColor=${DEFAULT_FILLS[kind]}`), + `default/${kind} fill ${DEFAULT_FILLS[kind]}`, + ); + assert.ok( + style.includes(`strokeColor=${DEFAULT_STROKES[kind]}`), + `default/${kind} stroke ${DEFAULT_STROKES[kind]}`, + ); + } +}); + +test("colorblind-safe strokes come ONLY from the Okabe-Ito palette", () => { + const preset = getPreset("colorblind-safe"); + const usedStrokes = new Set(); + for (const kind of NODE_KINDS) { + const stroke = preset.nodes[kind].strokeColor.toLowerCase(); + assert.ok( + OKABE_ITO.includes(stroke), + `colorblind-safe/${kind} stroke ${stroke} is NOT Okabe-Ito`, + ); + usedStrokes.add(stroke); + } + // No two kinds share a stroke (each is a distinct, distinguishable hue) — the + // "no problematic color pairs" acceptance: distinct Okabe-Ito hues. + assert.equal( + usedStrokes.size, + NODE_KINDS.length, + "each kind gets a distinct Okabe-Ito stroke", + ); +}); + +test("edge kinds: sync solid, async dashed, error red-dashed", () => { + const preset = getPreset("default"); + const sync = edgeStyle(preset, "sync"); + const async_ = edgeStyle(preset, "async"); + const error = edgeStyle(preset, "error"); + assert.doesNotMatch(sync, /dashed=1/, "sync is solid"); + assert.match(async_, /dashed=1/, "async is dashed"); + assert.match(error, /dashed=1/, "error is dashed"); + assert.match(error, /strokeColor=#DD344C/i, "error is red"); + // An unknown edge kind falls back to sync (solid). + assert.doesNotMatch(edgeStyle(preset, "weird"), /dashed=1/); +}); + +test("group style is always transparent (fillColor=none;container=1;dropTarget=1)", () => { + for (const p of PRESET_NAMES) { + const style = groupStyle(getPreset(p)); + assert.match(style, /fillColor=none/, `${p} group transparent`); + assert.match(style, /container=1/, `${p} group is a container`); + assert.match(style, /dropTarget=1/, `${p} group is a drop target`); + } +}); + +test("EDGE_KINDS / NODE_KINDS constants match the palette", () => { + const preset = getPreset("default"); + for (const k of NODE_KINDS) assert.ok(preset.nodes[k], `node kind ${k}`); + for (const k of EDGE_KINDS) assert.ok(preset.edges[k], `edge kind ${k}`); +}); diff --git a/packages/mcp/test/unit/drawio-stage3-registration.test.mjs b/packages/mcp/test/unit/drawio-stage3-registration.test.mjs new file mode 100644 index 00000000..2b7221e5 --- /dev/null +++ b/packages/mcp/test/unit/drawio-stage3-registration.test.mjs @@ -0,0 +1,85 @@ +// Drift guards for the stage-3 drawio tools (issue #425): the three high-level +// tools must be in the shared registry, routed in SERVER_INSTRUCTIONS, expose +// the right schema fields, and carry an `execute` (they call CLIENT methods, so +// unlike drawioShapes/drawioGuide they are NOT inlineBothHosts). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { SERVER_INSTRUCTIONS } from "../../build/index.js"; +import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js"; + +const NEW = ["drawioEditCells", "drawioFromGraph", "drawioFromMermaid"]; + +test("the three stage-3 tools are in the shared registry (deferred, camelCase)", () => { + for (const name of NEW) { + const spec = SHARED_TOOL_SPECS[name]; + assert.ok(spec, `${name} missing from registry`); + assert.equal(spec.mcpName, name); + assert.equal(spec.inAppKey, name); + assert.equal(spec.tier, "deferred"); + } +}); + +test("the stage-3 tools carry an execute (client-backed, NOT inlineBothHosts)", () => { + for (const name of NEW) { + const spec = SHARED_TOOL_SPECS[name]; + assert.equal(typeof spec.execute, "function", `${name} needs an execute`); + assert.notEqual(spec.inlineBothHosts, true, `${name} must not be inlineBothHosts`); + } +}); + +test("the stage-3 tools are routed in SERVER_INSTRUCTIONS", () => { + for (const name of NEW) { + assert.match(SERVER_INSTRUCTIONS, new RegExp(`\\b${name}\\b`), `${name} missing from guide`); + } +}); + +test("drawioFromGraph exposes graph + direction/preset/layout params", () => { + const shape = SHARED_TOOL_SPECS.drawioFromGraph.buildShape(makeZodStub()); + for (const key of ["pageId", "graph", "position", "direction", "preset", "layout", "node"]) { + assert.ok(key in shape, `drawioFromGraph missing ${key}`); + } +}); + +test("drawioEditCells exposes operations + baseHash", () => { + const shape = SHARED_TOOL_SPECS.drawioEditCells.buildShape(makeZodStub()); + for (const key of ["pageId", "node", "operations", "baseHash"]) { + assert.ok(key in shape, `drawioEditCells missing ${key}`); + } +}); + +test("drawioFromMermaid exposes a mermaid param", () => { + const shape = SHARED_TOOL_SPECS.drawioFromMermaid.buildShape(makeZodStub()); + assert.ok("mermaid" in shape); +}); + +test("the hard-rules block is injected into edit_cells (raw ops) but NOT from_graph/from_mermaid", () => { + // edit_cells takes raw xml in add/update ops, so it surfaces the XML + // rules. from_graph/from_mermaid NEVER expose XML to the model (the whole point + // is the model never writes a style/coord), so the hard rules would be noise. + assert.match(SHARED_TOOL_SPECS.drawioEditCells.description, /sentinels are MANDATORY/); + assert.doesNotMatch(SHARED_TOOL_SPECS.drawioFromGraph.description, /sentinels are MANDATORY/); + assert.doesNotMatch(SHARED_TOOL_SPECS.drawioFromMermaid.description, /sentinels are MANDATORY/); +}); + +test("routing prose distinguishes from_graph (architectures) vs from_mermaid (standard)", () => { + // The EDIT-section routing sentence must mention the semantic tools' intents. + assert.match(SERVER_INSTRUCTIONS, /drawioFromGraph[\s\S]*architecture|architecture[\s\S]*drawioFromGraph/i); + assert.match(SERVER_INSTRUCTIONS, /drawioFromMermaid[\s\S]*flowchart|flowchart[\s\S]*drawioFromMermaid/i); +}); + +// Tiny zod stub (buildShape only calls string/number/enum/array/object + +// chained min/optional/describe — all return `this`; object() returns a chain +// too so nested schemas resolve). +function makeZodStub() { + const chain = new Proxy( + {}, + { get: (_t, p) => (p === "parse" ? () => ({}) : () => chain) }, + ); + return { + string: () => chain, + number: () => chain, + enum: () => chain, + array: () => chain, + object: () => chain, + }; +} -- 2.52.0