Files
accounted/extensions/general/mcp-server/__tests__/strict-schemas.test.ts
T
ff84c8f316 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>
2026-08-26 20:47:22 +02:00

59 lines
2.2 KiB
TypeScript

/**
* Guard against schema-strictness regression on MCP tool inputs.
*
* Every tool's `inputSchema` must declare `additionalProperties: false` so
* agents receive a clear rejection on typos/hallucinated fields instead of a
* silent ignore. This is item 8 of the agent-native API plan
* (dev_docs/api_ai_architecture/PLAN.md).
*
* If this test fires on a newly authored tool, add the field to the tool's
* top-level inputSchema. Don't relax the guard.
*/
import { describe, it, expect } from 'vitest'
import { tools } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { isTenantWriteScope } from '../company-routing'
describe('MCP tool inputSchema strictness', () => {
it('every tool inputSchema has additionalProperties: false at the top level', () => {
const missing = tools
.filter((t) => {
const schema = t.inputSchema as Record<string, unknown> | undefined
return !schema || schema.additionalProperties !== false
})
.map((t) => t.name)
expect(missing).toEqual([])
})
it('every tenant write tool has a scope that the central role guard can classify', () => {
const allowedNonTenantWrites = new Set([
'gnubok_audit_package',
'gnubok_feedback',
])
const missing = tools
.filter(
(tool) =>
tool.annotations.readOnlyHint !== true &&
!isTenantWriteScope(TOOL_SCOPE_MAP[tool.name]) &&
!allowedNonTenantWrites.has(tool.name)
)
.map((tool) => tool.name)
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([])
})
})