3636 * omission, and the SDK's own `ToolAnnotationsSchema` documents the defaults
3737 * that then apply (`readOnlyHint` false, `destructiveHint` **true**), which is
3838 * the conservative reading the old `false` inverted.
39+ *
40+ * THE SECOND DEFECT, PINNED HERE TOO. `openWorldHint: false` was asserted for
41+ * every bridged tool from no source at all — the sibling of the above, one
42+ * hint later. It is now sourced the same way the `readOnlyHint` fallback is,
43+ * from platform-registered names (`PLATFORM_PROVIDED_TOOL_NAMES`), and
44+ * omitted for everyone else.
45+ *
46+ * ⚠️ AND ITS OMISSION IS PINNED DIFFERENTLY, ON PURPOSE. The same SDK schema
47+ * documents `openWorldHint` as **`Default: true`**, so an omitted world hint
48+ * is read as an OPEN world — the one place in this file where absence is not
49+ * the cautious answer, only the honest one. A future reader tempted to make
50+ * the three hints behave alike has to make these pins red first, which is the
51+ * point of stating it here as well as at the call site.
52+ *
53+ * ⚠️ ABSENCE MEANS AN ABSENT KEY, and these cases say so with
54+ * `Object.hasOwn`, not with `toBeUndefined()`. A spread of
55+ * `{ openWorldHint: undefined }` would satisfy `toBeUndefined()` while still
56+ * putting the property on the object; every such case below carries a
57+ * same-object positive control (a platform tool listed in the same call) so a
58+ * `false` from {@link hasHint} is a reading rather than a typo'd key name.
3959 */
4060
4161import { describe , it , expect , afterEach } from 'vitest' ;
@@ -155,6 +175,19 @@ async function annotationsOf(
155175 return { session, byName : Object . fromEntries ( listed . map ( ( t : any ) => [ t . name , t ] ) ) } ;
156176}
157177
178+ /**
179+ * Own-property presence on the PARSED WIRE object.
180+ *
181+ * ⚠️ Not `toBeUndefined()`, which a spread of `{ openWorldHint: undefined }`
182+ * would also satisfy while still putting the property on the object — the
183+ * distinction these cases exist to make. ⛔ And not `Object`.`hasOwn` either:
184+ * that is ES2022 and this repo compiles at `lib: ["ES2020"]`, so it type-errors
185+ * where the runtime (Node 22) would have run it happily — a gap this package's
186+ * own `typecheck` cannot report, because its tsconfig excludes test files.
187+ */
188+ const hasHint = ( annotations : Record < string , unknown > | undefined , hint : string ) : boolean =>
189+ Object . prototype . hasOwnProperty . call ( annotations ?? { } , hint ) ;
190+
158191const tool = ( name : string , extra : Partial < AIToolDefinition > = { } ) : AIToolDefinition => ( {
159192 name,
160193 description : `the ${ name } tool` ,
@@ -215,11 +248,13 @@ describe('bridgeTools — the safety annotations a client receives', () => {
215248 const s = await annotationsOf ( [ tool ( 'send_invoice_email' ) ] ) ;
216249 openSession = s . session ;
217250
218- const annotations = s . byName . send_invoice_email . annotations ;
251+ const annotations = s . byName . send_invoice_email . annotations ?? { } ;
219252 expect ( annotations . destructiveHint ) . toBeUndefined ( ) ;
220253 expect ( annotations . readOnlyHint ) . toBeUndefined ( ) ;
221- // The hint the bridge does still assert for every tool, unchanged here.
222- expect ( annotations . openWorldHint ) . toBe ( false ) ;
254+ // ...and no world hint either. `send_invoice_email` is the case in the
255+ // name: an app tool that reaches an outbound service was being told to
256+ // every client as closed-world.
257+ expect ( hasHint ( annotations , 'openWorldHint' ) ) . toBe ( false ) ;
223258 } ) ;
224259
225260 it ( 'what the definition declares outranks what its name suggests' , async ( ) => {
@@ -237,6 +272,9 @@ describe('bridgeTools — the safety annotations a client receives', () => {
237272 expect ( PLATFORM_PROVIDED_TOOL_NAMES . has ( 'aggregate_records' ) ) . toBe ( false ) ;
238273 expect ( s . byName . aggregate_records . annotations . readOnlyHint ) . toBeUndefined ( ) ;
239274 expect ( s . byName . aggregate_records . annotations . destructiveHint ) . toBeUndefined ( ) ;
275+ // Nor a world hint from this bridge. It gets one at its OWN registration
276+ // site in `mcp-http-tools.ts`, which is where that fact is known.
277+ expect ( hasHint ( s . byName . aggregate_records . annotations , 'openWorldHint' ) ) . toBe ( false ) ;
240278 } ) ;
241279
242280 /**
@@ -274,4 +312,95 @@ describe('bridgeTools — the safety annotations a client receives', () => {
274312 expect ( s . byName [ stranger . name ] . annotations . destructiveHint ) . toBeUndefined ( ) ;
275313 }
276314 } ) ;
315+
316+ // ── openWorldHint ────────────────────────────────────────────────────────
317+
318+ it ( 'CONTROL: a platform-registered name still receives `openWorldHint: false`' , async ( ) => {
319+ const s = await annotationsOf ( [
320+ tool ( 'query_records' ) ,
321+ tool ( 'list_objects' ) ,
322+ // Platform names OUTSIDE the two safety-class sets. These are the tools
323+ // a fallback keyed on those sets instead of the registry would have
324+ // silently flipped to the protocol's open-world default.
325+ tool ( 'create_object' ) ,
326+ tool ( 'list_metadata' ) ,
327+ tool ( 'describe_metadata' ) ,
328+ ] ) ;
329+ openSession = s . session ;
330+
331+ for ( const name of [ 'query_records' , 'list_objects' , 'create_object' , 'list_metadata' , 'describe_metadata' ] ) {
332+ expect ( PLATFORM_PROVIDED_TOOL_NAMES . has ( name ) ) . toBe ( true ) ;
333+ expect ( s . byName [ name ] . annotations . openWorldHint ) . toBe ( false ) ;
334+ }
335+ // The safety hints are sourced separately: `create_object` is a platform
336+ // name in NEITHER safety set, so it keeps the world hint and no other.
337+ expect ( s . byName . create_object . annotations . readOnlyHint ) . toBeUndefined ( ) ;
338+ expect ( s . byName . create_object . annotations . destructiveHint ) . toBeUndefined ( ) ;
339+ } ) ;
340+
341+ it ( 'an app-registered tool receives NO `openWorldHint` KEY — absence, not `undefined`' , async ( ) => {
342+ const s = await annotationsOf ( [
343+ tool ( 'check_weather' ) ,
344+ tool ( 'ask_llm' , { requiresConfirmation : false } ) ,
345+ tool ( 'delete_opportunity' , { requiresConfirmation : true } ) ,
346+ // Positive control in the same `tools/list` answer: `hasOwn` must be
347+ // able to say `true` about this exact wire object, or the `false`s
348+ // below would be indistinguishable from a misspelled key.
349+ tool ( 'query_records' ) ,
350+ ] ) ;
351+ openSession = s . session ;
352+
353+ expect ( hasHint ( s . byName . query_records . annotations , 'openWorldHint' ) ) . toBe ( true ) ;
354+
355+ for ( const name of [ 'check_weather' , 'ask_llm' , 'delete_opportunity' ] ) {
356+ const annotations = s . byName [ name ] . annotations ?? { } ;
357+ expect ( hasHint ( annotations , 'openWorldHint' ) ) . toBe ( false ) ;
358+ expect ( annotations . openWorldHint ) . toBeUndefined ( ) ;
359+ }
360+
361+ // Independently sourced: declaring `requiresConfirmation` buys the SAFETY
362+ // hints and buys nothing about the world, which is the whole point of the
363+ // two derivations being separate.
364+ expect ( s . byName . delete_opportunity . annotations . destructiveHint ) . toBe ( true ) ;
365+ expect ( s . byName . ask_llm . annotations . destructiveHint ) . toBe ( false ) ;
366+ } ) ;
367+
368+ /**
369+ * The world-hint counterpart of the fallback invariant above, driven across
370+ * the whole registry at once: `openWorldHint: false` is a claim the platform
371+ * can source about the tools it registers, and about nothing else.
372+ */
373+ it ( 'exactly the platform-registered names carry `openWorldHint`, and every one of them carries `false`' , async ( ) => {
374+ const platform = [ ...PLATFORM_PROVIDED_TOOL_NAMES ] . map ( ( name ) => tool ( name ) ) ;
375+ const strangers = [
376+ 'aggregate_records' ,
377+ 'action_close_deal' ,
378+ 'check_weather' ,
379+ 'send_invoice_email' ,
380+ 'void_invoice' ,
381+ ] . map ( ( name ) => tool ( name ) ) ;
382+
383+ const s = await annotationsOf ( [ ...platform , ...strangers ] ) ;
384+ openSession = s . session ;
385+
386+ const withWorldHint = Object . values ( s . byName )
387+ . filter ( ( t : any ) => hasHint ( t . annotations , 'openWorldHint' ) )
388+ . map ( ( t : any ) => t . name )
389+ . sort ( ) ;
390+
391+ // ⚠️ Non-vacuity first: `toEqual` between two empty arrays passes, so an
392+ // unbuilt or empty registry would make every assertion below say nothing.
393+ expect ( PLATFORM_PROVIDED_TOOL_NAMES . size ) . toBeGreaterThan ( 0 ) ;
394+ expect ( withWorldHint . length ) . toBe ( PLATFORM_PROVIDED_TOOL_NAMES . size ) ;
395+ expect ( withWorldHint ) . toEqual ( [ ...PLATFORM_PROVIDED_TOOL_NAMES ] . sort ( ) ) ;
396+ for ( const name of withWorldHint ) {
397+ expect ( s . byName [ name ] . annotations . openWorldHint ) . toBe ( false ) ;
398+ }
399+
400+ // `action_close_deal` is the family case stated explicitly: the runtime
401+ // materialises `action_<name>` wrappers around an app's OWN actions, so a
402+ // membership test widened to `PLATFORM_TOOL_FAMILY_PREFIXES` would put
403+ // this bridge back to claiming a closed world over app-defined behaviour.
404+ expect ( hasHint ( s . byName . action_close_deal . annotations , 'openWorldHint' ) ) . toBe ( false ) ;
405+ } ) ;
277406} ) ;
0 commit comments