fix(mcp): widget-bearing tools must be read-only: Claude.ai drops write-annotated interactive tools (#1961)

The SIE drop card kept flapping into Claude.ai's Interactive-tools list
and vanishing, and the agent could never call it: every hypothesis
(scope filter, tool-count cap, stale cache, schema shape) was
eliminated against live data until one discriminator remained: all
surviving widget tools carry readOnlyHint true and
gnubok_create_sie_upload was the only one annotated as a write.
Claude.ai accepts always-render widgets only on read-only tools and
silently drops the tool otherwise.

readOnlyHint is now true, which is also honest: the tool only mints a
short-lived upload URL; the actual write is the staged
gnubok_import_sie: the exact receipt_matcher shape (read-only widget
tool, writes via separate approval-gated tools). A guard test pins the
invariant for every future widget tool.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 20:47:22 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 9bad3aee06
commit ff84c8f316
2 changed files with 23 additions and 1 deletions
@@ -41,4 +41,18 @@ describe('MCP tool inputSchema strictness', () => {
expect(missing).toEqual([])
})
it('every widget-bearing tool is read-only: Claude.ai drops write-annotated interactive tools', () => {
// A tool with definition-level _meta.ui renders on every call. Claude.ai
// accepts that only for read-only tools and silently DROPS a
// write-annotated one from the connector (E2E #9, 2026-08-26: the SIE
// drop card flapped into the Interactive list and vanished). Widget
// tools mint links/lists only; actual writes go through separate
// approval-gated tools the widget calls.
const writers = tools
.filter((t) => (t as { _meta?: { ui?: unknown } })._meta?.ui !== undefined)
.filter((t) => t.annotations.readOnlyHint !== true)
.map((t) => t.name)
expect(writers).toEqual([])
})
})
+9 -1
View File
@@ -16387,7 +16387,15 @@ export const tools: McpTool[] = [
},
_meta: { ui: { resourceUri: 'ui://sie-drop/app.html' } },
annotations: {
readOnlyHint: false,
// readOnlyHint MUST stay true on widget-bearing tools: Claude.ai
// accepts always-render widgets only on read-only tools and DROPS a
// write-annotated one from the connector entirely (the tool flapped
// into the Interactive list and vanished, E2E #9 2026-08-26; every
// surviving widget tool was readOnly). Honest too: this only mints a
// short-lived upload URL; the actual write is the staged
// gnubok_import_sie, same shape as receipt_matcher (read-only tool,
// writes via separate approval-gated tools).
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,