feat(mcp): add hierarchical tree mode to list_pages

list_pages gains an opt-in `tree` parameter on both surfaces (the
@docmost/mcp server tool and the AI-chat agent tool), which share the
same DocmostClient.listPages. Default behavior (recent-by-updatedAt flat
list) is unchanged.

- client.ts: listPages(spaceId?, limit=50, tree=false); when tree is
  true it requires spaceId (throws a specific error otherwise), walks the
  sidebar tree via the existing bounded/cycle-safe enumerateSpacePages,
  and returns a nested tree; limit is ignored in tree mode.
- lib/tree.ts: new pure buildPageTree() — lean nodes { id, slugId, title,
  children? }, children sorted by position (code-unit order), orphans
  promoted to roots, cycle-safe.
- index.ts + ai-chat-tools.service.ts: expose `tree` in the tool schemas
  and descriptions; docmost-client.loader.ts: mirror the new signature.
- tests: add packages/mcp/test/unit/tree.test.mjs (nesting, ordering,
  lean shape, orphan promotion, cycle/self-reference safety).
- rebuild @docmost/mcp (build/ is tracked and loaded at runtime).
This commit is contained in:
vvzvlad
2026-06-18 20:30:00 +03:00
parent 8178d21c00
commit 1e7a306f96
9 changed files with 407 additions and 18 deletions

View File

@@ -0,0 +1,139 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildPageTree } from "../../build/lib/tree.js";
test("buildPageTree nests two children under their parent", () => {
const tree = buildPageTree([
{ id: "root", slugId: "s-root", title: "Root", position: "a0" },
{
id: "c1",
slugId: "s-c1",
title: "Child 1",
position: "a0",
parentPageId: "root",
},
{
id: "c2",
slugId: "s-c2",
title: "Child 2",
position: "a1",
parentPageId: "root",
},
]);
assert.equal(tree.length, 1);
assert.equal(tree[0].id, "root");
assert.equal(tree[0].children.length, 2);
assert.deepEqual(
tree[0].children.map((c) => c.id),
["c1", "c2"],
);
});
test("buildPageTree sorts children and roots ascending by position", () => {
const tree = buildPageTree([
// Roots provided out of order.
{ id: "r2", slugId: "s-r2", title: "R2", position: "a2" },
{ id: "r1", slugId: "s-r1", title: "R1", position: "a1" },
// Children provided out of order.
{
id: "c2",
slugId: "s-c2",
title: "C2",
position: "b1",
parentPageId: "r1",
},
{
id: "c1",
slugId: "s-c1",
title: "C1",
position: "b0",
parentPageId: "r1",
},
]);
assert.deepEqual(
tree.map((n) => n.id),
["r1", "r2"],
);
assert.deepEqual(
tree[0].children.map((c) => c.id),
["c1", "c2"],
);
});
test("buildPageTree omits the children key for leaf nodes", () => {
const tree = buildPageTree([
{ id: "leaf", slugId: "s-leaf", title: "Leaf", position: "a0" },
]);
assert.equal(tree.length, 1);
assert.equal("children" in tree[0], false);
});
test("buildPageTree promotes an orphan (missing parent) to a root", () => {
const tree = buildPageTree([
{
id: "orphan",
slugId: "s-orphan",
title: "Orphan",
position: "a0",
// parentPageId references an id NOT present in the input.
parentPageId: "does-not-exist",
},
]);
assert.equal(tree.length, 1);
assert.equal(tree[0].id, "orphan");
assert.equal("children" in tree[0], false);
});
test("buildPageTree is cycle-safe (two-node cycle does not recurse or appear in output)", () => {
// A <-> B cycle: each node's parent is present, so neither becomes a root.
// The cycle component is unreachable from the returned roots, so the output
// is finite and JSON-serializable (no infinite recursion / circular JSON).
const tree = buildPageTree([
{ id: "A", slugId: "s-A", title: "A", position: "a0", parentPageId: "B" },
{ id: "B", slugId: "s-B", title: "B", position: "a1", parentPageId: "A" },
]);
assert.deepEqual(tree, []);
// Must not throw on a structure that contains the cyclic component internally.
assert.doesNotThrow(() => JSON.stringify(tree));
});
test("buildPageTree is self-reference-safe (node parented to itself is dropped, no crash)", () => {
const tree = buildPageTree([
{ id: "root", slugId: "s-root", title: "Root", position: "a0" },
// Self-referencing node: its parent is present (itself) -> not a root.
{ id: "self", slugId: "s-self", title: "Self", position: "a0", parentPageId: "self" },
]);
assert.deepEqual(
tree.map((n) => n.id),
["root"],
);
assert.doesNotThrow(() => JSON.stringify(tree));
});
test("buildPageTree output shape is lean (drops position/parentPageId/hasChildren)", () => {
const tree = buildPageTree([
{
id: "p1",
slugId: "s-p1",
title: "P1",
position: "a0",
parentPageId: null,
hasChildren: false,
spaceId: "space-1",
},
]);
const node = tree[0];
assert.deepEqual(node, { id: "p1", slugId: "s-p1", title: "P1" });
assert.equal("position" in node, false);
assert.equal("parentPageId" in node, false);
assert.equal("hasChildren" in node, false);
assert.equal("spaceId" in node, false);
});