@@ -65,7 +65,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6565import type { AIToolDefinition , ToolCallPart } from '@objectstack/spec/contracts' ;
6666import { 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' ;
6974import 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 ] ) => / ^ P L A T F O R M _ [ A - Z 0 - 9 _ ] * _ T O O L _ N A M E S $ / . 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