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;