3a521ada4d
Поверхности записи «целым телом» были несимметричны: у in-app агента полная
замена тела markdown называлась updatePageContent (имя не про формат, тогда как
парный updatePageJson — про JSON), а у внешнего MCP голого plain-body-replace
не было вовсе (только import_page_markdown — на деле парсер round-trip к
export_page_markdown, не plain-replace). Пара должна быть updatePageMarkdown /
updatePageJson.
Пост-Фаза-1б архитектура (реестр + циклы по обоим хостам):
- новая shared-спека updatePageMarkdown (mcpName update_page_markdown, inAppKey
updatePageMarkdown, tier как у updatePageJson) с execute (client, {pageId,
content, title}) => client.updatePage(...) — тот же путь updatePageContentRealtime
→ markdownToProseMirrorCanonical, ^[...]-сноски парсятся. Реестровый цикл
регистрирует её на ОБОИХ хостах автоматически. Добавлен 'updatePage' в
Pick DocmostClientLike.
- import_page_markdown убран с внешнего MCP через inAppOnly:true у спеки
importPageMarkdown — MCP-цикл и генератор инвентаря её пропускают, in-app
агент сохраняет importPageMarkdown; спека и client-метод НЕ удалены.
- удалён inline in-app updatePageContent tool (теперь из реестра под inAppKey
updatePageMarkdown) + его INLINE_TOOL_TIERS-энтри.
- ROUTING_PROSE: bulk-rewrite ссылается на update_page_markdown|update_page_json;
убрано упоминание import_page_markdown; инвентарь генерируется из catalogLine.
- лейбл-мапы chat-markdown.util (en/ru), человекочитаемые метки не тронуты.
- НЕ тронуты одноимённые внутренности: PageService.updatePageContent,
updatePageContentRealtime, collaboration.handler — переименовано только имя тула.
Тесты: updatePageMarkdown на обеих поверхностях с идентичной схемой, forward в
client.updatePage; import_page_markdown ОТСУТСТВУЕТ на MCP, присутствует in-app;
^[...]→сноски покрыт через collaboration.test. CHANGELOG BREAKING + миграция;
README/README.ru пакета обновлены. Гейт: mcp node --test 646/646, server jest
259, tsc чисто. Первый линк breaking-окна #416 (#411→#412→#413→#415).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
5.5 KiB
JavaScript
135 lines
5.5 KiB
JavaScript
// Guard: the GENERATED <tool_inventory> in SERVER_INSTRUCTIONS (issue #448)
|
|
// names every tool the server registers. The inventory is BUILT from the
|
|
// registry (SHARED_TOOL_SPECS' mcpName/catalogLine + INLINE_MCP_INVENTORY), so
|
|
// the shared-registry tools can never drift by construction; this test's job is
|
|
// to catch the ONE remaining manual list — INLINE_MCP_INVENTORY — falling out
|
|
// of sync with the inline `server.registerTool(...)` calls in index.ts.
|
|
//
|
|
// It also asserts the composed guide keeps its routing prose (the hand-written
|
|
// intent hints) and is a valid non-empty string — the structural guarantees the
|
|
// old name-scraper test (server-instructions.test.mjs, now deleted) carried,
|
|
// minus its now-redundant per-name prose scrape.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import {
|
|
SERVER_INSTRUCTIONS,
|
|
ROUTING_PROSE,
|
|
buildToolInventoryLines,
|
|
} from "../../build/server-instructions.js";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const SRC = join(HERE, "..", "..", "src");
|
|
|
|
/**
|
|
* Every tool name the MCP server registers, scraped from the SOURCE:
|
|
* - inline `server.registerTool("name", ...)` calls in index.ts;
|
|
* - shared specs in tool-specs.ts (`mcpName: 'name'`), EXCEPT `inAppOnly`
|
|
* specs, which the registry loop in index.ts SKIPS on the MCP host (#411).
|
|
* Same two registration mechanisms the old guard covered.
|
|
*/
|
|
function registeredToolNames() {
|
|
const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8");
|
|
const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8");
|
|
const names = new Set();
|
|
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) {
|
|
names.add(m[1]);
|
|
}
|
|
// Each spec is one `{ ... }` block; scrape its mcpName but skip a block that
|
|
// carries `inAppOnly: true` (not registered on the external MCP host).
|
|
for (const block of specsSrc.split(/\n\s{2}\w+:\s*\{/)) {
|
|
const nameMatch = block.match(/mcpName:\s*['"]([a-z0-9_]+)['"]/);
|
|
if (!nameMatch) continue;
|
|
if (/inAppOnly:\s*true/.test(block)) continue;
|
|
names.add(nameMatch[1]);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
test("the generated inventory names every registered tool", () => {
|
|
const registered = registeredToolNames();
|
|
// Sanity: if the scrape regressed (regex drift), fail loudly rather than
|
|
// vacuously passing on an empty set.
|
|
assert.ok(
|
|
registered.size >= 40,
|
|
`sanity: expected 40+ registered tools, got ${registered.size} — ` +
|
|
"the extraction regexes in this test likely drifted from the source",
|
|
);
|
|
const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
|
|
const missing = [...registered].filter((n) => !inventory.has(n)).sort();
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
`tools missing from the generated <tool_inventory>: ${missing.join(", ")} — ` +
|
|
"a SHARED spec is covered automatically; an INLINE MCP-only tool needs a " +
|
|
"line added to INLINE_MCP_INVENTORY in src/server-instructions.ts",
|
|
);
|
|
});
|
|
|
|
test("the inventory has no phantom tool (every line is a real registered tool)", () => {
|
|
const registered = registeredToolNames();
|
|
const phantom = buildToolInventoryLines()
|
|
.map((l) => l.name)
|
|
.filter((n) => !registered.has(n))
|
|
.sort();
|
|
assert.deepEqual(
|
|
phantom,
|
|
[],
|
|
`<tool_inventory> lists tools that are NOT registered: ${phantom.join(", ")}`,
|
|
);
|
|
});
|
|
|
|
// #411: the external MCP surface gains update_page_markdown and LOSES
|
|
// import_page_markdown (now inAppOnly). The in-app agent still keeps
|
|
// importPageMarkdown — asserted in the server-side contract spec.
|
|
test("update_page_markdown is on the MCP surface; import_page_markdown is NOT", () => {
|
|
const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
|
|
assert.ok(
|
|
inventory.has("update_page_markdown"),
|
|
"update_page_markdown should be registered on the external MCP surface",
|
|
);
|
|
assert.ok(
|
|
!inventory.has("import_page_markdown"),
|
|
"import_page_markdown must be dropped from the external MCP surface (#411)",
|
|
);
|
|
// And the routing prose no longer points MCP clients at it.
|
|
assert.ok(
|
|
!ROUTING_PROSE.includes("import_page_markdown"),
|
|
"ROUTING_PROSE still mentions the removed import_page_markdown",
|
|
);
|
|
assert.ok(
|
|
ROUTING_PROSE.includes("update_page_markdown"),
|
|
"ROUTING_PROSE should mention update_page_markdown",
|
|
);
|
|
});
|
|
|
|
test("every inventory line has a non-empty purpose", () => {
|
|
for (const line of buildToolInventoryLines()) {
|
|
assert.equal(typeof line.purpose, "string");
|
|
assert.ok(line.purpose.trim().length > 0, `${line.name}: empty purpose`);
|
|
}
|
|
});
|
|
|
|
test("SERVER_INSTRUCTIONS keeps the routing prose and the generated inventory", () => {
|
|
assert.equal(typeof SERVER_INSTRUCTIONS, "string");
|
|
assert.ok(SERVER_INSTRUCTIONS.length > 0, "SERVER_INSTRUCTIONS is empty");
|
|
// Routing prose is spliced in verbatim (the hand-written intent hints).
|
|
assert.ok(
|
|
SERVER_INSTRUCTIONS.startsWith(ROUTING_PROSE),
|
|
"the routing prose is not preserved at the head of the guide",
|
|
);
|
|
// The generated inventory block is present.
|
|
assert.match(SERVER_INSTRUCTIONS, /<tool_inventory>/);
|
|
assert.match(SERVER_INSTRUCTIONS, /<\/tool_inventory>/);
|
|
// The routing families are still present in the prose.
|
|
for (const family of ["READ:", "EDIT:", "PAGES:", "COMMENTS:", "HISTORY:"]) {
|
|
assert.ok(
|
|
SERVER_INSTRUCTIONS.includes(family),
|
|
`routing prose lost its ${family} section`,
|
|
);
|
|
}
|
|
});
|