- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
MCP Client Input
An MCP server can pause a tool call and ask for input. It asks for one of these:
- A form that the user fills in.
- A sampling request that needs a model reply.
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.
Quick setup#
You need MCP Server Tools working first, with the client in mcp.clients.
- In your chat route, pass parentRunId and resume from the request to chat():
// 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)
}- In the UI, read interrupts from useChat. For each MCP input interrupt, show the request and call resolveInterrupt with the answer, or call cancel():
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.
How it works#
- The MCP server asks for input during a tool call.
- chat() ends the run with an interrupt. outcome.type is interrupt, and the interrupt id starts with mcp_input_.
- useChat shows the interrupt. The user answers.
- useChat sends the answer in a new run.
- chat() runs the tool again, and the MCP client sends the answer to the server.
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.
What to send#
The answer depends on the kind of the request.
- form: send an object that matches request.requestedSchema. A server from createMCPServer asks for { value: string }. The MCP client sends the object as the accepted content.
- sampling: send the model reply as a string. You can also send a full MCP CreateMessageResult.
- cancel(): for a form, the server gets { action: 'cancel' }. A createMCPServer tool then ends with a tool error. For a sampling request, the tool call ends with an error.
To decline a form, send the full MCP answer: { action: 'decline' }.
Advanced#
Read the pause on the server#
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:
- The input call has no tool result.
- The run does not emit RUN_ERROR.
- When other tools finish in the same turn, the stream has their results.
Interrupt fields#
| 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:
- A tool can set another string, for example auth_required.
- An empty reason stays mcp_input.
- The reserved reason tanstack:client_tool_execution also stays mcp_input.
Answers on the resumed tool#
- A resumed tool gets the answer on ctx.inputResponse. The MCP tools read it for you. Your own server tool can read it too.
- The client answers one input request for each tool call. If the server asks for input again after it gets the answer, the tool call ends with a tool error.
Which errors pause the run#
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:
- name: MCPInputRequiredError
- kind: form or sampling
- request: the MCP input body
- reason (optional): the reason of the interrupt. The default is 'mcp_input'.
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()#
Outside chat(), the client throws MCPInputRequiredError. In this sample, ask is the tool name on your server.
- Call tools() on the MCP client.
- Call execute on the tool.
- Test the error with isMCPInputRequiredError, then read kind and request.
- Close the client after the call.
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()
}
}