From ff84c8f316b2e4f3bc406fb8c93dc88cad0fc6b4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 20:47:22 +0200 Subject: [PATCH] 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 --- .../mcp-server/__tests__/strict-schemas.test.ts | 14 ++++++++++++++ extensions/general/mcp-server/server.ts | 10 +++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/extensions/general/mcp-server/__tests__/strict-schemas.test.ts b/extensions/general/mcp-server/__tests__/strict-schemas.test.ts index bc11d930..89bcc5d4 100644 --- a/extensions/general/mcp-server/__tests__/strict-schemas.test.ts +++ b/extensions/general/mcp-server/__tests__/strict-schemas.test.ts @@ -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([]) + }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index da6d28cb..7df34403 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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,