From b372592d53e5f596f1289045259053e771975372 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 9 Jul 2026 16:36:22 +0800 Subject: [PATCH] =?UTF-8?q?test(mcp):=20skill=20=E2=86=94=20tool-surface?= =?UTF-8?q?=20drift=20guard=20(#2714=20Phase=200=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/skill-surface-drift-guard.md | 12 +++ packages/mcp/src/skill-surface-guard.test.ts | 88 ++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 .changeset/skill-surface-drift-guard.md create mode 100644 packages/mcp/src/skill-surface-guard.test.ts diff --git a/.changeset/skill-surface-drift-guard.md b/.changeset/skill-surface-drift-guard.md new file mode 100644 index 0000000000..fa26b3f3fd --- /dev/null +++ b/.changeset/skill-surface-drift-guard.md @@ -0,0 +1,12 @@ +--- +'@objectstack/mcp': patch +--- + +test(mcp): drift guard — SKILL.md must document every registered native tool + +The registered surface is obtained by driving the real registration path (a +`tools/list` round-trip against `MCPServerRuntime` with a full data+action +bridge), not a hand-maintained list, so adding a tool to `mcp-http-tools.ts` +without teaching `skill.ts` fails the suite. Guards against a recurrence of +the 7-of-9 gap fixed in #2715; red-proven by temporarily removing +`run_action` from the skill. diff --git a/packages/mcp/src/skill-surface-guard.test.ts b/packages/mcp/src/skill-surface-guard.test.ts new file mode 100644 index 0000000000..4677cb96e8 --- /dev/null +++ b/packages/mcp/src/skill-surface-guard.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpDataBridge, McpActionBridge } from './mcp-http-tools.js'; +import { renderSkillMarkdown } from './skill.js'; + +/** + * Drift guard: the generated SKILL.md must document every native tool the + * HTTP MCP surface actually registers. + * + * The gap this guards against is real: the action tools (#2307) shipped with + * doc updates, but `skill.ts` was written later against the CRUD set only and + * silently documented 7 of 9 tools (fixed in #2715). The skill is the single + * source every distribution shell copies (ADR-0036 Amendment C), so an + * undocumented tool here means every agent installing the skill never learns + * the tool exists. + * + * The registered surface is obtained by driving the REAL registration path — + * a `tools/list` round-trip against `MCPServerRuntime` with a full + * data+action bridge — not from a hand-maintained name list, so adding a new + * tool to `mcp-http-tools.ts` without teaching the skill turns this red. + */ + +/** Minimal full-surface bridge: object + action methods present, all stubbed. */ +function makeFullBridge(): McpDataBridge & McpActionBridge { + return { + async listObjects() { + return []; + }, + async describeObject() { + return null; + }, + async query() { + return { records: [] }; + }, + async get() { + return null; + }, + async create() { + return {}; + }, + async update() { + return {}; + }, + async remove() { + return {}; + }, + async listActions() { + return []; + }, + async runAction() { + return {}; + }, + }; +} + +async function listRegisteredToolNames(): Promise { + const runtime = new MCPServerRuntime({ name: 'skill-guard', version: '0.0.0' }); + const body = { jsonrpc: '2.0', id: 1, method: 'tools/list' }; + const res = await runtime.handleHttpRequest( + new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify(body), + }), + { bridge: makeFullBridge(), parsedBody: body }, + ); + const json: any = await res.json(); + const tools: Array<{ name: string }> = json?.result?.tools ?? []; + return tools.map((t) => t.name); +} + +describe('SKILL.md ↔ native tool surface drift guard', () => { + it('documents every tool the HTTP MCP surface registers', async () => { + const registered = await listRegisteredToolNames(); + // Sanity: the harness must see the full surface, or the guard guards nothing. + expect(registered.length).toBeGreaterThanOrEqual(9); + + const md = renderSkillMarkdown(); + const undocumented = registered.filter((name) => !md.includes(name)); + expect(undocumented, `tools registered but missing from SKILL.md — update packages/mcp/src/skill.ts: ${undocumented.join(', ')}`).toEqual([]); + }); +});