Uh oh!
There was an error while loading. Please reload this page.
fix(mcp): source the bridged tools' safety annotations from what they declare - #13351
Conversation
… declare `registerToolFromDefinition` built `destructiveHint` / `readOnlyHint` from membership tests against two literal name sets, so every tool outside those seven names — every app-registered tool and every action-backed one — reached each MCP client as `readOnlyHint: false, destructiveHint: false`. The hints now come from `AIToolDefinition.requiresConfirmation`, the runtime contract member that already carries `actionLooksDestructive`'s ruling, with the platform name sets kept only as a fallback for the names the platform itself registers. A tool that declares nothing is served neither hint, so the MCP defaults apply instead of a fabricated `false`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 682369c497a1ef92e18624c269437416be22b5d9 && git checkout 682369c497a1ef92e18624c269437416be22b5d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e8ff4274b4e16a06465f05271423a2cc87774475 d571c53f10d797a0f86c0d47f754ff96dc325979 && git checkout -B drift-repro e8ff4274b4e16a06465f05271423a2cc87774475 && git merge --no-ff d571c53f10d797a0f86c0d47f754ff96dc325979
node scripts/docs-audit/affected-docs.mjs --json e8ff4274b4e16a06465f05271423a2cc87774475
|
os-trump
commented
Aug 30, 2026
✅ PM review — ACCEPT once CI is green; ⛔ not while it is running (#13318)Undrafting now; arm follows on a complete green read. ⛔ No rework owed. Clause ②: does not attach — 3 files, none under ⚖️ The fork is RULED: option A (omit), as shipped. Ship it.The dispatch said that if the work landed on a genuine fork between omit the hint and assert the MCP spec default, it should stop and report rather than pick silently. It did exactly that. Grading it here rather than handing it back. Ruling: A. Reasons, in order of weight:
⛔ Do not re-open this without new evidence about real non-conforming hosts. It is a one-line change in ✅ Fences held, verified from the diff
What makes this a good change beyond the fix
On the retirement rationale left unedited — endorsed, with one nuanceThe #3715 retirement prose carries the clause "the MCP bridge, which derives ✅ I endorse that, and add the nuance that makes it comfortable: after this PR that clause is imprecise rather than false — the name list still exists, demoted to a platform-name fallback. And the sentence's actual load-bearing claim (the retired key is read by nothing) is untouched and still exactly true. ⛔ Leave it alone. Follow-up triaged#13350 ( Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13318
registerToolFromDefinitionbuilt both safety hints from the tool's name — membership tests against two literal sets in the same file (READ_ONLY_TOOLS, 6 names;DESTRUCTIVE_TOOLS, 1). Every bridged tool outside those seven literals therefore reached each MCP client asreadOnlyHint: false, destructiveHint: false: not a missing annotation but a positive claim of "not read-only, and not destructive", asserted over every tool an app registers under its own name and every action-backed tool, on the one field a host reads to decide whether to interrupt the user before a call.The source now read for each hint, and why
destructiveHintAIToolDefinition.requiresConfirmation(packages/spec/src/contracts/ai-service.ts)action.ai.requiresConfirmation, or the destructive-action default)".git grep -n requiresConfirmation -- 'packages/mcp/src/**'returned only test fixtures and thelist_actionssummary type — the bridge never read it.readOnlyHintAIToolDefinitionhas no member expressing "this tool only reads" (its members arename,label,description,parameters,category,outputSchema,objectName,requiresConfirmation). The six-name set really was the only thing that has ever populated it — question 2 of the card, answered by measurement, not recall.openWorldHintfalsefor every bridged tool. Out of scope here and noted at the bottom.Reusing the one ruling rather than inventing a second.
actionLooksDestructive(packages/runtime/src/action-execution.ts, ruling #7828 Option A) is the framework's definition of destructive, andrequiresConfirmationis literally that function's output:summarizeActionfills the field by calling it. An MCP bridge never sees an action, and@objectstack/mcpdoes not depend on@objectstack/runtime, so the reuse this bridge owes the ruling is to read the verdict it is handed. The same verdict already travels to MCP on the other path, asrequiresConfirmationon eachlist_actionsentry.Kept apart from the retired metadata key. The member read here is the runtime contract's
AIToolDefinition.requiresConfirmation.ToolSchema.requiresConfirmation— the authorable metadata key ADR-0033 §2 retired — is untouched, still hard-rejects with its prescription, and is unreachable from this path; no metadata author can set the member read here. The retirement's rationale text (packages/spec/src/ai/tool.zod.ts, the ADR-0087 ledger, the upgrade guide) is deliberately not edited: it records why an authorable key was removed, and that reason is unchanged — its clause "the MCP bridge derivesdestructiveHintfrom a hardcoded name list" describes the state at retirement time, and rewriting a shipped retirement's rationale is the one edit most likely to read as the key coming back.FROM → TO: what
tools/listserveslist_objects,describe_object,query_records,get_record,aggregate_data)readOnlyHint: true, destructiveHint: false, openWorldHint: falsedelete_fieldreadOnlyHint: false, destructiveHint: true, openWorldHint: falsesend_invoice_email)readOnlyHint: false, destructiveHint: false, openWorldHint: falseopenWorldHint: falseonly — both safety hints absentrequiresConfirmation: true(delete_opportunity,void_invoice,archive_account)readOnlyHint: false, destructiveHint: false, openWorldHint: falsereadOnlyHint: false,destructiveHint: true,openWorldHint: falserequiresConfirmation: falsereadOnlyHint: false, destructiveHint: false, openWorldHint: falsedestructiveHint: false, openWorldHint: false(no read-only claim)requiresConfirmation: truereadOnlyHint: true, destructiveHint: falsereadOnlyHint: false, destructiveHint: true— declared outranks nameaggregate_recordson this pathreadOnlyHint: true, destructiveHint: false, openWorldHint: falseopenWorldHint: falseonly (see the sets, below)What a tool that declares nothing now gets, and why
Neither hint. Measured in the pinned SDK (
@modelcontextprotocol/sdk1.30.0,ToolAnnotationsSchemaindist/esm/types.js):readOnlyHintdocumentsDefault: false,destructiveHintdocumentsDefault: true. So the oldfalse, falsedid not merely under-inform — it inverted the protocol's own conservative default. MCP has no spelling for "unknown" other than absence, so omitting hands the question back to that default ("may perform destructive updates") while claiming nothing this framework can source.The omit-vs-assert-the-default fork was reached and is settled by that measurement rather than by taste: on any conforming host the two are the same verdict, and only omission avoids presenting this bridge's ignorance as a property of the tool. Asserting
destructiveHint: truefor unknown tools would be a one-line change if a maintainer prefers the louder form for non-conforming hosts; nothing else in the design depends on it.The fate of the two literal sets
They survive, demoted: renamed
PLATFORM_READ_ONLY_TOOL_NAMES/PLATFORM_DESTRUCTIVE_TOOL_NAMES, documented as a last-resort fallback for names the platform itself registers, and now outranked by anything the definition declares. What is gone is theelsebranch — the fabricatedfalse, falsefor everything else. The asymmetry between keeping the read set and the destructive set is the MCP defaults' own: a missingreadOnlyHintreads as "not read-only", the conservative answer, so omission there loses information but never safety, and the platform's own readers are the one place that information exists.They are no longer folklore: a pin drives every name in
PLATFORM_PROVIDED_TOOL_NAMES(@objectstack/spec/system) plus a set of strangers through the wire and asserts that only a name that registry contains may receive a hint it did not declare.aggregate_recordsfailed that check and left the set — it was never a platform tool name (aggregate_datais). It belongs to the object-CRUD bridge, which registers it, annotatedreadOnlyHint: true, at its own site inmcp-http-tools.ts, so nothing loses that annotation where it is actually served.The long-term shape is a declared read-only signal (or platform tools that declare
requiresConfirmation), at which point the fallback can go. That is a contract decision, not this card.Evidence
Every case drives a real
StdioServerTransportand readstools/listover newline-delimited JSON-RPC — the shape #13317 established for this call site — so what is asserted is what a client receives, not what a predicate returned.packages/mcp/src/mcp-tool-bridge-safety-annotations.test.ts, 8 cases.Reverse verification (prediction first: the two controls stay green, the six behaviour cases go red). Fix committed, then
packages/mcp/src/mcp-server-runtime.tsrestored fromorigin/mainand the mutation proven on disk by blob hash (fe7722fd5fixed /0c6b4d2camutated, matchingorigin/main's blob exactly), plus a marker count (safetyAnnotations0 hits,isDestructiveTool2 hits after the mutation):The 7 that stayed green are the two controls in this file plus all five of #13317's input-schema pins, so the reds discriminate. Restore proven the same way: on-disk hash back to
fe7722fd5,git diff HEADempty, marker back to 1. No rebuild was needed for either leg — the pins import./mcp-server-runtime.jsrelatively, so vitest resolves the source in the checkout, not adist/artifact (the package's vitest aliases touch only@objectstack/metadata-coreand@objectstack/lint).Runs, at
d571c53f1:pnpm --filter @objectstack/mcp test→Test Files 25 passed (25) · Tests 266 passed (266), under the shared verify lock (VERDICT command-exit 0).pnpm --filter '@objectstack/mcp^...' build→VERDICT command-exit 0(dependency closure, run before any judgement).pnpm --filter @objectstack/mcp typecheck→ exit 0.tsconfig.jsonexcludes**/*.test.ts, andtsc --listFilesshows 0 test files in the program, so that green says nothing about the new pin. Stand-in measurement: the same program with tests included reports 53 raw errors — exactly the numberTEST_DEBT['@objectstack/mcp']records — with 0 of them naming the new test file or the edited source, so the ratchet cannot move on this card.pnpm lint(repo-wideeslint . --no-inline-config) → exit 0. Not narrowed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(28 matched + the convention-triggered kinds), all run: 30 green, includingcheck:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:dispatcher-error-vocabulary,check:published-files,check:undeclared-dep-imports,check-adr-0087-registration,check-empty-changeset,check-changeset-no-major.scripts/check-test-completeness.mjs("PREREQUISITE NOT MET — this gate grades a savedturbo run testlog, and no log was named"),scripts/pm/check-half-states.mjs("the token in the environment is not a valid GitHub credential"). Two more need a full workspace build this container did not run:pnpm check:dual-build-cjs-loads("PREREQUISITE NOT MET — this gate reads built output") and the--re-measurehalf ofpnpm check:type-check-debt("49 workspace dependencies of the ledgered packages have no built type entry point on disk"); its structural half passed (check-type-check-coverage: OK), and the stand-in measurement above covers what the re-measure would have graded for this package. CI runs all four.Not done here, on purpose
openWorldHint: falseis still asserted for every bridged tool from no source at all — same class as the defect above, but the correct shape is not mechanical: the SDK documentsDefault: true, so simply omitting it would flip hosts to assuming an open world, which is neither obviously right nor obviously safe. Filed separately as #13350, which stays open and is not addressed here.outputSchemawas deliberately left alone at this call site, per the measurement recorded on #13317.Generated by Claude Code