Call the MCP client yourself when you need typed tool arguments, resources, or prompts. Pass the results into chat().
On this path, you own the client and you close it. If you only need tools, and you want chat() to close the client, use Managed MCP with chat().
// src/routes/api.chat.ts
import { createFileRoute } from '@tanstack/react-router'
import { chat, toServerSentEventsResponse, toolDefinition } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
import { z } from 'zod'
const searchDef = toolDefinition({
name: 'search',
description: 'Search for items',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.array(z.object({ id: z.string(), title: z.string() })),
})
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const { messages, threadId } = await request.json()
const mcp = await createMCPClient({
transport: { type: 'http', url: process.env.MCP_URL! },
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
// Typed MCP tools, merged with any other tools you pass
tools: [...(await mcp.tools([searchDef]))],
// Close after the run ends. Tools run while the response streams.
middleware: [
{
name: 'mcp-close',
onFinish: () => mcp.close(),
onAbort: () => mcp.close(),
onError: () => mcp.close(),
},
],
})
return toServerSentEventsResponse(stream)
},
},
},
})The search tool now has the argument type { query: string }, and Zod checks the arguments at runtime.
A server can send instructions that tell the model how to use its tools. Put them in the system prompt, so the model reads them before it calls a tool.
Read them from mcp.instructions after connect. The value is undefined when the server sends none.
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: { type: 'http', url: process.env.MCP_URL! },
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: 'What is the weather in Paris?' }],
threadId: 'thread-1',
systemPrompts: mcp.instructions ? [mcp.instructions] : [],
tools: await mcp.tools(),
})In a multi-server pool, each client has its own value: pool.clients.github!.instructions.
A server can serve an MCP extension, with methods that MCPClient has no helper for. Call them with mcp.request. It takes the same arguments as the SDK Client.request, and it uses the client's requestOptions.
import { createMCPClient } from '@tanstack/ai-mcp'
import { z } from 'zod'
const mcp = await createMCPClient({
transport: { type: 'http', url: process.env.MCP_URL! },
clientOptions: {
capabilities: { extensions: { 'com.example/notes': {} } },
},
})
const extensions = mcp.capabilities.extensions
if (
typeof extensions === 'object' &&
extensions !== null &&
'com.example/notes' in extensions
) {
const notes = await mcp.request(
{ method: 'notes/count', params: {} },
z.object({ count: z.number() }),
)
console.log(notes.count)
}To serve an extension, see Serve an MCP extension.
On protocol 2026-07-28, the client sends server/discover when it connects. Some hosts open a new connection for each request. They pay for that round trip each time.
Save the result per user or token, then pass it back as prior:
import { createMCPClient } from '@tanstack/ai-mcp'
import type { DiscoverResult } from '@tanstack/ai-mcp'
const MAX_AGE_MS = 5 * 60_000
const saved = new Map<string, { discover: DiscoverResult; savedAt: number }>()
export async function connect(token: string) {
const hit = saved.get(token)
const fresh = hit !== undefined && Date.now() - hit.savedAt < MAX_AGE_MS
const mcp = await createMCPClient({
transport: {
type: 'http',
url: process.env.MCP_URL!,
headers: { Authorization: `Bearer ${token}` },
},
prior: fresh ? { kind: 'modern', discover: hit.discover } : undefined,
})
const discover = mcp.getDiscoverResult()
if (!fresh && discover) saved.set(token, { discover, savedAt: Date.now() })
return mcp
}MCP resources are documents that the server exposes: files, database records, web pages. Read one and add it to a message as content parts:
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient, mcpResourceToContentPart } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: { type: 'http', url: process.env.MCP_URL! },
})
const resources = await mcp.resources()
// resources: Array<{ uri: string; name: string; ... }>
const readResult = await mcp.readResource(resources[0]!.uri)
const parts = readResult.contents.map(mcpResourceToContentPart)
// Add the resource to a user message
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [
{
role: 'user',
content: [
...parts,
{ type: 'text', content: 'Summarize the above document.' },
],
},
],
threadId: 'thread-1',
})mcpResourceToContentPart maps each MCP content block to a ContentPart:
| MCP content block | ContentPart |
|---|---|
| Has a text field | { type: 'text', content: text } |
| Has a blob field | { type: 'text', content: '[binary resource <uri>]' } |
| Anything else | { type: 'text', content: JSON.stringify(content) } |
import { createMCPClient } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: { type: 'http', url: process.env.MCP_URL! },
})
const templates = await mcp.resourceTemplates()
// templates: Array<ResourceTemplate>MCP prompts are message templates that the server exposes. Get a prompt, change it to ModelMessage[] with mcpPromptToMessages, and put it at the start of the conversation:
import { createFileRoute } from '@tanstack/react-router'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient, mcpPromptToMessages } from '@tanstack/ai-mcp'
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const { messages, threadId } = await request.json()
const mcp = await createMCPClient({
transport: { type: 'http', url: process.env.MCP_URL! },
})
try {
// List all available prompts on the server
const available = await mcp.prompts()
// available: Array<{ name: string; description?: string; arguments?: ... }>
// Get one prompt, and fill its template arguments
const prompt = await mcp.getPrompt('summarize', { language: 'english' })
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [
// Start the conversation with the prompt messages of the server
...mcpPromptToMessages(prompt),
// Then add the messages of the user
...messages,
],
threadId,
})
return toServerSentEventsResponse(stream)
} finally {
// Safe here: all MCP calls (prompts/getPrompt) completed before chat()
// started, and no MCP tools go to the run. If you also spread
// MCP tools into `tools`, close in a middleware hook instead
// (see "Quick setup: typed tools" above).
await mcp.close()
}
},
},
},
})getPrompt(name, args?) takes an optional args of type Record<string, string>. It fills the template variables of the prompt.
mcpPromptToMessages maps each MCP prompt message to a ModelMessage:
| MCP prompt message | ModelMessage |
|---|---|
| role === 'assistant' | { role: 'assistant', content: text } |
| Any other role | { role: 'user', content: text } |
| Content that is not text | content is the JSON.stringify output |
A chat run can stop, for example when the user leaves the page or an AbortController fires. Then TanStack AI stops the wait for MCP tool calls in progress. The abort signal of the run goes to the execute function of each tool in ToolExecutionContext.abortSignal.
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
const messages = [{ role: 'user' as const, content: 'Hello' }]
const mcp = await createMCPClient({
transport: { type: 'http', url: process.env.MCP_URL! },
})
const controller = new AbortController()
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId: 'thread-1',
tools: await mcp.tools(),
abortController: controller,
})
// Stop the run and the wait for MCP tool calls in progress:
controller.abort()This TanStack Start route connects to two MCP servers and streams the response to the browser.
Server route (src/routes/api.chat.ts):
import { createFileRoute } from '@tanstack/react-router'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClients } from '@tanstack/ai-mcp'
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.json()
if (typeof body !== 'object' || body === null || !Array.isArray(body.messages)) {
return new Response('Bad request', { status: 400 })
}
const pool = await createMCPClients({
github: {
transport: {
type: 'http',
url: process.env.GITHUB_MCP_URL!,
headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` },
},
},
linear: {
transport: {
type: 'http',
url: process.env.LINEAR_MCP_URL!,
headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` },
},
},
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: body.messages,
threadId: body.threadId,
tools: await pool.tools(),
// Close after the run ends. Tools run while the response streams.
middleware: [
{
name: 'mcp-close',
onFinish: () => pool.close(),
onAbort: () => pool.close(),
onError: () => pool.close(),
},
],
})
return toServerSentEventsResponse(stream)
},
},
},
})Client component (src/components/Chat.tsx):
import { useChat } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
}
export function Chat() {
const { messages, sendMessage, status } = useChat(chatOptions)
return (
<div>
<ul>
{messages.map((m) => (
<li key={m.id}>
<strong>{m.role}:</strong>{' '}
{m.parts.find((p) => p.type === 'text')?.content}
</li>
))}
</ul>
<button
onClick={() => sendMessage({ content: 'List my open GitHub issues' })}
disabled={status === 'streaming'}
>
Ask
</button>
</div>
)
}Click Ask. The model calls the GitHub tools, and the answer streams into the list.
To check tool names at compile time too, see MCP Type Generation.