1fda0ec8b0
The intent-routing guide had rotted: 17 of 41 registered tools were absent (get_outline, get_node, the whole table_* family, search, stash_page, sharing, page lifecycle), and two tips were actively harmful — 'read block ids via get_page_json' told agents to pull the whole ~100KB document when get_outline exists precisely to grab ids cheaply, and 'table cell -> patch_node by attrs.id' dead-ends because table nodes carry no attrs.id. - Rewrite SERVER_INSTRUCTIONS as intent clusters (READ / EDIT / PAGES / COMMENTS / HISTORY) covering every tool except get_workspace; add safety notes (share_page = PUBLIC, delete_page = soft) and a comment-anchor markup warning for get_page. - delete_page tool description: state SOFT delete / restorable explicitly. - MAINTENANCE RULE comments at both registration sites (index.ts, tool-specs.ts) + an AGENTS.md convention bullet: adding/renaming/removing a tool REQUIRES updating the guide. - New guard test (test/unit/server-instructions.test.mjs): extracts every registered tool name from source and fails when one is not mentioned in the shipped SERVER_INSTRUCTIONS (word-boundary match, so get_page can't hide behind get_page_json); EXCEPTIONS list is itself validated against the registry. SERVER_INSTRUCTIONS exported for the test. Tests: @docmost/mcp 450/450 (448 + 2 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
83 lines
3.3 KiB
JavaScript
83 lines
3.3 KiB
JavaScript
// Guard: every tool the MCP server registers must be routed by intent in
|
|
// SERVER_INSTRUCTIONS — the editing guide clients receive in the initialize
|
|
// result. Without this, new tools silently rot out of the guide and agents
|
|
// never learn to pick them (the guide once omitted 17 of 41 tools, including
|
|
// get_outline, which pushed agents into fetching whole documents for block
|
|
// ids). Tool names are extracted from the SOURCE (index.ts inline
|
|
// registrations + tool-specs.ts shared specs) so a registration added either
|
|
// way is caught; the guide text itself is imported from the build so the test
|
|
// checks what actually ships.
|
|
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 } from "../../build/index.js";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const SRC = join(HERE, "..", "..", "src");
|
|
|
|
// Tools DELIBERATELY absent from the guide. Keep this list minimal and
|
|
// justify every entry — the default is: every tool gets routed.
|
|
const EXCEPTIONS = new Set([
|
|
// Trivial and self-explanatory; carries no routing decision.
|
|
"get_workspace",
|
|
]);
|
|
|
|
/**
|
|
* Extract every registered tool name from the source. Two registration
|
|
* mechanisms exist and both are covered:
|
|
* - inline `server.registerTool("name", ...)` calls in index.ts;
|
|
* - shared specs in tool-specs.ts (`mcpName: 'name'`), registered via
|
|
* registerShared(SHARED_TOOL_SPECS.x, ...).
|
|
*/
|
|
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]);
|
|
}
|
|
for (const m of specsSrc.matchAll(/mcpName:\s*['"]([a-z0-9_]+)['"]/g)) {
|
|
names.add(m[1]);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
test("every registered tool is mentioned in SERVER_INSTRUCTIONS", () => {
|
|
const names = registeredToolNames();
|
|
// Sanity: if extraction regressed (regex drift), fail loudly rather than
|
|
// vacuously passing on an empty set.
|
|
assert.ok(
|
|
names.size >= 40,
|
|
`sanity: expected to extract 40+ registered tools, got ${names.size} — ` +
|
|
"the extraction regexes in this test likely drifted from the source",
|
|
);
|
|
const missing = [...names]
|
|
.filter((n) => !EXCEPTIONS.has(n))
|
|
// \b<name>\b: `_` is a word char, so \bget_page\b does NOT match inside
|
|
// get_page_json — a tool can't hide behind a longer sibling's mention.
|
|
.filter((n) => !new RegExp(`\\b${n}\\b`).test(SERVER_INSTRUCTIONS))
|
|
.sort();
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
`tools missing from SERVER_INSTRUCTIONS: ${missing.join(", ")} — ` +
|
|
"update the guide in packages/mcp/src/index.ts (see its MAINTENANCE " +
|
|
"RULE comment), or add a justified entry to EXCEPTIONS here",
|
|
);
|
|
});
|
|
|
|
test("EXCEPTIONS entries are real registered tools", () => {
|
|
// A stale exception (tool renamed/removed) must be cleaned up, otherwise
|
|
// the list quietly grows past its purpose.
|
|
const names = registeredToolNames();
|
|
for (const name of EXCEPTIONS) {
|
|
assert.ok(
|
|
names.has(name),
|
|
`EXCEPTIONS entry "${name}" is not a registered tool — remove it`,
|
|
);
|
|
}
|
|
});
|