diff --git a/packages/mcp/src/client/read.ts b/packages/mcp/src/client/read.ts index 9f8c4a11..9e2a3a4a 100644 --- a/packages/mcp/src/client/read.ts +++ b/packages/mcp/src/client/read.ts @@ -120,8 +120,13 @@ export function ReadMixin>(Base "listPages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.", ); } - const { pages } = await this.enumerateSpacePages(spaceId); - return buildPageTree(pages); + // #486: propagate `truncated` (same pattern as check_new_comments). The old + // code dropped it, so a caller handed an INCOMPLETE tree (the stdio-fallback + // BFS hit its node cap) had no way to know pages were missing. Return the + // tree alongside the flag; the primary /pages/tree path is uncapped so this + // is false there. + const { pages, truncated } = await this.enumerateSpacePages(spaceId); + return { tree: buildPageTree(pages), truncated }; } const clampedLimit = Math.max(1, Math.min(100, limit)); diff --git a/packages/mcp/test/mock/list-pages-tree-truncated.test.mjs b/packages/mcp/test/mock/list-pages-tree-truncated.test.mjs new file mode 100644 index 00000000..0d39ab23 --- /dev/null +++ b/packages/mcp/test/mock/list-pages-tree-truncated.test.mjs @@ -0,0 +1,55 @@ +// Unit test: listPages tree mode must propagate the `truncated` flag (#486). +// +// enumerateSpacePages returns { pages, truncated } — truncated is true ONLY when +// the stdio-fallback BFS hit its node cap (the primary /pages/tree path is +// uncapped). The old tree-mode listPages destructured only `pages` and returned a +// bare tree, dropping `truncated`, so a caller handed an INCOMPLETE tree had no +// way to know pages were missing. The fix returns { tree, truncated } (same +// pattern check_new_comments uses). +// +// Reaching the real cap (MAX_NODES = 10000) in a mock is impractical, so we stub +// enumerateSpacePages directly to assert the flag is threaded through verbatim. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { DocmostClient } from "../../build/client.js"; + +function stubClient() { + const client = new DocmostClient({ + apiUrl: "http://127.0.0.1:1/api", + getToken: async () => "access", + }); + // No network: the tree path only calls ensureAuthenticated + enumerateSpacePages. + client.ensureAuthenticated = async () => {}; + return client; +} + +const onePage = [{ id: "r1", title: "Root", parentPageId: null }]; + +test("tree mode carries truncated:true when the enumeration truncated (#486)", async () => { + const client = stubClient(); + client.enumerateSpacePages = async () => ({ pages: onePage, truncated: true }); + + const res = await client.listPages("space-1", 50, true); + + assert.equal(res.truncated, true, "the truncated flag is threaded through"); + assert.ok(Array.isArray(res.tree), "the built tree rides alongside the flag"); + assert.equal(res.tree[0].id, "r1"); +}); + +test("tree mode carries truncated:false for a complete enumeration", async () => { + const client = stubClient(); + client.enumerateSpacePages = async () => ({ pages: onePage, truncated: false }); + + const res = await client.listPages("space-1", 50, true); + + assert.equal(res.truncated, false); + assert.equal(res.tree[0].id, "r1"); +}); + +test("tree mode still requires a spaceId", async () => { + const client = stubClient(); + await assert.rejects( + client.listPages(undefined, 50, true), + /tree mode requires a spaceId/, + ); +}); diff --git a/packages/mcp/test/mock/pagination-cursor.test.mjs b/packages/mcp/test/mock/pagination-cursor.test.mjs index a81a158e..fd6ab47f 100644 --- a/packages/mcp/test/mock/pagination-cursor.test.mjs +++ b/packages/mcp/test/mock/pagination-cursor.test.mjs @@ -184,12 +184,14 @@ test("enumerateSpacePages (via listPages tree) uses one /pages/tree request", as }); const client = new DocmostClient(baseURL, "user@example.com", "pw"); - // listPages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree. - const tree = await client.listPages("space-1", 50, true); + // listPages tree:true -> enumerateSpacePages(spaceId) -> { tree, truncated }. + const { tree, truncated } = await client.listPages("space-1", 50, true); assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space"); assert.equal(sidebarRequests, 0, "no per-node sidebar BFS requests"); assert.deepEqual(treeBody, { spaceId: "space-1" }, "space scope posts spaceId only"); + // The uncapped /pages/tree path is never truncated (#486). + assert.equal(truncated, false, "primary /pages/tree path is not truncated"); // buildPageTree nests c1 under r1; two roots at the top level. assert.equal(tree.length, 2, "two root nodes"); const r1 = tree.find((n) => n.id === "r1"); @@ -249,7 +251,7 @@ test("enumerateSpacePages falls back to the cursor BFS on /pages/tree 404", asyn }); const client = new DocmostClient(baseURL, "user@example.com", "pw"); - const tree = await client.listPages("space-1", 50, true); + const { tree, truncated } = await client.listPages("space-1", 50, true); assert.ok(treeRequests >= 1, "the tree endpoint was attempted first"); assert.deepEqual( @@ -257,6 +259,8 @@ test("enumerateSpacePages falls back to the cursor BFS on /pages/tree 404", asyn ["", "r1"], "fell back to the sidebar BFS: roots then the root's children", ); + // Small fallback walk well under the node cap -> not truncated (#486). + assert.equal(truncated, false, "fallback BFS below the cap is not truncated"); assert.equal(tree.length, 1, "one root in the built tree"); assert.equal(tree[0].children[0].id, "c1", "leaf nested via the BFS"); });