From 4017ee6c97f27959835bbfd2c4270d23e2cc630c Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:37:10 +1100 Subject: [PATCH 1/3] fix(ai-client): surface server-only tool approvals as server-tool-approval MCP tools gated with needsApproval have no client definition, so useChat downgraded their approvals to generic interrupts and the approval UI never rendered them. Payloadless approvals with no same-name client tool now hydrate as a loosely typed server-tool-approval item. Refs #1924 --- .changeset/mcp-server-tool-approval.md | 5 + packages/ai-client/src/index.ts | 1 + packages/ai-client/src/interrupt-manager.ts | 28 +++- packages/ai-client/src/types.ts | 26 +++ .../tests/chat-client-interrupts.test.ts | 152 ++++++++++++++++++ .../tests/interrupts-types.test-d.ts | 20 +++ testing/e2e/src/routeTree.gen.ts | 21 +++ testing/e2e/src/routes/api.mcp-policy-test.ts | 2 + testing/e2e/src/routes/mcp-approval.tsx | 65 ++++++++ testing/e2e/tests/mcp-approval.spec.ts | 31 ++++ 10 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-server-tool-approval.md create mode 100644 testing/e2e/src/routes/mcp-approval.tsx create mode 100644 testing/e2e/tests/mcp-approval.spec.ts diff --git a/.changeset/mcp-server-tool-approval.md b/.changeset/mcp-server-tool-approval.md new file mode 100644 index 0000000000..a0bbab4f6d --- /dev/null +++ b/.changeset/mcp-server-tool-approval.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-client': minor +--- + +Approvals for tools that only the server knows, such as MCP tools gated with `needsApproval`, now reach `useChat` as `kind: 'server-tool-approval'`, with `toolName`, `toolCallId`, and `originalArgs`. Before, they arrived as `generic` interrupts, so the approval UI never rendered them. Call `resolveInterrupt(true)` or `resolveInterrupt(false)` to answer one, or answer all approvals at once with `resolveInterrupts(true)`. Tools with a matching client definition still arrive as typed `tool-approval` items. A server tool whose approval needs a payload still arrives as `generic`. diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index c4561ca23e..fefc2d017b 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -67,6 +67,7 @@ export type { UnboundInterrupt, InterruptItemStatus, ToolApprovalInterrupt, + ServerToolApprovalInterrupt, ClientContextOptionFromTools, ChatResumeState, ChatRequestBody, diff --git a/packages/ai-client/src/interrupt-manager.ts b/packages/ai-client/src/interrupt-manager.ts index f01b0e29c0..86b09d69c0 100644 --- a/packages/ai-client/src/interrupt-manager.ts +++ b/packages/ai-client/src/interrupt-manager.ts @@ -66,6 +66,10 @@ export interface InterruptManagerOptions< type UnknownObject = { [key: string]: unknown } +/** The binding hash of an approval that takes no payload on either branch. */ +const PAYLOADLESS_APPROVAL_SCHEMA_HASH = + normalizeApprovalSchema(undefined).approvalSchemaHash + type RuntimeKind = | 'generic' | 'tool-approval' @@ -823,6 +827,24 @@ export class InterruptManager< const tool = this.tools?.find( (configured) => configured.name === candidate.toolName, ) + // A tool only the server knows (an MCP tool, for example) has no client + // definition to type it. Keep it an approval, untyped, when it takes no + // payload. The server checks the decision against its own definition. + if ( + tool === undefined && + interrupt.toolCallId === candidate.toolCallId && + candidate.approvalSchemaHash === PAYLOADLESS_APPROVAL_SCHEMA_HASH + ) { + return { + descriptor: interrupt, + binding: cloneAndDeepFreezeJson(candidate), + kind: 'tool-approval', + status: 'pending', + canResolve: true, + resumable: true, + validationGeneration: 0, + } + } // Gated on the binding and the schema hashes below, not on // `interrupt.reason` — that string is free-form AG-UI text another // producer can also use, so it cannot be what decides ownership. @@ -1006,7 +1028,11 @@ export class InterruptManager< const binding = cloneAndDeepFreezeJson(item.binding) const snapshot = { ...base, - kind: 'tool-approval' as const, + // No client tool means a server-only tool: untyped public kind. + kind: + item.tool === undefined + ? ('server-tool-approval' as const) + : ('tool-approval' as const), binding, toolName: item.binding.toolName, toolCallId: item.binding.toolCallId, diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts index fcd58f8377..7b047fd1b7 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -225,6 +225,30 @@ export type ToolApprovalInterrupt = } : never +/** + * An approval for a tool that only the server knows, such as an MCP tool gated + * with `needsApproval`. No client definition exists for it, so the tool name + * and arguments are untyped. The server checks the decision and any edited + * arguments against its own tool definition. + * + * A server tool whose `approvalSchema` asks for a payload is not exposed this + * way. It stays a `generic` interrupt, because the client has no schema to + * build that payload from. + */ +export interface ServerToolApprovalInterrupt extends BoundInterruptBase { + readonly kind: 'server-tool-approval' + readonly binding: Readonly< + Extract + > + readonly toolName: string + readonly toolCallId: string + readonly originalArgs: unknown + readonly resolveInterrupt: ( + approved: boolean, + options?: { editedArgs?: unknown }, + ) => void +} + type ApprovalInterrupts> = TTools[number] extends infer TTool ? TTool extends AnyClientTool @@ -247,6 +271,7 @@ export type ChatInterrupt< | RegisteredGenericInterrupt | UnboundInterrupt | ApprovalInterrupts + | ServerToolApprovalInterrupt export type ResolvableChatInterrupt< TTools extends ReadonlyArray = ReadonlyArray, @@ -256,6 +281,7 @@ export type ResolvableChatInterrupt< | GenericAGUIInterrupt | RegisteredGenericInterrupt | ApprovalInterrupts + | ServerToolApprovalInterrupt export type BoundInterrupts< TTools extends ReadonlyArray = ReadonlyArray, diff --git a/packages/ai-client/tests/chat-client-interrupts.test.ts b/packages/ai-client/tests/chat-client-interrupts.test.ts index c63786d115..4c683f07fd 100644 --- a/packages/ai-client/tests/chat-client-interrupts.test.ts +++ b/packages/ai-client/tests/chat-client-interrupts.test.ts @@ -19,6 +19,7 @@ import type { StreamChunk, } from '@tanstack/ai' import type { + AnyClientTool, Interrupt, InterruptBinding, ModelMessage, @@ -2260,3 +2261,154 @@ describe('ChatClient native interrupts', () => { }, ) }) + +describe('InterruptManager server-only tool approvals', () => { + // A tool only the server knows, like an MCP tool gated with needsApproval. + async function serverApprovalInterrupts( + approvalSchema?: StandardSchemaV1, + ): Promise> { + const deleteNote = toolDefinition({ + name: 'delete_note', + description: 'Delete a note by id', + inputSchema: z.object({ id: z.string() }), + needsApproval: true, + ...(approvalSchema ? { approvalSchema } : {}), + }).server(({ id }) => Promise.resolve(`deleted ${id}`)) + const chunks = [ + { + type: EventType.RUN_STARTED, + runId: 'r1', + threadId: 't1', + timestamp: 1, + }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'delete_note', + timestamp: 1, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call-1', + delta: '{"id":"n1"}', + timestamp: 1, + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'call-1', timestamp: 1 }, + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'tool_calls', + timestamp: 1, + }, + ] as Array + const adapter: AnyTextAdapter = { + kind: 'text', + name: 'server-approval-test', + model: 'test-model', + '~types': { + providerOptions: {}, + inputModalities: ['text'], + messageMetadataByModality: { + text: undefined, + image: undefined, + audio: undefined, + video: undefined, + document: undefined, + }, + toolCapabilities: [], + toolCallMetadata: undefined, + systemPromptMetadata: undefined, + }, + chatStream: () => + (async function* () { + await Promise.resolve() + for (const chunk of chunks) yield chunk + })(), + structuredOutput: () => Promise.resolve({ data: {}, rawText: '{}' }), + } + for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'delete n1' }], + tools: [deleteNote], + runId: 'r1', + threadId: 't1', + })) { + if ( + chunk.type === EventType.RUN_FINISHED && + chunk.outcome?.type === 'interrupt' + ) { + return chunk.outcome.interrupts + } + } + throw new Error('Expected an interrupt terminal.') + } + + function hydrate( + interrupts: ReadonlyArray, + clientTools: ReadonlyArray = [], + ) { + const submit = vi.fn( + async (_submission: InterruptManagerSubmission) => undefined, + ) + const manager = new InterruptManager({ tools: clientTools, submit }) + manager.hydrate({ + threadId: 't1', + interruptedRunId: 'r1', + generation: 0, + interrupts, + }) + return { manager, submit } + } + + it('surfaces a server-only approval as server-tool-approval and submits the decision', async () => { + const { manager, submit } = hydrate(await serverApprovalInterrupts()) + + const item = manager.getInterrupts()[0] + if (item?.kind !== 'server-tool-approval') { + throw new Error(`Expected server-tool-approval, got ${item?.kind}`) + } + expect(item.canResolve).toBe(true) + expect(item.toolName).toBe('delete_note') + expect(item.toolCallId).toBe('call-1') + expect(item.originalArgs).toEqual({ id: 'n1' }) + + item.resolveInterrupt(true) + await settle() + expect(submit.mock.calls[0]?.[0].resolutions).toEqual([ + { interruptId: item.id, status: 'resolved', payload: { approved: true } }, + ]) + }) + + it('approves a server-only approval with boolean bulk resolution', async () => { + const { manager, submit } = hydrate(await serverApprovalInterrupts()) + const id = manager.getInterrupts()[0]?.id + + manager.resolve(false) + expect(manager.getInterruptErrors()).toEqual([]) + expect(submit.mock.calls[0]?.[0].resolutions).toEqual([ + { interruptId: id, status: 'resolved', payload: { approved: false } }, + ]) + }) + + it('keeps a server-only approval that needs a payload generic', async () => { + const { manager } = hydrate( + await serverApprovalInterrupts(z.object({ reason: z.string() })), + ) + expect(manager.getInterrupts()[0]?.kind).toBe('generic') + }) + + it('keeps a same-name client tool with different schemas generic', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const drifted = toolDefinition({ + name: 'delete_note', + description: 'Delete a note by id', + inputSchema: z.object({ noteId: z.number() }), + needsApproval: true, + }).client() + const { manager } = hydrate(await serverApprovalInterrupts(), [drifted]) + expect(manager.getInterrupts()[0]?.kind).toBe('generic') + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) +}) diff --git a/packages/ai-client/tests/interrupts-types.test-d.ts b/packages/ai-client/tests/interrupts-types.test-d.ts index 5a27623df8..013080ffc9 100644 --- a/packages/ai-client/tests/interrupts-types.test-d.ts +++ b/packages/ai-client/tests/interrupts-types.test-d.ts @@ -20,6 +20,7 @@ import type { GenericAGUIInterrupt, GenericInterrupt, RegisteredGenericInterrupt, + ServerToolApprovalInterrupt, ToolApprovalInterrupt, } from '../src/index' @@ -251,3 +252,22 @@ const hydrate = ( ): Promise => Promise.resolve({ messages: [], activeRun: null, interrupts: null }) expectTypeOf(hydrate).toExtend>() + +// Server-only tool approvals (MCP tools, for example) get their own kind, so +// narrowing a typed approval by tool name still types its arguments. +declare const anyInterrupt: ChatInterrupt +if ( + anyInterrupt.kind === 'tool-approval' && + anyInterrupt.toolName === 'transfer' +) { + expectTypeOf(anyInterrupt.originalArgs).toEqualTypeOf<{ cents: number }>() +} +if (anyInterrupt.kind === 'server-tool-approval') { + expectTypeOf(anyInterrupt).toEqualTypeOf() + expectTypeOf(anyInterrupt.toolName).toEqualTypeOf() + expectTypeOf(anyInterrupt.originalArgs).toEqualTypeOf() + anyInterrupt.resolveInterrupt(true) + anyInterrupt.resolveInterrupt(true, { editedArgs: { id: 'n2' } }) + // @ts-expect-error server-only approvals take no payload + anyInterrupt.resolveInterrupt(true, { payload: {} }) +} diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 445df11af5..5f73329842 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -36,6 +36,7 @@ import { Route as InterruptsTestRouteImport } from './routes/interrupts-test' import { Route as JoinRunClientToolRouteImport } from './routes/join-run-client-tool' import { Route as JsonTransportRouteImport } from './routes/json-transport' import { Route as MarkdownCjkRouteImport } from './routes/markdown-cjk' +import { Route as McpApprovalRouteImport } from './routes/mcp-approval' import { Route as MessageHistoryPagingRouteImport } from './routes/message-history-paging' import { Route as MiddlewareTestRouteImport } from './routes/middleware-test' import { Route as PersistenceDurabilityRouteImport } from './routes/persistence-durability' @@ -349,6 +350,11 @@ const MarkdownCjkRoute = MarkdownCjkRouteImport.update({ path: '/markdown-cjk', getParentRoute: () => rootRouteImport, } as any) +const McpApprovalRoute = McpApprovalRouteImport.update({ + id: '/mcp-approval', + path: '/mcp-approval', + getParentRoute: () => rootRouteImport, +} as any) const MessageHistoryPagingRoute = MessageHistoryPagingRouteImport.update({ id: '/message-history-paging', path: '/message-history-paging', @@ -1295,6 +1301,7 @@ export interface FileRoutesByFullPath { '/join-run-client-tool': typeof JoinRunClientToolRoute '/json-transport': typeof JsonTransportRoute '/markdown-cjk': typeof MarkdownCjkRoute + '/mcp-approval': typeof McpApprovalRoute '/message-history-paging': typeof MessageHistoryPagingRoute '/middleware-test': typeof MiddlewareTestRoute '/persistence-durability': typeof PersistenceDurabilityRoute @@ -1498,6 +1505,7 @@ export interface FileRoutesByTo { '/join-run-client-tool': typeof JoinRunClientToolRoute '/json-transport': typeof JsonTransportRoute '/markdown-cjk': typeof MarkdownCjkRoute + '/mcp-approval': typeof McpApprovalRoute '/message-history-paging': typeof MessageHistoryPagingRoute '/middleware-test': typeof MiddlewareTestRoute '/persistence-durability': typeof PersistenceDurabilityRoute @@ -1702,6 +1710,7 @@ export interface FileRoutesById { '/join-run-client-tool': typeof JoinRunClientToolRoute '/json-transport': typeof JsonTransportRoute '/markdown-cjk': typeof MarkdownCjkRoute + '/mcp-approval': typeof McpApprovalRoute '/message-history-paging': typeof MessageHistoryPagingRoute '/middleware-test': typeof MiddlewareTestRoute '/persistence-durability': typeof PersistenceDurabilityRoute @@ -1907,6 +1916,7 @@ export interface FileRouteTypes { | '/join-run-client-tool' | '/json-transport' | '/markdown-cjk' + | '/mcp-approval' | '/message-history-paging' | '/middleware-test' | '/persistence-durability' @@ -2110,6 +2120,7 @@ export interface FileRouteTypes { | '/join-run-client-tool' | '/json-transport' | '/markdown-cjk' + | '/mcp-approval' | '/message-history-paging' | '/middleware-test' | '/persistence-durability' @@ -2313,6 +2324,7 @@ export interface FileRouteTypes { | '/join-run-client-tool' | '/json-transport' | '/markdown-cjk' + | '/mcp-approval' | '/message-history-paging' | '/middleware-test' | '/persistence-durability' @@ -2517,6 +2529,7 @@ export interface RootRouteChildren { JoinRunClientToolRoute: typeof JoinRunClientToolRoute JsonTransportRoute: typeof JsonTransportRoute MarkdownCjkRoute: typeof MarkdownCjkRoute + McpApprovalRoute: typeof McpApprovalRoute MessageHistoryPagingRoute: typeof MessageHistoryPagingRoute MiddlewareTestRoute: typeof MiddlewareTestRoute PersistenceDurabilityRoute: typeof PersistenceDurabilityRoute @@ -2879,6 +2892,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MarkdownCjkRouteImport parentRoute: typeof rootRouteImport } + '/mcp-approval': { + id: '/mcp-approval' + path: '/mcp-approval' + fullPath: '/mcp-approval' + preLoaderRoute: typeof McpApprovalRouteImport + parentRoute: typeof rootRouteImport + } '/message-history-paging': { id: '/message-history-paging' path: '/message-history-paging' @@ -4186,6 +4206,7 @@ const rootRouteChildren: RootRouteChildren = { JoinRunClientToolRoute: JoinRunClientToolRoute, JsonTransportRoute: JsonTransportRoute, MarkdownCjkRoute: MarkdownCjkRoute, + McpApprovalRoute: McpApprovalRoute, MessageHistoryPagingRoute: MessageHistoryPagingRoute, MiddlewareTestRoute: MiddlewareTestRoute, PersistenceDurabilityRoute: PersistenceDurabilityRoute, diff --git a/testing/e2e/src/routes/api.mcp-policy-test.ts b/testing/e2e/src/routes/api.mcp-policy-test.ts index 911633f198..028b8b5c43 100644 --- a/testing/e2e/src/routes/api.mcp-policy-test.ts +++ b/testing/e2e/src/routes/api.mcp-policy-test.ts @@ -45,6 +45,8 @@ export const Route = createFileRoute('/api/mcp-policy-test')({ messages: params.messages, threadId: params.threadId, runId: params.runId, + ...(params.parentRunId && { parentRunId: params.parentRunId }), + ...(params.resume && { resume: params.resume }), mcp: { clients: [client] }, agentLoopStrategy: maxIterations(5), }) diff --git a/testing/e2e/src/routes/mcp-approval.tsx b/testing/e2e/src/routes/mcp-approval.tsx new file mode 100644 index 0000000000..a385f92c32 --- /dev/null +++ b/testing/e2e/src/routes/mcp-approval.tsx @@ -0,0 +1,65 @@ +import { useEffect } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { parseDevtoolsRouteSearch } from '@/lib/devtools-test' + +/** + * Harness page for MCP tools gated with `needsApproval`. The browser has no + * client definition for them, so `useChat` surfaces the pause as a + * `server-tool-approval` item. `/api/mcp-policy-test` with `policy: 'approval'` + * gates every MCP tool. + */ +function McpApprovalPage() { + const { testId, aimockPort } = Route.useSearch() + const { interrupts, messages, sendMessage } = useChat({ + threadId: `mcp-approval-${testId ?? 'local'}`, + connection: fetchServerSentEvents('/api/mcp-policy-test'), + forwardedProps: { testId, aimockPort, policy: 'approval' }, + }) + + useEffect(() => { + void sendMessage('[mcp] how much is the strat guitar') + // Fire the single run once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const assistantText = messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => + message.parts.flatMap((part) => + part.type === 'text' ? [part.content] : [], + ), + ) + .join(' ') + + return ( +
+
{assistantText}
+ {interrupts.map((interrupt) => ( +
+ {interrupt.kind} + {interrupt.kind === 'server-tool-approval' ? ( + <> + {interrupt.toolName} + + {JSON.stringify(interrupt.originalArgs)} + + + + ) : null} +
+ ))} +
+ ) +} + +export const Route = createFileRoute('/mcp-approval')({ + component: McpApprovalPage, + validateSearch: parseDevtoolsRouteSearch, +}) diff --git a/testing/e2e/tests/mcp-approval.spec.ts b/testing/e2e/tests/mcp-approval.spec.ts new file mode 100644 index 0000000000..65b76ae5a5 --- /dev/null +++ b/testing/e2e/tests/mcp-approval.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from './fixtures' +import { devtoolsUrl } from './devtools-helpers' + +// An MCP tool gated with needsApproval has no client definition in the +// browser. useChat must still surface it as an approval the user can act on. +test.describe('mcp approval — server-only tool in useChat', () => { + test('surfaces the gated MCP call as server-tool-approval and resumes on approve', async ({ + page, + testId, + aimockPort, + }) => { + await page.goto(devtoolsUrl('/mcp-approval', testId, aimockPort)) + + await expect(page.getByTestId('interrupt-kind')).toHaveText( + 'server-tool-approval', + ) + await expect(page.getByTestId('interrupt-tool')).toHaveText( + 'get_guitar_price', + ) + await expect(page.getByTestId('interrupt-args')).toHaveText( + '{"id":"strat"}', + ) + + await page.getByTestId('approve').click() + + await expect(page.getByTestId('interrupt')).toHaveCount(0) + await expect(page.getByTestId('assistant-text')).toContainText( + 'The strat guitar costs $1999.', + ) + }) +}) From 75eb6d100fc118e33c6834abd9dc86f69f404578 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:39:53 +1100 Subject: [PATCH 2/3] docs: show server-tool-approval for MCP needsApproval tools Refs #1924 --- docs/config.json | 21 +++++----- docs/interrupts/multiple.md | 3 ++ docs/tools/mcp-advanced.md | 42 ++++++++++++++++++- docs/tools/tool-approval.md | 15 +++++++ docs/ui/angular.md | 2 +- docs/ui/react.md | 1 + docs/ui/recipes/custom-interrupt.md | 1 + docs/ui/remix.md | 2 +- docs/ui/solid.md | 2 +- docs/ui/svelte.md | 2 +- docs/ui/vue.md | 2 +- packages/ai-mcp/skills/ai-mcp/SKILL.md | 4 ++ .../ai/skills/ai-core/tool-calling/SKILL.md | 5 +++ 13 files changed, 86 insertions(+), 16 deletions(-) diff --git a/docs/config.json b/docs/config.json index 5ebdf1256b..5021877118 100644 --- a/docs/config.json +++ b/docs/config.json @@ -374,7 +374,7 @@ "label": "Tool Approval Flow", "to": "tools/tool-approval", "addedAt": "2026-04-15", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-01" }, { "label": "Provider Tools", @@ -459,25 +459,25 @@ "label": "React", "to": "ui/react", "addedAt": "2026-08-26", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-01" }, { "label": "Solid", "to": "ui/solid", "addedAt": "2026-08-26", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-01" }, { "label": "Vue", "to": "ui/vue", "addedAt": "2026-08-26", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-01" }, { "label": "Svelte", "to": "ui/svelte", "addedAt": "2026-08-26", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-01" }, { "label": "Preact", @@ -495,13 +495,13 @@ "label": "Remix", "to": "ui/remix", "addedAt": "2026-09-01", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-01" }, { "label": "Angular", "to": "ui/angular", "addedAt": "2026-09-01", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-01" }, { "label": "Custom Adapters", @@ -554,7 +554,7 @@ "to": "ui/recipes/custom-interrupt", "tab": "guides", "addedAt": "2026-08-31", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-01" }, { "label": "Per-request context", @@ -585,7 +585,7 @@ "label": "Multiple Interrupts", "to": "interrupts/multiple", "addedAt": "2026-08-04", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-01" }, { "label": "Generic Interrupts", @@ -649,7 +649,8 @@ { "label": "MCP Server Tools: Advanced", "to": "tools/mcp-advanced", - "addedAt": "2026-10-21" + "addedAt": "2026-10-21", + "updatedAt": "2026-11-01" }, { "label": "MCP Events", diff --git a/docs/interrupts/multiple.md b/docs/interrupts/multiple.md index 7af8692e3c..8c95355908 100644 --- a/docs/interrupts/multiple.md +++ b/docs/interrupts/multiple.md @@ -105,6 +105,9 @@ full set before it accepts any item. In a mixed batch, `kind` tells you what each item is: - `tool-approval`: a tool call. It has its own approval controls. +- `server-tool-approval`: a call to a tool that only the server knows, such as + an MCP tool. It has approval controls, but `toolName` and `originalArgs` are + untyped. - `generic` with a `definitionId`: a registered generic interrupt. The definition id narrows its payload and response types. - `generic` without a `definitionId`: an external interrupt with a valid diff --git a/docs/tools/mcp-advanced.md b/docs/tools/mcp-advanced.md index 1a6f155771..daa56ba192 100644 --- a/docs/tools/mcp-advanced.md +++ b/docs/tools/mcp-advanced.md @@ -372,7 +372,47 @@ const mcp = await createMCPClient({ }) ``` -The run stops with an approval interrupt for each gated call. The client approves or denies it the same way as for any other tool. See [Tool Approval Flow](./tool-approval). +The run stops with an approval interrupt for each gated call. The browser has no definition for an MCP tool, so `useChat` gives you the call as `kind: 'server-tool-approval'`: + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' + +export function McpApprovals() { + const { interrupts, resuming } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + threadId: 'support-chat', + }) + + return ( + <> + {interrupts.map((interrupt) => + interrupt.kind === 'server-tool-approval' ? ( +
+

Run {interrupt.toolName}?

+
{JSON.stringify(interrupt.originalArgs, null, 2)}
+ + +
+ ) : null, + )} + + ) +} +``` + +- `toolName` is a `string` and `originalArgs` is `unknown`. In a [pool](#multi-server-pool), `toolName` has the server prefix, for example `github_create_issue`. +- The server checks the answer against the MCP tool, so the browser needs no tool definition. +- Your chat route must forward `parentRunId` and `resume` to `chat()`. See [Tool Approval Flow](./tool-approval#2-add-the-server-function-and-forward-the-resume-fields). In a [pool](#multi-server-pool), each server gets its own options: diff --git a/docs/tools/tool-approval.md b/docs/tools/tool-approval.md index 8626e91b54..312ec25fd4 100644 --- a/docs/tools/tool-approval.md +++ b/docs/tools/tool-approval.md @@ -289,6 +289,21 @@ const { messages, interrupts } = useChat({ }) ``` +### Approval on a server-only tool + +Some tools exist only on the server, for example an [MCP tool with `needsApproval`](./mcp-advanced#limit-and-gate-tools). The browser has no definition for them, so their approval arrives as `kind: 'server-tool-approval'`: + +```ts ignore +if (interrupt.kind === 'server-tool-approval') { + // toolName is a string. originalArgs is unknown. + console.log(interrupt.toolName, interrupt.originalArgs) + interrupt.resolveInterrupt(true) +} +``` + +- It has the same controls as `tool-approval`, and `resolveInterrupts(true)` approves it too. +- If the server tool has an `approvalSchema`, the approval arrives as a `generic` interrupt. See [Generic Interrupts](../interrupts/generic). + ### Tell a denial from a failure A denied or cancelled tool result has `state: 'error'` and the wire state `output-error`. To tell it apart from a real tool failure, read `outcome` on the `tool-result` part: diff --git a/docs/ui/angular.md b/docs/ui/angular.md index c0083dcf61..52853ac238 100644 --- a/docs/ui/angular.md +++ b/docs/ui/angular.md @@ -341,7 +341,7 @@ It passes these inputs to each widget: - A tool approval renders in the tool when you read the `interrupt` input. - To show it in the list instead, register a component under `interruptsComponents.tools` and render `Interrupts` in the layout. -- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id and an unbound interrupt. If the copy must be different, branch on `interrupt().kind === 'unbound'`. +- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id, an unbound interrupt, and a `server-tool-approval` (a tool that only the server knows, such as an MCP tool). If the copy must be different, branch on `interrupt().kind === 'unbound'`. ### Show queued messages diff --git a/docs/ui/react.md b/docs/ui/react.md index ed3ddb22cf..d4690139e3 100644 --- a/docs/ui/react.md +++ b/docs/ui/react.md @@ -374,6 +374,7 @@ Generic interrupts always render in `Interrupts`. They never render inside a too - an interrupt id that you did not register. - an unbound interrupt. Something outside this chat owns it, so it cannot be resolved here. +- a `server-tool-approval`: an approval for a tool that only the server knows, such as an MCP tool. If the copy for an unbound interrupt must be different, branch on `interrupt.kind === 'unbound'`. diff --git a/docs/ui/recipes/custom-interrupt.md b/docs/ui/recipes/custom-interrupt.md index 9082a52120..ecd397e560 100644 --- a/docs/ui/recipes/custom-interrupt.md +++ b/docs/ui/recipes/custom-interrupt.md @@ -100,6 +100,7 @@ When the server asks, the plan buttons show in the interrupt list. - An interrupt id that you did not register. - An unbound interrupt. Something outside this chat owns it, so it cannot be answered here. +- A `server-tool-approval`: an approval for a tool that only the server knows, such as an MCP tool. If the copy must be different, branch on `interrupt.kind === 'unbound'`. diff --git a/docs/ui/remix.md b/docs/ui/remix.md index 83215631db..3e7fa64bd8 100644 --- a/docs/ui/remix.md +++ b/docs/ui/remix.md @@ -297,7 +297,7 @@ The config keys, part keys, and prop types are the same as in React. See [Every - A tool approval renders in the tool when you read the `interrupt` prop. - To show it in the list instead, register a component under `interruptsComponents.tools` and render `Interrupts`. -- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id and an unbound interrupt. If the copy must be different, branch on `interrupt.kind === 'unbound'`. +- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id, an unbound interrupt, and a `server-tool-approval` (a tool that only the server knows, such as an MCP tool). If the copy must be different, branch on `interrupt.kind === 'unbound'`. ### Show queued messages diff --git a/docs/ui/solid.md b/docs/ui/solid.md index 41a7ba778b..8f907cea1f 100644 --- a/docs/ui/solid.md +++ b/docs/ui/solid.md @@ -324,7 +324,7 @@ The config keys, part keys, and prop types are the same as in React. See [Every - A tool approval renders in the tool when you read `props.interrupt`. - To show it in the list instead, register a component under `interruptsComponents.tools` and render `props.Interrupts`. -- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id and an unbound interrupt. If the copy must be different, branch on `interrupt.kind === 'unbound'`. +- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id, an unbound interrupt, and a `server-tool-approval` (a tool that only the server knows, such as an MCP tool). If the copy must be different, branch on `interrupt.kind === 'unbound'`. ### List `input` before `layout` diff --git a/docs/ui/svelte.md b/docs/ui/svelte.md index 98c557892d..b66b5d5687 100644 --- a/docs/ui/svelte.md +++ b/docs/ui/svelte.md @@ -278,7 +278,7 @@ The config keys, part keys, and prop types are the same as in React. See [Every - A tool approval renders in the tool when you read the `interrupt` prop. - To show it in the list instead, register a component under `interruptsComponents.tools` and render the `interrupts` snippet. -- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id and an unbound interrupt. If the copy must be different, branch on `interrupt.kind === 'unbound'`. +- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id, an unbound interrupt, and a `server-tool-approval` (a tool that only the server knows, such as an MCP tool). If the copy must be different, branch on `interrupt.kind === 'unbound'`. ### Show queued messages diff --git a/docs/ui/vue.md b/docs/ui/vue.md index eab2b8a096..1594e76df0 100644 --- a/docs/ui/vue.md +++ b/docs/ui/vue.md @@ -358,7 +358,7 @@ The config keys, part keys, and prop types are the same as in React. See [Every - A tool approval renders in the tool when you read the `interrupt` prop. - To show it in the list instead, register a component under `interruptsComponents.tools` and render the `interrupts` slot. -- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id and an unbound interrupt. If the copy must be different, branch on `interrupt.kind === 'unbound'`. +- Generic interrupts always render in the list. `generic.fallback` renders an unregistered id, an unbound interrupt, and a `server-tool-approval` (a tool that only the server knows, such as an MCP tool). If the copy must be different, branch on `interrupt.kind === 'unbound'`. ### Use `ui.useChatContext()` in a child file diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index 871eb79d34..a19a512620 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -551,6 +551,10 @@ These rules apply to both `toolFilter` forms and to `needsApproval`: apply to `callTool()`. - `needsApproval` does not change `tools([defs])`: each `toolDefinition` keeps its own `needsApproval`. +- In `useChat`, a gated MCP call arrives as `kind: 'server-tool-approval'` + (`toolName: string`, `originalArgs: unknown`, `resolveInterrupt(approved)`). + The browser needs no tool definition. The chat route must forward + `parentRunId` and `resume` to `chat()`. - MCP Apps widget calls have no approval step, so the call handler refuses a tool that `needsApproval` marks (`{ ok: false, error: 'Tool needs approval: ' }`). - Annotations are server-declared hints. For an untrusted server, use a diff --git a/packages/ai/skills/ai-core/tool-calling/SKILL.md b/packages/ai/skills/ai-core/tool-calling/SKILL.md index 11337b965b..5274be99a3 100644 --- a/packages/ai/skills/ai-core/tool-calling/SKILL.md +++ b/packages/ai/skills/ai-core/tool-calling/SKILL.md @@ -390,6 +390,11 @@ function ChatPage() { } ``` +A tool that only the server knows (for example an MCP tool gated with +`needsApproval`) has no client definition. Its approval arrives as +`kind: 'server-tool-approval'`, with `toolName: string` and +`originalArgs: unknown`, and the same `resolveInterrupt(approved)` control. + Batch all pending approvals with `resolveInterrupts` (void — submission is async; watch `resuming` / `interruptErrors`): From 5d686420b6eb947ddc4c7f05679662aeb514be03 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:03:05 +1100 Subject: [PATCH 3/3] feat(ai-client): send the tool input schema with server-tool-approval The server adds the tool's JSON Schema to the approval interrupt metadata. The client exposes it as inputSchema only when it hashes to the binding's inputSchemaHash, so a UI can label MCP tool arguments. --- .changeset/mcp-server-tool-approval.md | 3 +- docs/config.json | 4 +- docs/tools/mcp-advanced.md | 40 ++++++++++++++++--- docs/tools/tool-approval.md | 3 +- packages/ai-client/src/interrupt-manager.ts | 22 ++++++++++ packages/ai-client/src/types.ts | 6 +++ .../tests/chat-client-interrupts.test.ts | 17 ++++++++ packages/ai-mcp/skills/ai-mcp/SKILL.md | 1 + packages/ai/src/activities/chat/index.ts | 5 +++ .../activities/chat/tools/approval-schema.ts | 10 ++++- testing/e2e/src/routes/mcp-approval.tsx | 3 ++ testing/e2e/tests/mcp-approval.spec.ts | 2 + 12 files changed, 105 insertions(+), 11 deletions(-) diff --git a/.changeset/mcp-server-tool-approval.md b/.changeset/mcp-server-tool-approval.md index a0bbab4f6d..7910ca8efa 100644 --- a/.changeset/mcp-server-tool-approval.md +++ b/.changeset/mcp-server-tool-approval.md @@ -1,5 +1,6 @@ --- '@tanstack/ai-client': minor +'@tanstack/ai': patch --- -Approvals for tools that only the server knows, such as MCP tools gated with `needsApproval`, now reach `useChat` as `kind: 'server-tool-approval'`, with `toolName`, `toolCallId`, and `originalArgs`. Before, they arrived as `generic` interrupts, so the approval UI never rendered them. Call `resolveInterrupt(true)` or `resolveInterrupt(false)` to answer one, or answer all approvals at once with `resolveInterrupts(true)`. Tools with a matching client definition still arrive as typed `tool-approval` items. A server tool whose approval needs a payload still arrives as `generic`. +Approvals for tools that only the server knows, such as MCP tools gated with `needsApproval`, now reach `useChat` as `kind: 'server-tool-approval'`, with `toolName`, `toolCallId`, `originalArgs`, and `inputSchema`. `inputSchema` is the tool's JSON Schema, so the UI can label the arguments. The client shows it only when it matches the schema hash the server checks against. Before, they arrived as `generic` interrupts, so the approval UI never rendered them. Call `resolveInterrupt(true)` or `resolveInterrupt(false)` to answer one, or answer all approvals at once with `resolveInterrupts(true)`. Tools with a matching client definition still arrive as typed `tool-approval` items. A server tool whose approval needs a payload still arrives as `generic`. diff --git a/docs/config.json b/docs/config.json index 5021877118..7ea96381e4 100644 --- a/docs/config.json +++ b/docs/config.json @@ -374,7 +374,7 @@ "label": "Tool Approval Flow", "to": "tools/tool-approval", "addedAt": "2026-04-15", - "updatedAt": "2026-11-01" + "updatedAt": "2026-11-03" }, { "label": "Provider Tools", @@ -650,7 +650,7 @@ "label": "MCP Server Tools: Advanced", "to": "tools/mcp-advanced", "addedAt": "2026-10-21", - "updatedAt": "2026-11-01" + "updatedAt": "2026-11-03" }, { "label": "MCP Events", diff --git a/docs/tools/mcp-advanced.md b/docs/tools/mcp-advanced.md index daa56ba192..2bcd4c69de 100644 --- a/docs/tools/mcp-advanced.md +++ b/docs/tools/mcp-advanced.md @@ -377,6 +377,22 @@ The run stops with an approval interrupt for each gated call. The browser has no ```tsx import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +// Use the schema's description as the label, or fall back to the name. +function argLabel( + schema: Readonly> | undefined, + name: string, +): string { + const properties = schema?.['properties'] + if (typeof properties !== 'object' || properties === null) return name + const property: unknown = Reflect.get(properties, name) + return typeof property === 'object' && + property !== null && + 'description' in property && + typeof property.description === 'string' + ? property.description + : name +} + export function McpApprovals() { const { interrupts, resuming } = useChat({ connection: fetchServerSentEvents('/api/chat'), @@ -385,11 +401,24 @@ export function McpApprovals() { return ( <> - {interrupts.map((interrupt) => - interrupt.kind === 'server-tool-approval' ? ( + {interrupts.map((interrupt) => { + if (interrupt.kind !== 'server-tool-approval') return null + const args = interrupt.originalArgs + return (

Run {interrupt.toolName}?

-
{JSON.stringify(interrupt.originalArgs, null, 2)}
+ {typeof args === 'object' && args !== null ? ( +
+ {Object.entries(args).map(([name, value]) => ( +
+
{argLabel(interrupt.inputSchema, name)}
+
{JSON.stringify(value)}
+
+ ))} +
+ ) : ( +
{JSON.stringify(args)}
+ )}
- ) : null, - )} + ) + })} ) } ``` - `toolName` is a `string` and `originalArgs` is `unknown`. In a [pool](#multi-server-pool), `toolName` has the server prefix, for example `github_create_issue`. +- `inputSchema` is the MCP tool's JSON Schema. Use it to label the arguments, or pass it to a JSON Schema form library. It is absent when the tool has no JSON Schema, or when it does not match the schema the server checks against. - The server checks the answer against the MCP tool, so the browser needs no tool definition. - Your chat route must forward `parentRunId` and `resume` to `chat()`. See [Tool Approval Flow](./tool-approval#2-add-the-server-function-and-forward-the-resume-fields). diff --git a/docs/tools/tool-approval.md b/docs/tools/tool-approval.md index 312ec25fd4..2cf3b8697f 100644 --- a/docs/tools/tool-approval.md +++ b/docs/tools/tool-approval.md @@ -296,7 +296,8 @@ Some tools exist only on the server, for example an [MCP tool with `needsApprova ```ts ignore if (interrupt.kind === 'server-tool-approval') { // toolName is a string. originalArgs is unknown. - console.log(interrupt.toolName, interrupt.originalArgs) + // inputSchema is the tool's JSON Schema, when the server has one. + console.log(interrupt.toolName, interrupt.originalArgs, interrupt.inputSchema) interrupt.resolveInterrupt(true) } ``` diff --git a/packages/ai-client/src/interrupt-manager.ts b/packages/ai-client/src/interrupt-manager.ts index 86b09d69c0..15c18d2f6a 100644 --- a/packages/ai-client/src/interrupt-manager.ts +++ b/packages/ai-client/src/interrupt-manager.ts @@ -66,6 +66,23 @@ export interface InterruptManagerOptions< type UnknownObject = { [key: string]: unknown } +/** + * The tool's input schema from the descriptor, only when it hashes to the + * binding's `inputSchemaHash`. Anything else is not the schema the server + * checks against, so it is not shown. + */ +function verifiedInputSchema( + metadata: Readonly> | undefined, + binding: Extract, +): Readonly> | undefined { + const schema = metadata?.['inputSchema'] + if (!isUnknownObject(schema)) return undefined + const hash = digestInterruptJson(canonicalInterruptJson(schema)) + return hash === binding.inputSchemaHash + ? cloneAndDeepFreezeJson(schema) + : undefined +} + /** The binding hash of an approval that takes no payload on either branch. */ const PAYLOADLESS_APPROVAL_SCHEMA_HASH = normalizeApprovalSchema(undefined).approvalSchemaHash @@ -1026,6 +1043,10 @@ export class InterruptManager< item.binding.kind === 'tool-approval' ) { const binding = cloneAndDeepFreezeJson(item.binding) + const inputSchema = + item.tool === undefined + ? verifiedInputSchema(item.descriptor.metadata, binding) + : undefined const snapshot = { ...base, // No client tool means a server-only tool: untyped public kind. @@ -1034,6 +1055,7 @@ export class InterruptManager< ? ('server-tool-approval' as const) : ('tool-approval' as const), binding, + ...(inputSchema !== undefined && { inputSchema }), toolName: item.binding.toolName, toolCallId: item.binding.toolCallId, originalArgs: cloneAndDeepFreezeJson(item.binding.originalArgs), diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts index 7b047fd1b7..4d9fecfcd6 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -243,6 +243,12 @@ export interface ServerToolApprovalInterrupt extends BoundInterruptBase { readonly toolName: string readonly toolCallId: string readonly originalArgs: unknown + /** + * The tool's input schema as JSON Schema, to label and render + * `originalArgs`. Absent when the tool has no JSON Schema, or when the schema + * does not match the one the server checks against. + */ + readonly inputSchema?: Readonly> readonly resolveInterrupt: ( approved: boolean, options?: { editedArgs?: unknown }, diff --git a/packages/ai-client/tests/chat-client-interrupts.test.ts b/packages/ai-client/tests/chat-client-interrupts.test.ts index 4c683f07fd..8f217f6d48 100644 --- a/packages/ai-client/tests/chat-client-interrupts.test.ts +++ b/packages/ai-client/tests/chat-client-interrupts.test.ts @@ -2372,6 +2372,10 @@ describe('InterruptManager server-only tool approvals', () => { expect(item.toolName).toBe('delete_note') expect(item.toolCallId).toBe('call-1') expect(item.originalArgs).toEqual({ id: 'n1' }) + expect(item.inputSchema).toMatchObject({ + type: 'object', + properties: { id: { type: 'string' } }, + }) item.resolveInterrupt(true) await settle() @@ -2380,6 +2384,19 @@ describe('InterruptManager server-only tool approvals', () => { ]) }) + it('drops an input schema that does not match the binding hash', async () => { + const tampered = (await serverApprovalInterrupts()).map((interrupt) => ({ + ...interrupt, + metadata: { + ...interrupt.metadata, + inputSchema: { type: 'object', properties: { other: {} } }, + }, + })) + const item = hydrate(tampered).manager.getInterrupts()[0] + expect(item?.kind).toBe('server-tool-approval') + expect(item && 'inputSchema' in item).toBe(false) + }) + it('approves a server-only approval with boolean bulk resolution', async () => { const { manager, submit } = hydrate(await serverApprovalInterrupts()) const id = manager.getInterrupts()[0]?.id diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index a19a512620..65e13983be 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -553,6 +553,7 @@ These rules apply to both `toolFilter` forms and to `needsApproval`: its own `needsApproval`. - In `useChat`, a gated MCP call arrives as `kind: 'server-tool-approval'` (`toolName: string`, `originalArgs: unknown`, `resolveInterrupt(approved)`). + `inputSchema` holds the MCP tool's JSON Schema; render the args from it. The browser needs no tool definition. The chat route must forward `parentRunId` and `resume` to `chat()`. - MCP Apps widget calls have no approval step, so the call handler refuses a diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 89c7c4da44..fdddcc69b0 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -98,6 +98,7 @@ import { import { hashSchemaInput, normalizeApprovalSchema, + schemaInputToJsonSchema, } from './tools/approval-schema' import { combineStrategies, @@ -3615,6 +3616,9 @@ class TextEngine< tool?.approvalSchema, tool?.inputSchema, ) + // Lets a client without the tool's definition (an MCP tool, for + // example) show the arguments. It hashes to `inputSchemaHash`. + const inputSchema = schemaInputToJsonSchema(tool?.inputSchema) interrupts.push({ id: approval.approvalId, // Display hint only. `reason` is free-form AG-UI text that another @@ -3628,6 +3632,7 @@ class TextEngine< kind: 'approval', toolName: approval.toolName, input: approval.input, + ...(inputSchema !== undefined && { inputSchema }), [interruptBindingMetadataKey]: { v: INTERRUPT_BINDING_VERSION, kind: 'tool-approval', diff --git a/packages/ai/src/activities/chat/tools/approval-schema.ts b/packages/ai/src/activities/chat/tools/approval-schema.ts index bbf5e2d973..8c4b6323a0 100644 --- a/packages/ai/src/activities/chat/tools/approval-schema.ts +++ b/packages/ai/src/activities/chat/tools/approval-schema.ts @@ -195,10 +195,16 @@ export function normalizeApprovalSchema( } } +/** The JSON Schema that {@link hashSchemaInput} hashes, when there is one. */ +export function schemaInputToJsonSchema( + schema: SchemaInput | undefined, +): JSONSchema | undefined { + return schema === undefined ? undefined : schemaToWire(schema).jsonSchema +} + export function hashSchemaInput(schema: SchemaInput | undefined): string { if (schema === undefined) return digestInterruptJson('undefined') - const normalized = schemaToWire(schema) - const identity = normalized.jsonSchema ?? { + const identity = schemaInputToJsonSchema(schema) ?? { standardValidator: 'unserialized', } return digestInterruptJson(canonicalInterruptJson(identity)) diff --git a/testing/e2e/src/routes/mcp-approval.tsx b/testing/e2e/src/routes/mcp-approval.tsx index a385f92c32..43afa0360c 100644 --- a/testing/e2e/src/routes/mcp-approval.tsx +++ b/testing/e2e/src/routes/mcp-approval.tsx @@ -44,6 +44,9 @@ function McpApprovalPage() { {JSON.stringify(interrupt.originalArgs)} + + {JSON.stringify(interrupt.inputSchema?.['properties'])} +