An MCP server can pause a tool call and ask for input. It asks for one of these:
You want to show that request in your UI and send the answer back to the server. chat() does this with an interrupt: the run pauses, the UI answers, and the next run calls the tool again with the answer.
You need MCP Server Tools working first, with the client in mcp.clients.
// app/api/chat/route.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const mcp = await createMCPClient({
transport: {
type: 'http',
url: 'https://my-mcp-server.example.com/mcp',
},
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
...(params.resume ? { resume: params.resume } : {}),
mcp: { clients: [mcp] },
})
return toServerSentEventsResponse(stream)
}import { useState } from 'react'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readMcpInput(
metadata: Readonly<Record<string, unknown>> | undefined,
) {
const payload = metadata?.['tanstack:interruptPayload']
if (!isRecord(payload)) return undefined
const kind = payload.kind
if (kind !== 'form' && kind !== 'sampling') return undefined
if (!isRecord(payload.request)) return undefined
return { kind, request: payload.request }
}
export function McpInputPrompt() {
const [answer, setAnswer] = useState('')
const { interrupts } = useChat({
threadId: 'thread-1',
connection: fetchServerSentEvents('/api/chat'),
})
return (
<>
{interrupts.map((interrupt) => {
if (!interrupt.id.startsWith('mcp_input_')) return null
if (interrupt.kind !== 'generic') return null
const input = readMcpInput(interrupt.metadata)
if (!input) return null
return (
<form
key={interrupt.id}
onSubmit={(event) => {
event.preventDefault()
interrupt.resolveInterrupt({ value: answer })
}}
>
<label>
{String(input.request.message ?? interrupt.message)}
<input
value={answer}
onChange={(event) => setAnswer(event.target.value)}
/>
</label>
<button type="submit">Send</button>
<button type="button" onClick={() => interrupt.cancel()}>
Cancel
</button>
</form>
)
})}
</>
)
}Try it. When a tool asks for input, the form shows the question from the server. Type an answer and click Send. The tool call continues with your answer.
The pause and the resume work on spec 2026. On spec 2025, the server asks the client for input in the middle of the tool call. chat() cannot pause that call, so the tool call fails.
The answer depends on the kind of the request.
To decline a form, send the full MCP answer: { action: 'decline' }.
The stream ends with one RUN_FINISHED chunk. You can read the interrupt there:
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
const messages = [{ role: 'user' as const, content: 'Ask the server' }]
const mcp = await createMCPClient({
transport: {
type: 'http',
url: 'https://my-mcp-server.example.com/mcp',
},
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId: 'thread-1',
mcp: { clients: [mcp] },
})
for await (const chunk of stream) {
if (chunk.type !== 'RUN_FINISHED') continue
if (chunk.outcome?.type !== 'interrupt') continue
for (const interrupt of chunk.outcome.interrupts) {
if (!interrupt.id.startsWith('mcp_input_')) continue
const payload = interrupt.metadata?.['tanstack:interruptPayload']
console.log(interrupt.id, payload)
}
}During the pause:
| Field | Value |
|---|---|
| id | mcp_input_ and the tool call id. Find the pause by this id. |
| reason | Display text. The default is mcp_input. See the rules below. |
| message | Input required to run and the tool name. |
| metadata.toolName | The tool name. |
| metadata['tanstack:interruptPayload'] | kind and request. kind is form or sampling. request is the MCP input body. |
Rules for reason:
If a tool throws a plain Error, the result is a tool error. The run does not pause.
chat() pauses only for a thrown value with this shape:
A tool can throw this shape with its own reason, for example auth_required. The interrupt id stays mcp_input_<toolCallId>, and the resume uses the same id. reason is only display text, so find the pause by its id.
Outside chat(), the client throws MCPInputRequiredError. In this sample, ask is the tool name on your server.
import { createMCPClient, isMCPInputRequiredError } from '@tanstack/ai-mcp'
export async function callAsk() {
const mcp = await createMCPClient({
transport: {
type: 'http',
url: 'https://my-mcp-server.example.com/mcp',
},
})
try {
const tools = await mcp.tools()
const ask = tools.find((tool) => tool.name === 'ask')
if (!ask?.execute) return
await ask.execute({})
} catch (error) {
if (!isMCPInputRequiredError(error)) throw error
console.log(error.kind, error.request)
} finally {
await mcp.close()
}
}