Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mcp-server-tool-approval.md
Original file line number Diff line number Diff line change
@@ -0,0 +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`, `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`.
21 changes: 11 additions & 10 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@
"label": "Tool Approval Flow",
"to": "tools/tool-approval",
"addedAt": "2026-04-15",
"updatedAt": "2026-10-22"
"updatedAt": "2026-11-03"
},
{
"label": "Provider Tools",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -649,7 +649,8 @@
{
"label": "MCP Server Tools: Advanced",
"to": "tools/mcp-advanced",
"addedAt": "2026-10-21"
"addedAt": "2026-10-21",
"updatedAt": "2026-11-03"
},
{
"label": "MCP Events",
Expand Down
3 changes: 3 additions & 0 deletions docs/interrupts/multiple.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 71 additions & 1 deletion docs/tools/mcp-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,77 @@ 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'

// Use the schema's description as the label, or fall back to the name.
function argLabel(
schema: Readonly<Record<string, unknown>> | 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'),
threadId: 'support-chat',
})

return (
<>
{interrupts.map((interrupt) => {
if (interrupt.kind !== 'server-tool-approval') return null
const args = interrupt.originalArgs
return (
<div key={interrupt.id}>
<p>Run {interrupt.toolName}?</p>
{typeof args === 'object' && args !== null ? (
<dl>
{Object.entries(args).map(([name, value]) => (
<div key={name}>
<dt>{argLabel(interrupt.inputSchema, name)}</dt>
<dd>{JSON.stringify(value)}</dd>
</div>
))}
</dl>
) : (
<pre>{JSON.stringify(args)}</pre>
)}
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(true)}
>
Approve
</button>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(false)}
>
Deny
</button>
</div>
)
})}
</>
)
}
```

- `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).

In a [pool](#multi-server-pool), each server gets its own options:

Expand Down
16 changes: 16 additions & 0 deletions docs/tools/tool-approval.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,22 @@ 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.
// inputSchema is the tool's JSON Schema, when the server has one.
console.log(interrupt.toolName, interrupt.originalArgs, interrupt.inputSchema)
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:
Expand Down
2 changes: 1 addition & 1 deletion docs/ui/angular.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/ui/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`.

Expand Down
1 change: 1 addition & 0 deletions docs/ui/recipes/custom-interrupt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`.

Expand Down
2 changes: 1 addition & 1 deletion docs/ui/remix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/ui/solid.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion docs/ui/svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/ui/vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/ai-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type {
UnboundInterrupt,
InterruptItemStatus,
ToolApprovalInterrupt,
ServerToolApprovalInterrupt,
ClientContextOptionFromTools,
ChatResumeState,
ChatRequestBody,
Expand Down
50 changes: 49 additions & 1 deletion packages/ai-client/src/interrupt-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@ 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<Record<string, unknown>> | undefined,
binding: Extract<InterruptBinding, { kind: 'tool-approval' }>,
): Readonly<Record<string, unknown>> | 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

type RuntimeKind =
| 'generic'
| 'tool-approval'
Expand Down Expand Up @@ -823,6 +844,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.
Expand Down Expand Up @@ -1004,10 +1043,19 @@ 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,
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,
...(inputSchema !== undefined && { inputSchema }),
toolName: item.binding.toolName,
toolCallId: item.binding.toolCallId,
originalArgs: cloneAndDeepFreezeJson(item.binding.originalArgs),
Expand Down
32 changes: 32 additions & 0 deletions packages/ai-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,36 @@ export type ToolApprovalInterrupt<TTool extends AnyClientTool = AnyClientTool> =
}
: 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<InterruptBinding, { kind: 'tool-approval' }>
>
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<Record<string, unknown>>
readonly resolveInterrupt: (
approved: boolean,
options?: { editedArgs?: unknown },
) => void
}

type ApprovalInterrupts<TTools extends ReadonlyArray<AnyClientTool>> =
TTools[number] extends infer TTool
? TTool extends AnyClientTool
Expand All @@ -247,6 +277,7 @@ export type ChatInterrupt<
| RegisteredGenericInterrupt<TInterrupts>
| UnboundInterrupt
| ApprovalInterrupts<TTools>
| ServerToolApprovalInterrupt

export type ResolvableChatInterrupt<
TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,
Expand All @@ -256,6 +287,7 @@ export type ResolvableChatInterrupt<
| GenericAGUIInterrupt
| RegisteredGenericInterrupt<TInterrupts>
| ApprovalInterrupts<TTools>
| ServerToolApprovalInterrupt

export type BoundInterrupts<
TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,
Expand Down
Loading
Loading