Files
gitmost/packages/mcp/test/unit/tool-specs.test.mjs
T
agent_coder bfb4c8d8d0 feat(mcp): getTree — иерархия пространства/поддерева одним вызовом (#443, часть 2/3)
Раньше единственный способ получить дерево — 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) <noreply@anthropic.com>
2026-07-10 21:58:32 +03:00

245 lines
11 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { z } from "zod";
import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js";
// The shared registry is consumed by BOTH the zod-v3 MCP server and the zod-v4
// in-app AI-SDK service, so every spec must carry the cross-layer wiring
// (mcpName + inAppKey) and its builders must produce the right field set when
// called with a real zod namespace.
test("every spec exposes mcpName + inAppKey, and the key matches inAppKey", () => {
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
assert.equal(typeof spec.mcpName, "string");
assert.ok(spec.mcpName.length > 0, `${key}: empty mcpName`);
assert.equal(typeof spec.inAppKey, "string");
assert.ok(spec.inAppKey.length > 0, `${key}: empty inAppKey`);
assert.equal(typeof spec.description, "string");
assert.ok(spec.description.length > 0, `${key}: empty description`);
// The registry is keyed by inAppKey — keep the two in sync.
assert.equal(spec.inAppKey, key, `${key}: registry key must equal inAppKey`);
}
});
// Since issue #412 the external MCP name equals the in-app key: both are the
// same camelCase identifier (mcpName === inAppKey).
test("mcpName and inAppKey are the same camelCase identifier", () => {
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
assert.match(spec.mcpName, /^[a-z][a-zA-Z0-9]*$/, `${key}: mcpName not camelCase`);
assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`);
assert.equal(spec.mcpName, spec.inAppKey, `${key}: mcpName must equal inAppKey`);
}
});
test("mcpName and inAppKey are each unique across the registry", () => {
const mcpNames = new Set();
const inAppKeys = new Set();
for (const spec of Object.values(SHARED_TOOL_SPECS)) {
assert.ok(!mcpNames.has(spec.mcpName), `duplicate mcpName: ${spec.mcpName}`);
assert.ok(!inAppKeys.has(spec.inAppKey), `duplicate inAppKey: ${spec.inAppKey}`);
mcpNames.add(spec.mcpName);
inAppKeys.add(spec.inAppKey);
}
});
test("buildShape (when present) returns a usable ZodRawShape with a real zod", () => {
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
if (!spec.buildShape) continue;
const shape = spec.buildShape(z);
assert.equal(typeof shape, "object");
// Each field must be a real zod type so z.object(shape) compiles a schema.
for (const [field, zt] of Object.entries(shape)) {
assert.ok(
zt && typeof zt.parse === "function",
`${key}.${field}: not a zod type`,
);
}
// The compiled object schema must parse a minimal valid input.
assert.doesNotThrow(() => z.object(shape));
}
});
test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => {
const spec = SHARED_TOOL_SPECS.editPageText;
assert.equal(spec.mcpName, "editPageText");
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]);
// A valid edits batch parses.
const schema = z.object(shape);
const parsed = schema.parse({
pageId: "p1",
edits: [{ find: "teh", replace: "the" }],
});
assert.equal(parsed.pageId, "p1");
assert.equal(parsed.edits.length, 1);
// The canonical description must NOT carry the stale MCP strip-and-retry claim.
assert.ok(
!/strip-and-retry/i.test(spec.description),
"editPageText description still claims strip-and-retry",
);
assert.match(spec.description, /REFUSED into\s+failed\[\]/);
});
// #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);
});
// #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 #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(),
["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(parsedNode.pageId, "p1");
// Neither given parses at the schema level (the client throws the XOR error).
assert.doesNotThrow(() => schema.parse({ pageId: "p1", nodeId: "n1" }));
});
// #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");
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/);
const shape = spec.buildShape(z);
assert.deepEqual(
Object.keys(shape).sort(),
["anchorNodeId", "anchorText", "markdown", "node", "pageId", "position"],
);
// 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", markdown: "x", position: "sideways" }),
);
});
// #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`);
}
});
// #411: plain-Markdown full-body replace tool, paired with updatePageJson.
test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId, content, title }", () => {
const spec = SHARED_TOOL_SPECS.updatePageMarkdown;
assert.ok(spec, "updatePageMarkdown spec missing");
assert.equal(spec.mcpName, "updatePageMarkdown");
assert.equal(spec.inAppKey, "updatePageMarkdown");
// Registered on BOTH hosts (a shared spec, no inAppOnly/mcpOnly flag).
assert.notEqual(spec.inAppOnly, true);
assert.notEqual(spec.mcpOnly, true);
// Same tier as its JSON sibling.
assert.equal(spec.tier, SHARED_TOOL_SPECS.updatePageJson.tier);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["content", "pageId", "title"]);
// pageId + content required, title optional.
const schema = z.object(shape);
assert.doesNotThrow(() => schema.parse({ pageId: "p1", content: "# Hi" }));
assert.throws(() => schema.parse({ pageId: "p1" }));
// The description must flag the `^[...]` inline-footnote parse path so the
// markdown->footnote canonicalization guarantee stays documented (#411).
assert.match(spec.description, /\^\[/);
});
// #411: importPageMarkdown is dropped from the EXTERNAL MCP surface but stays
// available to the in-app agent — encoded as inAppOnly on the shared spec.
test("importPageMarkdown spec is inAppOnly (removed from the external MCP surface, kept in-app)", () => {
const spec = SHARED_TOOL_SPECS.importPageMarkdown;
assert.ok(spec, "importPageMarkdown spec missing");
assert.equal(spec.inAppOnly, true);
// The spec + its client method are NOT deleted — only hidden from the MCP host.
assert.equal(spec.mcpName, "importPageMarkdown");
assert.equal(spec.inAppKey, "importPageMarkdown");
});