Skip to content

The MCP tool bridge derives destructiveHint / readOnlyHint from a hardcoded tool-name set, so every app-registered and action-backed tool is advertised as neither read-only nor destructive #13318

Description

@os-trump

Found while fixing #13271 (the same call site, a different dropped key). Filed rather than folded: #13271's scope is parameters only, and this touches the safety annotations, which are a separately-gradeable behaviour change on the same published surface.

Measured at a876ebe6

packages/mcp/src/mcp-server-runtime.ts, registerToolFromDefinition, passes the SDK an annotations object built entirely from the tool's name:

annotations: {destructiveHint: this.isDestructiveTool(tool.name),readOnlyHint: this.isReadOnlyTool(tool.name),openWorldHint: false,},

Both predicates are membership tests against two literal sets at the top of the same file:

constREAD_ONLY_TOOLS=newSet(['list_objects','describe_object','query_records','get_record','aggregate_records','aggregate_data',]);constDESTRUCTIVE_TOOLS=newSet(['delete_field']);

⇒ a bridged tool whose name is not one of those seven literals is served to every MCP client as readOnlyHint: false, destructiveHint: false — "not read-only, and not destructive", the least informative and most permissive combination the annotation pair can express. That is every tool an app registers under its own name, and every action-backed tool (delete_opportunity, void_invoice, archive_account, …).

This matters because destructiveHint is not decoration: it is the field an MCP host reads to decide whether to interrupt the user before a call. A destructive action-backed tool currently arrives flagged as safe.

Why it reads as a defect rather than a design choice

The definition the bridge is handed already carries a destructiveness signal it does not read. AIToolDefinition in packages/spec/src/contracts/ai-service.ts:

/** * Whether invoking this tool requires human-in-the-loop confirmation. * Action-backed tools set this from the action's confirmation policy * (`action.ai.requiresConfirmation`, or the destructive-action default). */
requiresConfirmation?: boolean;

git grep -n requiresConfirmation -- 'packages/mcp/src/**' returns nothing, against tool.name (5 sites) and tool.description (1 site) live in the same file — the same reverse-check shape #13271 used, and the same real absence.

Meanwhile the framework has a maintainer-ruled definition of what "destructive" means for an action, in packages/runtime/src/action-execution.ts:

exportfunctionactionLooksDestructive(_deps: ActionExecutionDeps,action: any): boolean{if(action?.ai?.requiresConfirmation!==undefined)returnBoolean(action.ai.requiresConfirmation);returnBoolean(action?.mode==='delete'||action?.variant==='danger');}

whose docblock records the ruling behind it (#7828, Option A: mode: 'delete' / variant: 'danger' are the closed, declared signals; confirmText deliberately is not). So the repo has one declared answer to this question and the MCP bridge asks a different one — a seven-name allowlist that no metadata author can extend.

⛔ Not a re-litigation of #3715

#3715 removed ToolSchema.requiresConfirmation — the metadata key — under ADR-0033, because nothing enforced it. That ruling stands and nothing here asks for it back. The key named above is AIToolDefinition.requiresConfirmation, the runtime contract member, which #3715 did not touch and which is still declared and documented as carried by action-backed tools. Any fix must keep those two apart.

What a fix would need to decide

  1. Whether the bridge should read AIToolDefinition.requiresConfirmation for destructiveHint, reuse actionLooksDestructive's ruling, or take a third source — and what a tool that declares nothing should then be annotated as. Note the MCP spec's own defaults are readOnlyHint: false and destructiveHint: **true**, so the current "declare nothing" path is not merely uninformative, it inverts the protocol's own conservative default.
  2. Whether readOnlyHint has any declared source at all today, or whether the six-name set is the only thing that has ever populated it — in which case the honest interim shape may be to omit the hint rather than assert false.
  3. Whether the two literal sets should survive as a fallback for the built-in names or be deleted once a declared source exists.

Either way, an annotation asserted from a name allowlist should stop being presented as a property of the tool.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions