本页还没有译文
本页 32 段还没译成Deutsch,下面显示的是英文原文。MCP Client Input
还没翻译译自英文(tanstack.com)本页 32 段都还没译
An MCP server can stop a tool call. The server asks for input.
- A form needs an answer from the user.
- A sampling request needs a model reply.
You want that request in the UI, and you want the answer to reach the server. chat() ends the run with an interrupt. outcome.type is interrupt. The payload has kind and request. When the user answers, the next run calls the tool again with the answer.
Read the pause
Pass the MCP client to chat() in mcp.clients. MCP Server Tools has that setup.
The stream ends on one RUN_FINISHED chunk. 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-5.5'),
messages,
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)
}
}The pause follows these rules:
- The input call has no tool result.
- The run does not emit
RUN_ERROR. - When other tools finish in that turn, the stream has those results.
The interrupt has these fields:
id:mcp_input_and the tool call id. Read the pause by this id.reason: display text. The default ismcp_input. A tool can set another string, for exampleauth_required. An empty reason staysmcp_input. These reasons also staymcp_input:approval_required,client_tool_input, andtanstack:client_tool_execution.message:Input required to runand the tool namemetadata.toolName: the tool namemetadata['tanstack:interruptPayload']:kindandrequest
kind is form or sampling. request is the MCP input body.
Answer the request
The answer comes back in a new request. Pass parentRunId and resume from that 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-5.5'),
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)
}Then answer the request in the UI:
- Read
interruptsfromuseChat. - Find the item where
reasonismcp_input. - Read
metadata['tanstack:interruptPayload']and show the request. - Call
resolveInterruptwith the answer, or callcancel().
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>
)
})}
</>
)
}useChat sends the answer in a new run. Then chat() runs the tool again, and the MCP client sends the answer to the server.
What to send
The answer depends on kind.
form: send an object that matchesrequest.requestedSchema. A server fromcreateMCPServerasks 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 MCPCreateMessageResult.cancel(): for a form, the server gets{ action: 'cancel' }. AcreateMCPServertool 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' }.
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 per tool call. If the server asks for input again after it gets the answer, the tool call ends with a tool error.
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.
A tool error
If the thrown value is a plain Error, the result is a tool error. The run does not pause.
chat() pauses only for this shape:
name:MCPInputRequiredErrorkind:formorsamplingrequest: the MCP input bodyreason(optional): thereasonof 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>. Read the pause by that id. reason is display text. The resume uses the same id.
Outside chat()
Outside chat(), the client throws MCPInputRequiredError. The name ask is the tool name on your server.
- Call
tools()on the MCP client. - Call
executeon the tool. - Check the error with
isMCPInputRequiredError, then readkindandrequest. - 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()
}
}