Skip to content

Commit 6193e57

Browse files
claude[bot]claude
andauthored
fix(mcp): pin the tool bridge's two hand-copied safety name sets in the direction the old pin could not see (#13888)
* wip: export mcp safety name sets + drift pin * wip: changeset for the mcp safety-set drift pin --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e4dc299 commit 6193e57

3 files changed

Lines changed: 172 additions & 12 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/mcp": patch
3+
---
4+
5+
fix(mcp): pin the tool bridge's two hand-copied safety name sets in the direction the old pin could not see (#13486)
6+
7+
`mcp-server-runtime.ts` keeps two literal name sets — `PLATFORM_READ_ONLY_TOOL_NAMES`
8+
and `PLATFORM_DESTRUCTIVE_TOOL_NAMES` — that `safetyAnnotations` consults to decide a
9+
bridged tool's `readOnlyHint` / `destructiveHint` when the definition declares nothing.
10+
Both are hand copies of `PLATFORM_TOOLS_BY_PACKAGE` (`@objectstack/spec/system`), and
11+
the docblock claimed a sibling pin held them there.
12+
13+
It held them in one direction only. That pin bridges `[...PLATFORM_PROVIDED_TOOL_NAMES]`
14+
and asserts every annotated name is in the registry, so its **iteration source is the
15+
registry**: it sees a name added to a local set that the platform never registers. A name
16+
**withdrawn** from `PLATFORM_TOOLS_BY_PACKAGE` while it stays in a local set is not among
17+
the tools it bridges at all — nothing drives it, nothing is annotated, and the case stays
18+
green over exactly the drift it is named for.
19+
20+
The harm in that direction is not "a tool the platform no longer registers keeps a hint".
21+
These sets annotate **by name**, so once a name leaves the registry, a **plugin**
22+
registering a tool of that name inherits a `readOnlyHint` it never declared — a read-only
23+
promise the plugin may not honour, handed to it by a stale literal. That is what makes the
24+
gap worth closing while the data is still clean.
25+
26+
The new pin iterates the thing that can drift — the two sets — and checks each name
27+
against the registry, plus a coverage guard that fails if the module exports a
28+
name-keyed safety set the pin does not cover. Reaching the sets from a test required
29+
exporting them from `mcp-server-runtime.ts`; they are deliberately **not** re-exported
30+
from `index.ts`, so `dist/index.d.ts` and the package's published surface are unchanged.
31+
No runtime behaviour changes: no name was added, removed or reclassified, and all six
32+
were re-verified present in the registry (size 30).
33+
34+
`worldAnnotation` is untouched on purpose. It reads `PLATFORM_PROVIDED_TOOL_NAMES`
35+
directly, so derivation and pin share one source and a withdrawn name simply stops being
36+
annotated — the shape that does not get this disease, kept as the contrast.

packages/mcp/src/mcp-server-runtime.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,36 @@ interface ObjectDef {
6363
* Every name here is a tool the cloud AI runtime registers statically
6464
* (`PLATFORM_TOOLS_BY_PACKAGE` in `@objectstack/spec/system`) and hands to
6565
* this bridge through the AI service's `ToolRegistry` carrying no
66-
* `requiresConfirmation`. A sibling pin holds both sets to that registry, so
67-
* the lists cannot drift back into folklore: a name the platform does not
68-
* register is a name this bridge knows nothing about.
66+
* `requiresConfirmation`. Two sibling pins hold both sets to that registry —
67+
* one per direction — so the lists cannot drift back into folklore: a name the
68+
* platform does not register is a name this bridge knows nothing about.
69+
*
70+
* WHY BOTH SETS ARE EXPORTED, AND FOR WHAT. The older pin (`no tool outside
71+
* PLATFORM_PROVIDED_TOOL_NAMES receives a hint it did not declare`) bridges
72+
* `[...PLATFORM_PROVIDED_TOOL_NAMES]` and asserts every annotated name is in
73+
* it. Its ITERATION SOURCE is the registry, so it can only ever see
74+
* local-has → registry-lacks. The reverse — a name WITHDRAWN from
75+
* `PLATFORM_TOOLS_BY_PACKAGE` while it stays in a set here — is not among the
76+
* tools that pin bridges at all: nothing drives it, nothing is annotated, and
77+
* the case stays green over exactly the drift it is named for.
78+
*
79+
* ⚠️ That silent direction is the dangerous one, and NOT because a tool the
80+
* platform no longer registers keeps a hint. These sets annotate BY NAME. Once
81+
* a name leaves the registry, a PLUGIN registering a tool of that name
82+
* inherits a `readOnlyHint` it never declared — a read-only promise the plugin
83+
* may not honour, handed to it by a stale literal in this file. So the second
84+
* pin iterates the thing that can drift, which is these two sets, and that
85+
* requires reaching them from outside this module. They are exported for that
86+
* and are deliberately NOT re-exported from `index.ts`: the package's
87+
* published surface is unchanged, and a new name-keyed set added here must be
88+
* exported too or the pin's coverage guard goes red.
89+
*
90+
* ⛔ Do not "repair" this by filtering the literals through
91+
* `PLATFORM_PROVIDED_TOOL_NAMES` at construction. That absorbs the drift
92+
* instead of reporting it — the withdrawn name would simply stop annotating,
93+
* nothing would go red, and the folklore would stay in this file forever. The
94+
* pin exists to make a withdrawn name LOUD, in CI, at the one moment somebody
95+
* can still delete it.
6996
*
7097
* ⛔ What the fallback must never do again is answer for tools it does NOT
7198
* contain. These two sets used to be the ONLY source of both hints, so the
@@ -80,7 +107,7 @@ interface ObjectDef {
80107
* registers it — annotated `readOnlyHint: true` — at its own registration site
81108
* in `mcp-http-tools.ts`, and never reaches this path.
82109
*/
83-
const PLATFORM_READ_ONLY_TOOL_NAMES = new Set([
110+
export const PLATFORM_READ_ONLY_TOOL_NAMES: ReadonlySet<string> = new Set([
84111
'list_objects',
85112
'describe_object',
86113
'query_records',
@@ -90,9 +117,10 @@ const PLATFORM_READ_ONLY_TOOL_NAMES = new Set([
90117

91118
/**
92119
* The destructive half of the same platform-name fallback — see
93-
* {@link PLATFORM_READ_ONLY_TOOL_NAMES} for what it is and is not for.
120+
* {@link PLATFORM_READ_ONLY_TOOL_NAMES} for what it is and is not for, and for
121+
* why both halves are exported to a test rather than kept private.
94122
*/
95-
const PLATFORM_DESTRUCTIVE_TOOL_NAMES = new Set([
123+
export const PLATFORM_DESTRUCTIVE_TOOL_NAMES: ReadonlySet<string> = new Set([
96124
'delete_field',
97125
]);
98126

packages/mcp/src/mcp-tool-bridge-safety-annotations.test.ts

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6565
import type { AIToolDefinition, ToolCallPart } from '@objectstack/spec/contracts';
6666
import { PLATFORM_PROVIDED_TOOL_NAMES } from '@objectstack/spec/system';
6767

68-
import { MCPServerRuntime } from './mcp-server-runtime.js';
68+
import * as serverRuntimeModule from './mcp-server-runtime.js';
69+
import {
70+
MCPServerRuntime,
71+
PLATFORM_READ_ONLY_TOOL_NAMES,
72+
PLATFORM_DESTRUCTIVE_TOOL_NAMES,
73+
} from './mcp-server-runtime.js';
6974
import type { ToolRegistry, ToolExecutionResult } from './types.js';
7075

7176
// ---------------------------------------------------------------------------
@@ -278,11 +283,18 @@ describe('bridgeTools — the safety annotations a client receives', () => {
278283
});
279284

280285
/**
281-
* The invariant that keeps the name fallback from drifting back into
282-
* folklore, asserted from OUTSIDE the module (the two sets are private):
283-
* only a name the platform itself registers may receive a hint it did not
284-
* declare. Driving every platform name at once also proves the fallback is a
285-
* SUBSET of that registry rather than merely overlapping it.
286+
* ONE of the two directions that keep the name fallback from drifting back
287+
* into folklore: only a name the platform itself registers may receive a
288+
* hint it did not declare. Driving every platform name at once also proves
289+
* the fallback is a SUBSET of that registry rather than merely overlapping
290+
* it.
291+
*
292+
* ⚠️ Its ITERATION SOURCE is the registry, which is exactly what bounds it.
293+
* A name WITHDRAWN from `PLATFORM_TOOLS_BY_PACKAGE` while it stays in a
294+
* local set is not among the tools bridged here, so nothing drives it,
295+
* `annotated` never contains it, and this case stays green. The other
296+
* direction is pinned by the sibling describe at the foot of this file,
297+
* which iterates the local sets instead.
286298
*/
287299
it('no tool outside `PLATFORM_PROVIDED_TOOL_NAMES` receives a hint it did not declare', async () => {
288300
const platform = [...PLATFORM_PROVIDED_TOOL_NAMES].map((name) => tool(name));
@@ -404,3 +416,87 @@ describe('bridgeTools — the safety annotations a client receives', () => {
404416
expect(hasHint(s.byName.action_close_deal.annotations, 'openWorldHint')).toBe(false);
405417
});
406418
});
419+
420+
// ---------------------------------------------------------------------------
421+
422+
/**
423+
* THE DIRECTION THE CASE ABOVE CANNOT SEE (#13486).
424+
*
425+
* `safetyAnnotations` keeps two literal name sets that are hand copies of
426+
* `PLATFORM_TOOLS_BY_PACKAGE`. The registry-driven pin above catches a name
427+
* added to a set but never registered. It is structurally blind to the
428+
* reverse: a name REMOVED from the registry while it stays in a set is simply
429+
* not one of the tools that pin bridges, so nothing drives it and the case
430+
* stays green.
431+
*
432+
* ⚠️ WHY THAT REVERSE MATTERS WHILE THE DATA IS CLEAN. The harm is not "a tool
433+
* the platform no longer registers keeps a hint" — that tool is gone. These
434+
* sets annotate BY NAME, so once a name leaves the registry, a PLUGIN
435+
* registering a tool of that name inherits a `readOnlyHint` it never declared.
436+
* A safety annotation acquired by name collision, from a stale literal.
437+
*
438+
* THE ITERATION SOURCE IS THE POINT. These cases iterate the two sets — the
439+
* thing that can drift — and check each name against the registry. ⛔ The
440+
* names are never re-typed here: a hard-coded list of the six would be a THIRD
441+
* hand copy, i.e. the defect this pins, and it would pin a copy against a copy
442+
* without ever reading what `safetyAnnotations` actually consults.
443+
*
444+
* These cases deliberately do not drive the transport. The wire behaviour of
445+
* both sets is already pinned by the two CONTROL cases at the head of this
446+
* file; what is unpinned is the CONTENT of the sets, which is data.
447+
*/
448+
describe('the hand-maintained safety name sets cannot drift out of the registry', () => {
449+
/**
450+
* The sets under test, keyed by their module-export name so a failure names
451+
* the set to edit. VALUES are imported, never re-typed — see above.
452+
*/
453+
const COVERED_SETS: Readonly<Record<string, ReadonlySet<string>>> = {
454+
PLATFORM_READ_ONLY_TOOL_NAMES,
455+
PLATFORM_DESTRUCTIVE_TOOL_NAMES,
456+
};
457+
458+
it('every name in the two safety sets is still a name the platform registers', () => {
459+
// Non-vacuity first, on both sides: an empty registry would make every
460+
// `has()` below false rather than silently true, but an empty SET would
461+
// make the loop run zero times and pass saying nothing.
462+
expect(PLATFORM_PROVIDED_TOOL_NAMES.size).toBeGreaterThan(0);
463+
464+
const checked: string[] = [];
465+
for (const [setName, names] of Object.entries(COVERED_SETS)) {
466+
expect(names.size).toBeGreaterThan(0);
467+
for (const name of names) {
468+
checked.push(name);
469+
expect(
470+
PLATFORM_PROVIDED_TOOL_NAMES.has(name),
471+
`\`${setName}\` still carries \`${name}\`, which \`PLATFORM_TOOLS_BY_PACKAGE\` no longer registers. ` +
472+
`Delete the name from the set — do NOT widen the registry to match it. ` +
473+
`Left there, any plugin registering a tool called \`${name}\` inherits a safety hint it never declared.`,
474+
).toBe(true);
475+
}
476+
}
477+
expect(checked.length).toBeGreaterThan(0);
478+
});
479+
480+
/**
481+
* ⚠️ The case above can only iterate the sets it was told about. A third
482+
* name-keyed set added to `mcp-server-runtime.ts` would be annotating tools
483+
* with nothing holding its contents to the registry, and no existing
484+
* assertion would notice — the same silence, one set over.
485+
*
486+
* This guard closes that by discovering the sets from the module's own
487+
* exports. It cannot see a set that is left PRIVATE, which is why the source
488+
* docblock instructs the author to export it; what it can do is refuse to
489+
* let an exported one go unpinned.
490+
*/
491+
it('COVERAGE GUARD: every name-keyed safety set the module exports is covered above', () => {
492+
const exported = Object.entries(serverRuntimeModule)
493+
.filter(([name, value]) => /^PLATFORM_[A-Z0-9_]*_TOOL_NAMES$/.test(name) && value instanceof Set)
494+
.map(([name]) => name)
495+
.sort();
496+
497+
// Non-vacuity: without this, a regex that matches nothing would leave two
498+
// empty arrays agreeing with each other.
499+
expect(exported.length).toBeGreaterThan(0);
500+
expect(exported).toEqual(Object.keys(COVERED_SETS).sort());
501+
});
502+
});

0 commit comments

Comments
 (0)