Skip to content

Commit 148beb4

Browse files
os-zhuangclaude
andauthored
test(mcp): skill ↔ tool-surface drift guard (#2714 Phase 0 follow-up) (#2718)
Asserts every tool registered on the HTTP MCP surface appears in the generated SKILL.md, deriving the surface from a real tools/list round-trip rather than a name list. Red-proven: stripping run_action from skill.ts fails with a pointed message. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5a92341 commit 148beb4

2 files changed

Lines changed: 100 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
test(mcp): drift guard — SKILL.md must document every registered native tool
6+
7+
The registered surface is obtained by driving the real registration path (a
8+
`tools/list` round-trip against `MCPServerRuntime` with a full data+action
9+
bridge), not a hand-maintained list, so adding a tool to `mcp-http-tools.ts`
10+
without teaching `skill.ts` fails the suite. Guards against a recurrence of
11+
the 7-of-9 gap fixed in #2715; red-proven by temporarily removing
12+
`run_action` from the skill.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
import { MCPServerRuntime } from './mcp-server-runtime.js';
6+
import type { McpDataBridge, McpActionBridge } from './mcp-http-tools.js';
7+
import { renderSkillMarkdown } from './skill.js';
8+
9+
/**
10+
* Drift guard: the generated SKILL.md must document every native tool the
11+
* HTTP MCP surface actually registers.
12+
*
13+
* The gap this guards against is real: the action tools (#2307) shipped with
14+
* doc updates, but `skill.ts` was written later against the CRUD set only and
15+
* silently documented 7 of 9 tools (fixed in #2715). The skill is the single
16+
* source every distribution shell copies (ADR-0036 Amendment C), so an
17+
* undocumented tool here means every agent installing the skill never learns
18+
* the tool exists.
19+
*
20+
* The registered surface is obtained by driving the REAL registration path —
21+
* a `tools/list` round-trip against `MCPServerRuntime` with a full
22+
* data+action bridge — not from a hand-maintained name list, so adding a new
23+
* tool to `mcp-http-tools.ts` without teaching the skill turns this red.
24+
*/
25+
26+
/** Minimal full-surface bridge: object + action methods present, all stubbed. */
27+
function makeFullBridge(): McpDataBridge & McpActionBridge {
28+
return {
29+
async listObjects() {
30+
return [];
31+
},
32+
async describeObject() {
33+
return null;
34+
},
35+
async query() {
36+
return { records: [] };
37+
},
38+
async get() {
39+
return null;
40+
},
41+
async create() {
42+
return {};
43+
},
44+
async update() {
45+
return {};
46+
},
47+
async remove() {
48+
return {};
49+
},
50+
async listActions() {
51+
return [];
52+
},
53+
async runAction() {
54+
return {};
55+
},
56+
};
57+
}
58+
59+
async function listRegisteredToolNames(): Promise<string[]> {
60+
const runtime = new MCPServerRuntime({ name: 'skill-guard', version: '0.0.0' });
61+
const body = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
62+
const res = await runtime.handleHttpRequest(
63+
new Request('http://localhost/api/v1/mcp', {
64+
method: 'POST',
65+
headers: {
66+
'content-type': 'application/json',
67+
accept: 'application/json, text/event-stream',
68+
},
69+
body: JSON.stringify(body),
70+
}),
71+
{ bridge: makeFullBridge(), parsedBody: body },
72+
);
73+
const json: any = await res.json();
74+
const tools: Array<{ name: string }> = json?.result?.tools ?? [];
75+
return tools.map((t) => t.name);
76+
}
77+
78+
describe('SKILL.md ↔ native tool surface drift guard', () => {
79+
it('documents every tool the HTTP MCP surface registers', async () => {
80+
const registered = await listRegisteredToolNames();
81+
// Sanity: the harness must see the full surface, or the guard guards nothing.
82+
expect(registered.length).toBeGreaterThanOrEqual(9);
83+
84+
const md = renderSkillMarkdown();
85+
const undocumented = registered.filter((name) => !md.includes(name));
86+
expect(undocumented, `tools registered but missing from SKILL.md — update packages/mcp/src/skill.ts: ${undocumented.join(', ')}`).toEqual([]);
87+
});
88+
});

0 commit comments

Comments
 (0)