Skip to content

fix(mcp): scope the tool bridge's openWorldHint: false to platform-registered names - #13485

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-13350-openworldhint-platform-scope
Aug 30, 2026
Merged

fix(mcp): scope the tool bridge's openWorldHint: false to platform-registered names#13485
os-trump merged 2 commits into
mainfrom
claude/issue-13350-openworldhint-platform-scope

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#13350

Implements the ruled repair: option 2 now, option 1 as follow-up (maintainer, 2026-08-30 12:21:51Z, 第 5 場總監席決裁批 #1, verbatim「第一批其餘同意」). The bridge asserts openWorldHint: false for platform-registered names only; everything else — app-registered tools included — omits the annotation entirely. ⛔ packages/spec/** is untouched: no member added to AIToolDefinition, no clause ② surface moved.

Measured at 7280f39e83 — the commit this branch carries and the commit every verdict below was produced at.

The defect

registerToolFromDefinition in packages/mcp/src/mcp-server-runtime.ts put a bare openWorldHint: false in every bridged tool's annotations, from no source at all. AIToolDefinition has no member expressing the hint, so the false was a property of that file served to every MCP client as a property of the tool. An app tool reaching a weather API, an LLM or any outbound service was announced as having a closed, well-defined domain of interaction.

The mechanism reused, and where it came from

The crux was to find how the readOnlyHint fallback already identifies platform-owned names in this same function and reuse it, rather than mint a fresh allowlist. Measured on origin/main:

safetyAnnotations() → PLATFORM_READ_ONLY_TOOL_NAMES.has(tool.name) (5 names)
→ PLATFORM_DESTRUCTIVE_TOOL_NAMES.has(tool.name) (1 name)

Both are membership tests against hand-held subsets of one canonical registry, and the file says so at line 63: "Every name here is a tool the cloud AI runtime registers statically (PLATFORM_TOOLS_BY_PACKAGE in @objectstack/spec/system)". The pin that holds them there is mcp-tool-bridge-safety-annotations.test.ts, which already imports PLATFORM_PROVIDED_TOOL_NAMES from @objectstack/spec/system and asserts "no tool outside PLATFORM_PROVIDED_TOOL_NAMES receives a hint it did not declare".

So the mechanism exists and it can carry this hint. The new worldAnnotation() is the same shape, reading the registry the safety lists are pinned to:

functionworldAnnotation(tool: AIToolDefinition): ToolWorldHint{returnPLATFORM_PROVIDED_TOOL_NAMES.has(tool.name) ? {openWorldHint: false} : {};}

Why the registry and not the two local safety sets. They answer different questions. The safety sets classify a safety class and are deliberately partial — 6 names of the registry's 30. Ownership is the question the world hint needs, and keying it off the safety sets would have dropped create_object, add_field, list_metadata, describe_metadata and twenty more — platform tools whose world is just as closed — to the protocol default. That is option 3's accuracy loss under a narrower name, and option 3 was rejected precisely for it. Registry size measured live at 30, against the same specifier the test resolves.

⛔ Not the dynamic families.PLATFORM_TOOL_FAMILY_PREFIXES materialises action_NAME wrappers around an app's own declarative actions — the platform registers the wrapper, the app defines the behaviour. mcp-http-tools.ts asserts openWorldHint: true for run_action on exactly that reasoning. A pin drives action_close_deal and asserts it receives no key, so widening the membership test to the prefixes goes red.

The trap, written into the code

Verified in the pinned SDK rather than taken on faith — @modelcontextprotocol/sdk 1.30.0, ToolAnnotationsSchema in dist/esm/types.js:

hintdocumented defaultwhat omission means
readOnlyHintDefault: false"not read-only" — cautious, costs information only
destructiveHintDefault: true"may be destructive" — cautious
openWorldHintDefault: true"OPEN world" — not the cautious direction

So the two functions are structurally one rule (assert what the platform can source, omit what it cannot, because MCP has no spelling for "unknown" other than absence) while the price of the omission differs. That asymmetry is stated at the derivation site and again in the test file header, explicitly as a thing not to tidy — because a reader who makes the three hints behave alike reintroduces the defect, and the reader who tries has to turn four pins red first.

Positive controls

All in packages/mcp/src/mcp-tool-bridge-safety-annotations.test.ts, driven through a real StdioServerTransport and read from an actual tools/list answer — what a desktop MCP host receives, not the in-process JS object.

  • Platform tool still emits falsequery_records, list_objects, plus create_object, list_metadata, describe_metadata, the three that a safety-set-keyed fallback would have silently dropped.
  • App tool emits no key at allcheck_weather, ask_llm, delete_opportunity asserted with own-property presence on the parsed wire object, nottoBeUndefined(), which a spread of openWorldHint: undefined would also satisfy. Each such case carries a same-object positive control: query_records is listed in the same call and its own-property check must answer true, so a false is a reading rather than a misspelled key.
  • Registry sweep — all 30 platform names plus 5 strangers in one tools/list; the set carrying the hint must equal the registry exactly, and non-vacuity is asserted first (size greater than 0, and the carrier count equal to it) because toEqual between two empty arrays passes.
  • Independencedelete_opportunity declaring requiresConfirmation: true still gets destructiveHint: true and no world hint: declaring confirmation buys the safety hints and buys nothing about the world.

pnpm --filter @objectstack/mcp testTest Files 25 passed (25) · Tests 269 passed (269).

The card's own re-check, and one place I read it differently

git grep -n openWorldHint -- 'packages/**' on this branch: 11 hits at the mcp-http-tools.ts registration sites (unchanged, including run_action's deliberate true), 2 in mcp-server-runtime.ts, 20 in the pin file.

⚠️ The dispatch brief asked that the grep keep finding the hint "only at annotation-writing sites, never at one that derives it, and your change should not alter that." Taken literally that cannot hold for any implementation of option 2 — deriving the hint from platform-name membership is adding a deriving site, and the card's sentence was a description of the defect (nothing derived it), not a constraint on the repair. What the check still buys is the shape: the grep stays small, and every hit is a site that writes or derives the annotation, none is a consumer widened to tolerate it. Flagging rather than quietly reinterpreting.

Reverse verification (ablation)

Predicted before running: replacing the platform-scoping predicate with the unconditional return { openWorldHint: false }; restores the pre-fix behaviour, so the four absence cases go red while the three platform controls and the four safety-only cases stay green — an asymmetric prediction, because "everything goes red" would not show that the pins discriminate the app branch.

No rebuild is performed on purpose: the pin imports the bridge by a relative specifier, so vitest resolves TypeScript source. Had it been dist-resolved, an un-rebuilt ablation would have stayed green — so the red is itself the proof that the mutated source is what ran.

HEAD blob : 58e73492bbe97ed5e33788278798aa68b645e28b
pre-mutation hash : 58e73492bbe97ed5e33788278798aa68b645e28b (equal — clean start)
injected text count : 1 deleted text count: 0
post-mutation hash : 0ea4f35f641e8b4611c1aaa98efabe035f98ce38 (differs — ablation on disk)
vitest exit under ablation: 1
Tests 4 failed | 7 passed (11)
× a tool that declares nothing is served NEITHER hint — not a fabricated `false`
× `aggregate_records` gets no name-derived hint here …
× an app-registered tool receives NO `openWorldHint` KEY — absence, not `undefined`
× exactly the platform-registered names carry `openWorldHint`, and every one of them carries `false`
restored hash : 58e73492bbe97ed5e33788278798aa68b645e28b (equals the HEAD blob)
injected text after restore: 0 original text after restore: 1
git diff HEAD : [] git status --porcelain : []

Prediction matched exactly: 4 red / 7 green, and the red set is precisely the four absence cases. The script restores with git checkout HEAD -- ABSOLUTE_PATH under trap … EXIT INT TERM, and judges by observed bytes and blob hashes, never by an exit code; an empty hash is treated as failure, not as "nothing to compare".

Gate union

Derived after merging origin/main. Both known defects in the derivation script were read around rather than trusted:

git rev-list --count HEAD..origin/main → 0
dispatch-gates: gate list derived from the tree of 'objectstack-ai/objectstack' at commit 7280f39e83
--repo 'objectstack-ai/objectstack' checked against this checkout's 'origin' remote — it holds.
dispatch-gates: change set derived from git — 3 path(s) vs merge base d38ad7fc5

The behind-count is the reading; the banner is not. It came back 0, and no STALE TREE banner appeared — the two agree, which is what the two known derivation defects each break in one direction.

GREEN — 29 path-matched plus the convention-triggered families:

  • check:changeset-gate-self-tests · check:cross-package-test-inputs · check:doc-authoring · check:logger-receiver-detach · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:pm-half-states · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:where-matcher — all exit 0
  • check-adr-0087-registration · check-changeset-no-major · check-ci-filter-parity · check-comment-mask-adoption · check-cross-package-test-inputs · check-empty-changeset · check-keyed-text-bounds · check-plugin-teardown-shape · check-shard-attestation · check-undeclared-dep-imports · docs-audit/check-affected-docs · docs-audit/check-drift-comment · pm/release-rehearsal-clone --self-test — all exit 0
  • check:query-options-erasure · check:type-check-coverage · check:engine-double-contract · check:dispatcher-error-vocabulary — all exit 0
  • check:nul-bytescheck-nul-bytes: OK (scanned 7448 text file(s) … no raw ASCII control bytes).
  • check:dual-build-cjs-loads — refused on the unbuilt worktree, then green after building the closure: ✓ check:dual-build-cjs-loads — 102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree.
  • check:type-check-debtcheck-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 442.5s, 1547 raw tsc error(s) total, none above its recorded number. / surplus: none — every entry sits exactly at its measurement, so any new error is red.

REFUSALS — recorded separately, NOT folded into the green list. NOT MEASURED is not a pass:

  • node scripts/check-test-completeness.mjsPREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named. Exit 3. The gate's own text says the family list names it with no argument and that the local reading is NOT MEASURED. CI tees the log and passes the path, so CI is unaffected.
  • node scripts/pm/check-half-states.mjsPREREQUISITE NOT MET, exit 3: the container's GITHUB_TOKEN is the proxy placeholder and GET /rate_limit answers 401 with it. Nothing was swept: no issue was listed, no predicate (H1–H16) ran. Not a clean board and not a dirty one.

⭐ The ratchet nearly caught something, and this is why the debt entry matters. The first draft of the pins used Object.hasOwn, which runs fine on Node 22 and passed both the targeted run and the full suite. It is ES2022; this repo compiles at lib: ["ES2020"]. pnpm --filter @objectstack/mcp typecheck could not see it either — packages/mcp/tsconfig.json excludes test files, and tsc --listFiles confirmed the edited test file has 0 hits in that program while mcp-server-runtime.ts has 1. Measured explicitly with a throwaway tests-including tsconfig: 6 errors attributable to the new pins, on top of 53 pre-existing (reverse control: 14 in mcp-action-tools.test.ts, so the grep can find errors that are there). The TEST_DEBT ledger records @objectstack/mcp at exactly errors: 53, frozen — so shipping it would have put the count at 59 and turned the ratchet red in the merge queue, which that entry's own note says has happened to it before. Replaced with an ES2020-safe own-property helper; the tests-including typecheck now reports 0 errors in the edited file and the ratchet is green. The temp tsconfig was deleted and git status --porcelain verified clean afterwards.

Repo-wide pnpm lint was not run locally — it is a repo-scale scan CI owns and runs exactly once regardless. Declared, not silently skipped.

Changeset judgement, by measurement

.changeset/mcp-openworldhint-platform-scope.md, @objectstack/mcp: patch. Judged by building the artifact and grepping it, not by analogy:

The runtime artifact moves.packages/mcp/dist/index.js after pnpm --filter @objectstack/mcp build carries the derivation verbatim —

PLATFORM_PROVIDED_TOOL_NAMES.has(tool.name) ? { openWorldHint: false } : {}

— plus a new from "@objectstack/spec/system" import edge (@objectstack/spec was already a runtime dependency, and mcp-http-tools.ts / stdio-data-bridge.ts already take value imports from sibling spec subpaths, so no dependency is added). What every consumer's MCP clients receive changes.

The type artifact does not.dist/index.d.ts contains openWorldHint and worldAnnotation once each, both inside the JSDoc of a private method; grepping for a declared or exported worldAnnotation returns 0. Positive control on the same file so the zero is a reading: MCPServerRuntime 7 hits, bridgeTools 2 hits.

⇒ behaviour of a published package moves, no exported declaration moves ⇒ patch, and a changeset is owed because it is user-visible. The changeset states the consumer-facing consequence plainly: a host that keyed off a bridged app tool's openWorldHint: false will now find the annotation absent and fall to the protocol's true.

Follow-up, deliberately not done here

Option 1 — a declared member on AIToolDefinition, with action-backed tools inheriting run_action's openWorldHint: true — is the shape that makes the hint true rather than defensible. It is a packages/spec/** public contract extension (clause ②) and was ruled a follow-up. Nothing here anticipates it.

One observation for whoever picks it up: this repair asserts false across all 30 registry names, which is the ruled predicate, but a handful of them (propose_blueprint, suggest_builder) are AI-runtime tools where "closed domain of interaction" is a judgement about the domain rather than about the implementation. A per-tool declared member is where that gets settled individually; a name-membership test cannot.


Generated by Claude Code

os-trumpand others added 2 commits August 30, 2026 14:01
…tered names
`registerToolFromDefinition` asserted `openWorldHint: false` in every bridged
tool's annotations, from no declared source — over every tool an app registers
under its own name as well as the platform's. `AIToolDefinition` has no member
expressing the hint, so the `false` was a property of this file served to every
MCP client as a property of the tool: an app tool reaching a weather API, an
LLM or any outbound service was announced as closed-world.
The hint is now derived in `worldAnnotation()` from
`PLATFORM_PROVIDED_TOOL_NAMES` (`@objectstack/spec/system`) — the same registry
the bridge's `readOnlyHint` name fallback is already pinned to as a subset, so
the two hints read one registry between them. Platform names keep the
known-correct `false`; every other bridged tool is served no `openWorldHint`
key at all.
The asymmetry with the safety hints is deliberate and written down at the
derivation site: SDK 1.30.0 documents `openWorldHint` as `Default: true`, so
omission here is the honest direction rather than the conservative one, unlike
`readOnlyHint` (`Default: false`) and `destructiveHint` (`Default: true`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9cfc1f7e935aa62e46ec9a03ecd49194fd024754packageMentionDocs.

Which tree this was computed on

This run read content/docs from f60eb27d5f2f4a36b1f8fd0b67dffc823825ce49 — the merge of head 7280f39e836ea9d0472f15c77169a5ace4fe5ffc into base 9cfc1f7e935aa62e46ec9a03ecd49194fd024754, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f60eb27d5f2f4a36b1f8fd0b67dffc823825ce49 && git checkout f60eb27d5f2f4a36b1f8fd0b67dffc823825ce49
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9cfc1f7e935aa62e46ec9a03ecd49194fd024754 7280f39e836ea9d0472f15c77169a5ace4fe5ffc && git checkout -B drift-repro 9cfc1f7e935aa62e46ec9a03ecd49194fd024754 && git merge --no-ff 7280f39e836ea9d0472f15c77169a5ace4fe5ffc
node scripts/docs-audit/affected-docs.mjs --json 9cfc1f7e935aa62e46ec9a03ecd49194fd024754

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 30, 2026
@os-trump
os-trump marked this pull request as ready for review August 30, 2026 14:48
@os-trump
os-trump enabled auto-merge August 30, 2026 14:48
@os-trump
os-trump added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit c39369dAug 30, 2026
34 checks passed
@os-trump
os-trump deleted the claude/issue-13350-openworldhint-platform-scope branch August 30, 2026 15:12
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The MCP tool bridge asserts openWorldHint: false on every bridged tool from no declared source

2 participants

@os-trump@claude