MCP `needsApproval` calls show up as `generic` interrupts in `useChat`, so the Approve/Deny UI from the docs never renders
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 3/5
- Tiempo estimado
- 1-2 días
- Aptitud para principiantes
- 58/100
- Tipo de issue
- Error
- Claridad
- Bien especificado
- Estado de actividad
- Activo
- Stack tecnológico
- react, typescript
- Área
- backend-api-design, frontend
Línea de trabajo
Start with packages/ai-client/src/interrupt-manager.ts, where the issue says the tool-approval binding falls through when no matching client tool definition exists. Reproduce with the described MCP server and InterruptManager test, then inspect the interrupt descriptor and existing approval tests. Done means useChat exposes the approval as tool-approval with toolName and originalArgs, or the MCP docs clearly explain the required client setup.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
TanStack AI version: @tanstack/ai, ai-client, ai-mcp, ai-react 1.0.0 (main @ 669fd31d)
Framework/Library version: React 19.2.3
Describe the bug and the steps to reproduce it
If you gate MCP tools with createMCPClient({ needsApproval }), the server side works fine. The run pauses with a proper tool-approval binding. But on the client the interrupt comes out as kind: 'generic', not kind: 'tool-approval'. The UI from Tool Approval only renders interrupt.kind === 'tool-approval', so nothing shows up and the run just sits there.
The cause is in packages/ai-client/src/interrupt-manager.ts. The tool-approval branch only binds when useChat({ tools }) has a definition with the same name, needsApproval: true, and matching input/approval/response schema hashes. MCP tools are discovered on the server at runtime, so the browser has no such definition. The item falls through to the generic fallback.
Server:
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { createMCPClient } from '@tanstack/ai-mcp'
import { openaiText } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const { messages, ...rest } = await request.json()
const notes = await createMCPClient({
transport: { type: 'http', url: 'http://localhost:3001/mcp' },
needsApproval: () => true,
})
return toServerSentEventsResponse(
chat({ adapter: openaiText('gpt-6.1-sol'), messages, ...rest, mcp: { clients: [notes] } }),
)
}
MCP server (@tanstack/ai-mcp/server), one tool:
const deleteNote = toolDefinition({
name: 'delete_note',
description: 'Delete a note by id',
inputSchema: z.object({ id: z.string() }),
}).server(async ({ id }) => `deleted ${id}`)
export const server = createMCPServer({ name: 'notes', version: '1.0.0', tools: [deleteNote] })
Client:
const { interrupts } = useChat({ connection: fetchServerSentEvents('/api/chat') })
// ask "delete note n1"
console.log(interrupts.map((i) => [i.kind, i.canResolve]))
// [['generic', true]] ← expected [['tool-approval', true]]
I checked this on main with a vitest test: real createMCPServer, createMCPClient({ needsApproval: () => true }), chat({ mcp }) with a mock adapter that calls delete_note, then the emitted interrupts hydrated into InterruptManager. The descriptor carries tanstack:interruptBinding.kind: 'tool-approval', but with no client tools the snapshot is { kind: 'generic', canResolve: true }.
Two things I found while digging:
- Answering the generic item with
resolveInterrupt({ approved: true })does work. The server resumes and the MCP tool runs (deleted n1). So it's a client typing/routing gap, not a server one. But you only find that out by readinginterrupt-manager.ts, and you losetoolName/originalArgs(they're only indescriptor.metadata). - Building a client def by hand fixes it.
toolDefinition({ name: 'delete_note', description, inputSchema: <JSON schema from the MCP tools list>, needsApproval: true })inuseChat({ tools })makes the itemtool-approval, andresolveInterrupt(true)submits as usual. With a pool, the name needs the prefix (notes_delete_note). The catch is the browser needs the server's tool list, so the app has to add an endpoint just for that.
The docs say the opposite in MCP, Limit and Gate Tools:
The client approves or denies it the same way as for any other tool.
That's only true if you also ship a matching client definition.
Expected: an MCP tool gated with needsApproval shows up as kind: 'tool-approval' (with toolName and originalArgs) in useChat, with no extra client setup, or the docs say what you need to add.
Actual: it shows up as kind: 'generic', so the documented approval UI skips it.
Two possible fixes, happy to hear which one fits better:
- A generic approval binding for server tools that have no client definition. When the binding is
tool-approvalbut there's no matching client tool, surface it astool-approvalanyway, typed loosely (toolName: string,originalArgs: unknown,resolveInterrupt(boolean)), with the server'sresponseSchemaas the source of truth. Hash checks still apply when there IS a client def. - A documented helper that builds client defs from an MCP tool list, e.g.
mcpClientTools(list)in@tanstack/ai-mcp, plus a small server helper to expose the gated names and JSON Schemas. And a docs fix in mcp-advanced pointing to it.
Option 1 feels nicer since it also covers any other server-only tool with approval, imho.
Minimal reproduction: vitest repro below. Drop it in packages/ai-mcp/tests/ on main and run npx vitest run <file> --silent=false --reporter=verbose after a build.
mcp-approval-repro.test.ts
import { describe, expect, it, vi } from 'vitest'
import { EventType, chat, toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { InterruptManager } from '../../ai-client/src/interrupt-manager'
import { createMCPClient } from '../src/client'
import { createMCPServer } from '../src/server/create-server'
import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai'
const deleteNote = toolDefinition({
name: 'delete_note',
description: 'Delete a note by id',
inputSchema: z.object({ id: z.string() }),
}).server(async ({ id }) => `deleted ${id}`)
const server = createMCPServer({
name: 'notes',
version: '1.0.0',
tools: [deleteNote],
})
async function connect() {
return createMCPClient({
transport: {
type: 'http',
url: 'https://notes.example.com/mcp',
fetch: (input, init) => server.fetch(new Request(input, init)),
},
needsApproval: () => true,
})
}
function adapterCalling(seen: Array<string>): AnyTextAdapter {
return {
kind: 'text',
name: 'mock',
model: 'test-model',
'~types': {
providerOptions: {},
inputModalities: ['text'],
messageMetadataByModality: {
text: undefined,
image: undefined,
audio: undefined,
video: undefined,
document: undefined,
},
toolCapabilities: [],
toolCallMetadata: undefined,
systemPromptMetadata: undefined,
},
chatStream: (options) => {
const names = (options.tools ?? []).map((t) => t.name)
seen.push(...names)
const name = names[0]!
return (async function* () {
yield { type: EventType.RUN_STARTED, runId: 'r1', threadId: 't1', timestamp: 1 }
yield { type: EventType.TOOL_CALL_START, toolCallId: 'call-1', toolCallName: name, timestamp: 1 }
yield { type: EventType.TOOL_CALL_ARGS, toolCallId: 'call-1', delta: '{"id":"n1"}', timestamp: 1 }
yield { type: EventType.TOOL_CALL_END, toolCallId: 'call-1', timestamp: 1 }
yield { type: EventType.RUN_FINISHED, runId: 'r1', threadId: 't1', finishReason: 'tool_calls', timestamp: 1 }
})() as never
},
structuredOutput: async () => ({ data: {}, rawText: '{}' }),
}
}
async function serverInterrupt() {
const mcp = await connect()
const seen: Array<string> = []
const emitted: Array<StreamChunk> = []
for await (const chunk of chat({
adapter: adapterCalling(seen),
messages: [{ role: 'user', content: 'delete n1' }],
runId: 'r1',
threadId: 't1',
mcp: { clients: [mcp] },
})) {
emitted.push(chunk)
}
const terminal = emitted.find(
(c) => c.type === EventType.RUN_FINISHED && c.outcome?.type === 'interrupt',
)
if (terminal?.type !== EventType.RUN_FINISHED || terminal.outcome?.type !== 'interrupt') {
throw new Error('no interrupt terminal')
}
const mcpTools = await (await connect()).tools()
return { interrupts: terminal.outcome.interrupts, seen, mcpTools }
}
describe('MCP needsApproval -> useChat', () => {
it('without a client definition', async () => {
const { interrupts, seen } = await serverInterrupt()
console.log('TOOLS SEEN BY MODEL', seen)
console.log('INTERRUPT', JSON.stringify(interrupts, null, 2))
const submit = vi.fn(async () => undefined)
const manager = new InterruptManager({ tools: [], submit })
manager.hydrate({ threadId: 't1', interruptedRunId: 'r1', generation: 0, interrupts })
const item = manager.getInterrupts()[0]!
console.log('NO CLIENT DEF', { kind: item.kind, canResolve: item.canResolve })
expect(item.kind).not.toBe('tool-approval')
;(item as any).resolveInterrupt({ approved: true })
await Promise.resolve()
await Promise.resolve()
const submission: any = (submit.mock.calls as any)[0]?.[0]
console.log('GENERIC SUBMITTED', JSON.stringify(submission?.resolutions))
// Resume on the server with what the generic item submitted.
const mcp = await connect()
const calls: Array<string> = []
const resumed: Array<StreamChunk> = []
for await (const chunk of chat({
adapter: {
...adapterCalling([]),
chatStream: () =>
(async function* () {
yield { type: EventType.RUN_STARTED, runId: 'r2', threadId: 't1', timestamp: 1 }
yield { type: EventType.RUN_FINISHED, runId: 'r2', threadId: 't1', finishReason: 'stop', timestamp: 1 }
})() as never,
},
messages: [
{ id: 'u1', role: 'user', parts: [{ type: 'text', content: 'delete n1' }] },
{
id: 'a1',
role: 'assistant',
parts: [
{
type: 'tool-call',
id: 'call-1',
name: 'delete_note',
arguments: '{"id":"n1"}',
input: { id: 'n1' },
state: 'approval-requested',
approval: { id: 'approval_call-1', needsApproval: true },
},
],
},
] as never,
threadId: 't1',
runId: 'r2',
parentRunId: 'r1',
resume: submission.resolutions,
mcp: { clients: [mcp] },
})) {
resumed.push(chunk)
if (chunk.type === EventType.TOOL_CALL_RESULT) calls.push(JSON.stringify(chunk))
}
console.log('RESUME CHUNKS', resumed.map((c) => c.type), calls, JSON.stringify(resumed.filter((c) => c.type === EventType.RUN_ERROR)))
})
it('with a client definition built from the MCP tool list', async () => {
const { interrupts, mcpTools } = await serverInterrupt()
const t = mcpTools[0]!
const clientDef = toolDefinition({
name: t.name,
description: t.description,
inputSchema: t.inputSchema as never,
needsApproval: true,
})
const submit = vi.fn(async () => undefined)
const manager = new InterruptManager({ tools: [clientDef] as never, submit })
manager.hydrate({ threadId: 't1', interruptedRunId: 'r1', generation: 0, interrupts })
const item = manager.getInterrupts()[0]!
console.log('WITH CLIENT DEF', { kind: item.kind, canResolve: item.canResolve })
expect(item.kind).toBe('tool-approval')
expect(item.canResolve).toBe(true)
if (item.kind === 'tool-approval') (item as any).resolveInterrupt(true)
await Promise.resolve()
await Promise.resolve()
console.log('SUBMITTED', JSON.stringify(submit.mock.calls[0]?.[0], null, 2))
expect(submit).toHaveBeenCalledOnce()
})
})
Do you intend to try to help solve this bug with your own PR? Maybe, I'll investigate and start debugging
- I agree to follow this project's Code of Conduct
- I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.
- Lenguaje dominante
- TypeScript
- Estrellas
- 3.2k
- Forks
- 372
- Merge medio
- 1 d 9 h
- PR fusionados (30 d)
- 229
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de TanStack/ai
-
openai-base: a Responses max_output_tokens cutoff ends as RUN_ERROR without usage instead of finishReason 'length'Posiblemente ocupada @pcbeingused333 la tomó hace 1 día. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
TanStack/ai#1916 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
[ai-harness] Rejected receipt resolves before durable rejection; recovery can rerun the inputAbierto
Dificultad 4/5 3-5 días Aptitud para principiantes 52/100
Los mantenedores suelen responder en 1 día
-
Dificultad 3/5 1-2 días Aptitud para principiantes 64/100
TanStack/ai#1926 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
ai-vercel-gateway: usage.cost leaves out gateway surcharges, and the BYOK credential type is not exposedPosiblemente ocupada @pcbeingused333 la tomó hace 1 día. Abierto
Dificultad 3/5 1-2 días Aptitud para principiantes 55/100
Los mantenedores suelen responder en 1 día
-
ai-gemini: native image generation throws "returned no id" when modelOptions.store is falsePosiblemente ocupada @pcbeingused333 la tomó hace 1 día. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 57/100
Los mantenedores suelen responder en 1 día
Todos los issues de TanStack/ai
Issues similares
-
area:plugins area:security area:skills bug has repro
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
anthropics/claude-code#105911 ·
Los mantenedores suelen responder en 1 día
-
openspec-apply-change skill: when blocked, may select a ready artifact unrelated to missingArtifactsAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
Fission-AI/OpenSpec#2165 ·
Los mantenedores suelen responder en 1 día
-
🐞 bug 🧐 unconfirmed
Dificultad 2/5 1-3 horas Aptitud para principiantes 66/100
Tencent/tdesign-vue-next#7032 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
gnosis/gnosis_vpn#661 ·
Los mantenedores suelen responder en 1 día
-
content-freshness: staleOwnedContent doc comments still say 'complete receipt' (follow-up to #7912)Abiertotriage:bug-verified
Dificultad 1/5 Menos de una hora Aptitud para principiantes 92/100
Los mantenedores suelen responder en 1 día