Skip to content

Commit 3ec8646

Browse files
os-trumpclaude
andauthored
fix(mcp): source the bridged tools' safety annotations from what they declare (#13351)
* fix(mcp): source the bridged tools' safety annotations from what they 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 * chore(changeset): record the MCP tool annotation source change 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>
1 parent 2331b1e commit 3ec8646

3 files changed

Lines changed: 422 additions & 25 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/mcp": patch
3+
---
4+
5+
Bridged MCP tools are now annotated from what their definition DECLARES, not from a seven-name allowlist. `registerToolFromDefinition` built both safety hints as membership tests against two literal sets (`READ_ONLY_TOOLS`, 6 names; `DESTRUCTIVE_TOOLS`, 1), so every tool outside them — every tool an app registers under its own name, and every action-backed tool (`delete_opportunity`, `void_invoice`, `archive_account`, …) — reached each MCP client as `readOnlyHint: false, destructiveHint: false`. That pair is not a missing annotation: it is a positive claim of "not read-only, and not destructive", on the one field an MCP host reads to decide whether to interrupt the user before a call, so a destructive action-backed tool arrived flagged as safe. It also inverted the protocol's own conservative default (`@modelcontextprotocol/sdk` 1.30.0 documents `destructiveHint` as `Default: true`).
6+
7+
The declared source is `AIToolDefinition.requiresConfirmation` — the runtime contract member that already carries the framework's one maintainer-ruled definition of destructive (`actionLooksDestructive`, #7828 Option A, whose output `summarizeAction` writes into that very field). ⛔ Nothing here restores the retired metadata key `ToolSchema.requiresConfirmation`, which ADR-0033 §2 removed and which still hard-rejects; it is a different member, on a different object, at a different layer, and no metadata author can reach the one read here.
8+
9+
What a `tools/list` now serves, per tool:
10+
11+
- **declares `requiresConfirmation: true`**`destructiveHint: true, readOnlyHint: false` (was `destructiveHint: false`). ⚠️ Hosts will start prompting before these calls, which is the point of the change and the intended direction.
12+
- **declares `requiresConfirmation: false`**`destructiveHint: false`, no `readOnlyHint` (was an asserted `readOnlyHint: false`; the MCP default is `false`, so nothing changes for a conforming host).
13+
- **declares nothing** → NEITHER hint (was `false, false`). MCP has no spelling for "unknown" other than absence, so the protocol's own defaults apply — `readOnlyHint` false, `destructiveHint` **true** — instead of a value this bridge cannot source.
14+
- **a platform tool name** (`list_objects`, `describe_object`, `query_records`, `get_record`, `aggregate_data`, `delete_field`) → unchanged, as an explicit last-resort fallback for the names the platform itself registers, now outranked by anything the definition declares and pinned to be a subset of `PLATFORM_PROVIDED_TOOL_NAMES`.
15+
16+
One name left the read-only fallback: `aggregate_records` is not a platform tool name (`aggregate_data` is) — it belongs to the object-CRUD bridge, which registers it, annotated `readOnlyHint: true`, at its own site in `mcp-http-tools.ts`, so nothing loses that annotation where it is actually served. `openWorldHint: false` is unchanged for every bridged tool.

packages/mcp/src/mcp-server-runtime.ts

Lines changed: 129 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -54,25 +54,136 @@ interface ObjectDef {
5454
}
5555

5656
/**
57-
* Names of tools that are read-only (no side effects).
58-
* Kept as a module-level constant for easy extension.
57+
* PLATFORM tool names whose safety class is known from the platform's own
58+
* registration rather than from anything the definition carries — the
59+
* last-resort fallback inside {@link safetyAnnotations}, and deliberately NOT
60+
* a general classifier.
61+
*
62+
* Every name here is a tool the cloud AI runtime registers statically
63+
* (`PLATFORM_TOOLS_BY_PACKAGE` in `@objectstack/spec/system`) and hands to
64+
* this bridge through the AI service's `ToolRegistry` carrying no
65+
* `requiresConfirmation`. A sibling pin holds both sets to that registry, so
66+
* the lists cannot drift back into folklore: a name the platform does not
67+
* register is a name this bridge knows nothing about.
68+
*
69+
* ⛔ What the fallback must never do again is answer for tools it does NOT
70+
* contain. These two sets used to be the ONLY source of both hints, so the
71+
* `else` branch of the membership tests asserted
72+
* `readOnlyHint: false, destructiveHint: false` — "not read-only and not
73+
* destructive", the most permissive pair the annotation can express — for
74+
* every app-registered and every action-backed tool, and inverted the
75+
* protocol's own conservative default while doing it.
76+
*
77+
* `aggregate_records` left the read-only half because it was never a platform
78+
* tool name (`aggregate_data` is): it belongs to the object-CRUD bridge, which
79+
* registers it — annotated `readOnlyHint: true` — at its own registration site
80+
* in `mcp-http-tools.ts`, and never reaches this path.
5981
*/
60-
const READ_ONLY_TOOLS = new Set([
82+
const PLATFORM_READ_ONLY_TOOL_NAMES = new Set([
6183
'list_objects',
6284
'describe_object',
6385
'query_records',
6486
'get_record',
65-
'aggregate_records',
6687
'aggregate_data',
6788
]);
6889

6990
/**
70-
* Names of tools that perform destructive mutations.
91+
* The destructive half of the same platform-name fallback — see
92+
* {@link PLATFORM_READ_ONLY_TOOL_NAMES} for what it is and is not for.
7193
*/
72-
const DESTRUCTIVE_TOOLS = new Set([
94+
const PLATFORM_DESTRUCTIVE_TOOL_NAMES = new Set([
7395
'delete_field',
7496
]);
7597

98+
/** The safety hints this bridge can source, as MCP spells them. */
99+
interface ToolSafetyHints {
100+
readOnlyHint?: boolean;
101+
destructiveHint?: boolean;
102+
}
103+
104+
/**
105+
* The `readOnlyHint` / `destructiveHint` an {@link AIToolDefinition} can
106+
* actually SOURCE — and nothing else.
107+
*
108+
* THE DEFECT. Both hints used to be membership tests against the two name sets
109+
* above, so a bridged tool outside those seven literals was served to every
110+
* MCP client as `readOnlyHint: false, destructiveHint: false`. That is not a
111+
* missing annotation, it is a positive claim of "not read-only, and not
112+
* destructive" — asserted over every tool an app registers under its own name
113+
* and every action-backed tool (`delete_opportunity`, `void_invoice`, …).
114+
* `destructiveHint` is what a host reads to decide whether to interrupt the
115+
* user before a call, so a destructive action-backed tool arrived flagged as
116+
* safe.
117+
*
118+
* THE DECLARED SOURCE is `AIToolDefinition.requiresConfirmation`
119+
* (`@objectstack/spec/contracts`), documented on the member itself as carried
120+
* by action-backed tools "from the action's confirmation policy
121+
* (`action.ai.requiresConfirmation`, or the destructive-action default)".
122+
*
123+
* ⛔ NOT the retired metadata key. `ToolSchema.requiresConfirmation` was
124+
* removed by ADR-0033 §2 and still hard-REJECTS with a prescription; nothing
125+
* here asks for it back and no metadata author can reach the member read
126+
* below. The two live on different objects at different layers: the retired
127+
* one was authorable `tool` metadata, this one is the runtime contract the AI
128+
* service registers, which the retirement never touched.
129+
*
130+
* ⛔ NOT a second definition of "destructive" either. The framework already
131+
* has one, maintainer-ruled (`actionLooksDestructive` in
132+
* `@objectstack/runtime`, #7828 Option A: `mode: 'delete'` / `variant:
133+
* 'danger'` are the closed declared signals and `confirmText` deliberately is
134+
* not), and `requiresConfirmation` is literally that function's output —
135+
* `summarizeAction` fills the field by calling it. So the reuse this bridge
136+
* owes the ruling is to READ the verdict it is handed, not to re-derive one:
137+
* an MCP bridge never sees an action, and `@objectstack/mcp` does not depend
138+
* on `@objectstack/runtime`. The same verdict already travels to MCP on the
139+
* other path, as `requiresConfirmation` on each `list_actions` entry.
140+
*
141+
* WHY A TOOL THAT DECLARES NOTHING GETS NO HINT AT ALL. Measured in the
142+
* pinned SDK (`@modelcontextprotocol/sdk` 1.30.0, `ToolAnnotationsSchema`):
143+
* `readOnlyHint` documents `Default: false` and `destructiveHint` documents
144+
* `Default: true`. Omitting a hint therefore hands the question to the
145+
* protocol's own conservative default — "may perform destructive updates" —
146+
* while claiming nothing this framework cannot source, and it is the same
147+
* treatment the annotation vocabulary has no other word for: MCP has no
148+
* spelling for "unknown" other than absence. Asserting `false` was the
149+
* inversion; asserting `true` here would be a property presented as the
150+
* tool's when it is really this bridge's ignorance.
151+
*
152+
* WHY `readOnlyHint` KEEPS A NAME FALLBACK AND NOTHING ELSE. There is no
153+
* declared source for it at all: `AIToolDefinition` has no member expressing
154+
* "this tool only reads". The asymmetry with `destructiveHint` is the MCP
155+
* defaults' own asymmetry — a missing `readOnlyHint` reads as "not read-only",
156+
* which is the conservative answer, so omission loses only information and
157+
* never safety. The platform's own readers are the one place that information
158+
* exists, so they keep it; every other tool is served no `readOnlyHint`
159+
* rather than a fabricated `false`.
160+
*
161+
* PRECEDENCE: what the definition declares outranks what the name suggests.
162+
*/
163+
function safetyAnnotations(tool: AIToolDefinition): ToolSafetyHints {
164+
if (tool.requiresConfirmation !== undefined) {
165+
// A tool whose invocation is gated on human confirmation is by
166+
// construction not a read (`readOnlyHint` is stated so the destructive
167+
// hint is unambiguously meaningful — MCP reads it only when read-only is
168+
// false). `false` is the action's declared "no confirmation needed", the
169+
// one thing that legitimately sources a non-destructive claim.
170+
return tool.requiresConfirmation
171+
? { readOnlyHint: false, destructiveHint: true }
172+
: { destructiveHint: false };
173+
}
174+
175+
if (PLATFORM_READ_ONLY_TOOL_NAMES.has(tool.name)) {
176+
// Read-only entails non-destructive; both come from the one fact.
177+
return { readOnlyHint: true, destructiveHint: false };
178+
}
179+
180+
if (PLATFORM_DESTRUCTIVE_TOOL_NAMES.has(tool.name)) {
181+
return { readOnlyHint: false, destructiveHint: true };
182+
}
183+
184+
return {};
185+
}
186+
76187
// ── AIToolDefinition.parameters → MCP inputSchema ────────────────────────────
77188

78189
/**
@@ -813,8 +924,9 @@ export class MCPServerRuntime {
813924
* Each registered tool becomes an MCP tool with the same name, description
814925
* and declared arguments: `AIToolDefinition.parameters` is JSON Schema, and
815926
* {@link toolInputSchema} converts it into the Zod schema the SDK requires
816-
* for `inputSchema`. The handler delegates to the ToolRegistry's execute
817-
* path.
927+
* for `inputSchema`. Its safety annotations come from what the definition
928+
* declares — see {@link safetyAnnotations}. The handler delegates to the
929+
* ToolRegistry's execute path.
818930
*/
819931
bridgeTools(toolRegistry: ToolRegistry): void {
820932
const tools = toolRegistry.getAll();
@@ -876,6 +988,12 @@ export class MCPServerRuntime {
876988
* with no `arguments` anywhere on that `extra` (`RequestHandlerExtra` has no
877989
* such member), which is why a bridged tool used to execute with `{}` no
878990
* matter what the client sent.
991+
*
992+
* The safety annotations come from {@link safetyAnnotations}, which reads
993+
* what the definition DECLARES and omits the hints it cannot source; the
994+
* name-derived `readOnlyHint: false, destructiveHint: false` this call used
995+
* to assert over every unlisted tool is gone. `openWorldHint` is untouched
996+
* by that change and still asserted for every bridged tool.
879997
*/
880998
private registerToolFromDefinition(tool: AIToolDefinition, toolRegistry: ToolRegistry): void {
881999
const logger = this.config.logger;
@@ -886,9 +1004,9 @@ export class MCPServerRuntime {
8861004
description: tool.description,
8871005
inputSchema: toolInputSchema(tool, logger),
8881006
annotations: {
889-
// Mark tools with write side-effects for destructive operations
890-
destructiveHint: this.isDestructiveTool(tool.name),
891-
readOnlyHint: this.isReadOnlyTool(tool.name),
1007+
// Only the hints {@link safetyAnnotations} can source — a tool that
1008+
// declares nothing is served neither, so the MCP defaults apply.
1009+
...safetyAnnotations(tool),
8921010
openWorldHint: false,
8931011
},
8941012
},
@@ -925,20 +1043,6 @@ export class MCPServerRuntime {
9251043
);
9261044
}
9271045

928-
/**
929-
* Check if a tool is read-only (data query tools).
930-
*/
931-
private isReadOnlyTool(name: string): boolean {
932-
return READ_ONLY_TOOLS.has(name);
933-
}
934-
935-
/**
936-
* Check if a tool performs destructive operations.
937-
*/
938-
private isDestructiveTool(name: string): boolean {
939-
return DESTRUCTIVE_TOOLS.has(name);
940-
}
941-
9421046
// ── Resource Bridge ────────────────────────────────────────────
9431047

9441048
/**

0 commit comments

Comments
 (0)