| name | ai-mcp |
| description | Host-side Model Context Protocol (MCP) client for TanStack AI: connect to external MCP servers, discover and run their tools inside any adapter's chat() loop, read resources and prompts, generate TypeScript types (typed tool names/pool keys) with the bundled CLI, and manage lifecycle with close()/await using.
|
| type | sub-skill |
| library | tanstack-ai |
| library_version | 0.10.0 |
| sources | ["TanStack/ai:docs/tools/mcp.md","TanStack/ai:packages/ai-mcp/src/client.ts","TanStack/ai:packages/ai-mcp/src/pool.ts","TanStack/ai:packages/ai-mcp/src/resources.ts","TanStack/ai:packages/ai-mcp/src/transport.ts"] |
@tanstack/ai-mcp
This skill covers the @tanstack/ai-mcp package. Read ai-core/tool-calling/SKILL.md
first — MCP tools flow into chat() the same way hand-written tools do.
When to use this package
Use @tanstack/ai-mcp when:
- A third-party MCP server exposes tools you want an agent or chat loop to call.
- You want to read MCP server resources (files, text, data) or prompts into a
chat() message list.
- You want generated TypeScript types for an external MCP server's tool
signatures (via the bundled
generate CLI).
- You are running tool execution on the server side and want to connect to MCP
servers with HTTP (Streamable HTTP or SSE) or stdio transports.
Do NOT use this package for browser/client-side code — MCP connections are
server-side only.
Install
pnpm add @tanstack/ai-mcp
The package has two subpath exports:
. — main client API (createMCPClient, createMCPClients, converters, types)
./stdio — Node-only stdio transport factory (stdioTransport); import it
separately so edge bundles stay clean
createMCPClient — single server
import { createMCPClient } from '@tanstack/ai-mcp'
const client = await createMCPClient({
transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
prefix: 'weather',
name: 'my-app',
})
createMCPClient connects immediately and returns an MCPClient. Throws
MCPConnectionError if the connection fails.
Transports
Streamable HTTP (default for internet-facing servers)
const client = await createMCPClient({
transport: {
type: 'http',
url: 'https://mcp.example.com/mcp',
headers: { Authorization: 'Bearer sk-...' },
},
})
SSE
const client = await createMCPClient({
transport: {
type: 'sse',
url: 'https://mcp.example.com/sse',
headers: { Authorization: 'Bearer sk-...' },
},
})
stdio (Node-only — import from /stdio subpath)
import { createMCPClient } from '@tanstack/ai-mcp'
import { stdioTransport } from '@tanstack/ai-mcp/stdio'
const client = await createMCPClient({
transport: stdioTransport({
command: 'npx',
args: ['-y', 'my-mcp-server'],
env: { API_KEY: process.env.API_KEY ?? '' },
}),
})
Custom transport (escape hatch)
Pass any SDK Transport instance directly:
import { createMCPClient } from '@tanstack/ai-mcp'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
const [clientTransport] = InMemoryTransport.createLinkedPair()
const client = await createMCPClient({ transport: clientTransport })
Authentication
Two levels:
- Static tokens — pass
headers on the http/sse config (sent with
every request): headers: { Authorization: 'Bearer ...' }.
- OAuth 2.1 (MCP authorization spec) — pass
authProvider on the
http/sse config. It accepts any OAuthClientProvider from
@modelcontextprotocol/sdk/client/auth.js; the SDK transport attaches
tokens, refreshes them, and retries on 401.
import { createMCPClient } from '@tanstack/ai-mcp'
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
declare const myOAuthProvider: OAuthClientProvider
const client = await createMCPClient({
transport: {
type: 'http',
url: 'https://mcp.example.com/mcp',
authProvider: myOAuthProvider,
},
})
Caveat: interactive authorization-code flows need transport.finishAuth(code),
and createMCPClient does not expose its internal transport. For redirect
flows, construct the StreamableHTTPClientTransport yourself with the
authProvider, keep a reference, call finishAuth(code) in the OAuth
callback route, then pass the transport via the escape hatch above. For
server-side providers backed by pre-provisioned/refreshable tokens, the
config form is sufficient.
Three type-safety modes
Mode 1 — Auto-discovery (no types needed)
client.tools() lists every tool the server exposes. Args are typed unknown
at compile time but the tool's JSON Schema is forwarded to the LLM.
const tools = await client.tools()
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools,
})
Use { lazy: true } to defer schema sending via the existing LazyToolManager:
const tools = await client.tools({ lazy: true })
Mode 2 — Typed via toolDefinition instances
Pass bare toolDefinition() instances (no .server() call) to client.tools([...]).
The MCP client binds a callTool proxy as the execute function while
input/output validation and TypeScript types come from the definitions' Zod schemas.
Only the named tools are returned (allowlist = the definitions' names).
Throws MCPToolNotFoundError if the server does not expose a tool with that name.
import { toolDefinition } from '@tanstack/ai'
import { createMCPClient } from '@tanstack/ai-mcp'
import { z } from 'zod'
const getWeatherDef = toolDefinition({
name: 'get_weather',
description: 'Current weather for a city',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ temperature: z.number(), conditions: z.string() }),
})
const client = await createMCPClient({
transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
})
const tools = await client.tools([getWeatherDef])
Mode 3 — Generated types (via generate CLI)
Run npx @tanstack/ai-mcp generate to introspect live servers and emit a
ServerDescriptor interface per server. Pass the generated interface as the
generic to createMCPClient<WeatherServer>(...) to narrow discovered tool
names to the server's literals (args stay untyped — use Mode 2 for typed args).
See the "Codegen CLI" section below for details.
Lifecycle
The caller owns the lifecycle. chat() never closes the client.
Tools execute lazily while the response stream is consumed — close only after
the stream is drained. In a streaming route handler, try/finally around the
return (or await using at function scope) closes the client before the
body streams; use a middleware terminal hook there instead (see Common
Mistakes below).
const client = await createMCPClient({
transport: { type: 'http', url: '...' },
})
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools: await client.tools(),
middleware: [
{
name: 'mcp-close',
onFinish: () => client.close(),
onAbort: () => client.close(),
onError: () => client.close(),
},
],
})
return toServerSentEventsResponse(stream)
const client = await createMCPClient({
transport: { type: 'http', url: '...' },
})
try {
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools: await client.tools(),
})
for await (const chunk of stream) {
}
} finally {
await client.close()
}
await using client = await createMCPClient({
transport: { type: 'http', url: '...' },
})
chat({ mcp }) — discovery + lifecycle in one prop
Rather than calling client.tools() and client.close() yourself, pass the
mcp option to chat() and let it manage the full lifecycle.
Behavior:
chat() calls .tools() on every entry in clients at run start and merges
all results into the tool list.
lazyTools: true is forwarded to tools({ lazy: true }).
connection: 'close' (default) — each client is closed when the run ends
(after the agent loop completes and the stream is drained). With
'keep-alive', chat() never closes the clients — the caller owns their
lifecycle (keep connections warm across requests).
onDiscoveryError: throw (or re-throw) to abort the entire call; return
normally to skip that source and continue. Omitting the handler re-throws
(fail-fast).
When to use mcp vs. the tools spread:
| Approach | Use when |
|---|
chat({ mcp: { clients: [...] } }) | Convenience: discovery + lifecycle handled for you; untyped args are fine |
tools: [...await client.tools([toolDefinition(...)])] | Fully-typed args/results via Zod schemas (toolDefinition mode) |
Server-side example:
import { createFileRoute } from '@tanstack/react-router'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const { messages } = await request.json()
const mcpClient = await createMCPClient({
transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
})
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
mcp: {
clients: [mcpClient],
connection: 'keep-alive',
onDiscoveryError: (err, source) => {
console.warn('MCP discovery failed for source, skipping:', err)
},
},
})
return toServerSentEventsResponse(stream)
},
},
},
})
You can also pass an MCPClients pool directly:
const pool = await createMCPClients({
github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } },
linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } },
})
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
mcp: { clients: [pool], connection: 'keep-alive' },
})
createMCPClients — multiple servers
Connect to many MCP servers in parallel. Each config key becomes the default
prefix for that server's tools, preventing name collisions across servers.
import { createMCPClients } from '@tanstack/ai-mcp'
await using pool = await createMCPClients({
github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } },
linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } },
})
const tools = await pool.tools()
const lazyTools = await pool.tools({ lazy: true })
const githubTools = await pool.clients.github.tools()
createMCPClients connects in parallel, closes already-connected clients if
any connection fails (no leaks), and throws MCPConnectionError naming the
failed server(s).
Override or disable prefixing:
await using pool = await createMCPClients({
github: { transport: { ... }, prefix: 'gh' },
linear: { transport: { ... }, prefix: '' },
})
Abort signal — cancelling in-flight MCP calls
MCP tool calls are automatically cancelled when the chat run's AbortController
fires (e.g. client disconnect, server abort). The abortSignal is threaded
through ToolExecutionContext into every callTool call with no extra code.
You can also read it in a hand-written server tool that wraps an MCP call:
const myTool = myDef.server(async (args, ctx) => {
const result = await fetch('https://slow.api/data', {
signal: ctx?.abortSignal,
})
return result.json()
})
Resources
const resources = await client.resources()
const resource = await client.readResource(resources[0].uri)
import { mcpResourceToContentPart } from '@tanstack/ai-mcp'
const part = mcpResourceToContentPart(resource.contents[0])
Inject resources into a chat turn:
import { chat } from '@tanstack/ai'
import { createMCPClient, mcpResourceToContentPart } from '@tanstack/ai-mcp'
const client = await createMCPClient({
transport: { type: 'http', url: '...' },
})
const resource = await client.readResource('file:///project/README.md')
const parts = resource.contents.map(mcpResourceToContentPart)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: [
{
role: 'user',
content: [
...parts,
{ type: 'text', content: 'Summarize this document.' },
],
},
],
})
Prompts
const prompts = await client.prompts()
const prompt = await client.getPrompt('review_code', { language: 'TypeScript' })
import { mcpPromptToMessages } from '@tanstack/ai-mcp'
const messages = mcpPromptToMessages(prompt)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: [...messages, ...userMessages],
})
MCP Apps
MCP Apps let an MCP tool surface a UI widget (static or interactive) on the
client. Two variants exist. See docs/mcp/apps.md for the full guide.
Static widgets — UIResourcePart
When an MCP tool result carries a ui:// resource, TanStack AI emits a
UIResourcePart on the assistant UIMessage, alongside the normal
ToolCallPart / ToolResultPart. It is purely presentational — it never
enters model input. The resource is read eagerly during the chat() run; if
the read fails the tool result still flows to the model and the widget is
simply absent (fail-soft). Static widgets require the MCP source to expose
readResource — both createMCPClient and a createMCPClients pool do.
import type { UIResourcePart } from '@tanstack/ai'
Interactive apps — createMcpAppCallHandler
For interactive apps (the widget iframe posts tool-call / prompt / link
actions back), mount createMcpAppCallHandler from @tanstack/ai-mcp/apps
at a POST route. Pass the MCP client(s) you already created — a single
MCPClient, an MCPClients pool, or an array of either. The handler reads
each client's transport descriptor via client.getInfo() /
pool.getServers() (pure config, not a live socket) and reconnects
per-call (stateless / serverless-safe). It matches the widget-supplied
native (unprefixed) tool name against the server's unprefixed tool names,
enforces a same-server allowlist, and returns { ok: true, result } or
{ ok: false, error }.
For a pool, the serverId on the UIResourcePart is the config key (the
tool prefix); for a single client it is the client's prefix (or the sole
default when serverId is absent and there is exactly one client).
import { createMCPClients } from '@tanstack/ai-mcp'
import {
createMcpAppCallHandler,
inMemoryMcpSessionStore,
} from '@tanstack/ai-mcp/apps'
const mcp = await createMCPClients({
weather: {
transport: { type: 'http', url: 'https://mcp-app.example.com/mcp' },
},
})
const handler = createMcpAppCallHandler({ clients: mcp })
const handlerWithStore = createMcpAppCallHandler({
clients: mcp,
store: inMemoryMcpSessionStore(),
allowTool: (req) => req.toolName === 'place_order',
})
The handler invokes the server (body: { threadId, serverId?, toolName, args?, messageId? }):
const result = await handler(body)
Client side — useMcpAppBridge + MCPAppResource
In React/Preact, create the bridge with the useMcpAppBridge hook (from
@tanstack/ai-react / @tanstack/ai-preact) — it returns a stable bridge
per threadId/callEndpoint and always calls your latest sendMessage/onLink,
so the bridge isn't recreated on every render (no useMemo / exhaustive-deps
by hand). It's a thin wrapper over the framework-agnostic createMcpAppBridge
from @tanstack/ai-client (use that directly outside React/Preact). Render
resources with MCPAppResource from @tanstack/ai-react/mcp-apps (also
@tanstack/ai-preact/mcp-apps, which requires a preact/compat alias).
MCPAppResource uses @mcp-ui/client's AppRenderer under the hood — React
only. Solid, Vue, Svelte, and Angular renderers are deferred.
The bridge exposes { callTool, sendPrompt, openLink } and routes the
iframe's actions: tool → POST to callEndpoint; prompt →
chat.sendMessage; link → onLink(url) if provided, otherwise the link
is dropped (with a console warning) and openLink returns { isError: true }
— it does NOT hang. toolName is read from part.toolName; it is not a
prop. Omit bridge for display-only (inert) rendering.
import { useChat, useMcpAppBridge } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { MCPAppResource } from '@tanstack/ai-react/mcp-apps'
function ChatPage() {
const threadId = 'weather-chat'
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
})
const bridge = useMcpAppBridge({
threadId,
callEndpoint: '/api/mcp-app/call',
chat: { sendMessage: async (content) => void sendMessage({ content }) },
onLink: (url) => window.open(url, '_blank', 'noopener'),
})
return (
<div>
{messages.map((msg) =>
msg.parts.map((part, i) => {
if (part.type === 'text') return <p key={i}>{part.content}</p>
if (part.type === 'ui-resource') {
return (
<MCPAppResource
key={i}
part={part}
bridge={bridge}
sandbox={{ url: new URL('https://sandbox.example.com') }}
// toolInput is optional; toolName comes from part.toolName.
/>
)
}
return null
}),
)}
</div>
)
}
Codegen CLI
Generate TypeScript types (typed tool names and pool keys) by introspecting live MCP servers.
1. Create mcp.config.ts at your project root:
import { defineConfig } from '@tanstack/ai-mcp'
export default defineConfig({
servers: {
github: {
transport: { type: 'http', url: 'https://mcp.github.com/mcp' },
},
},
outFile: './src/mcp-types.generated.ts',
})
2. Run the generator:
npx @tanstack/ai-mcp generate
This connects to each server, lists its tools/resources/prompts, converts JSON
Schemas to TypeScript, and writes one interface <Name>Server extends ServerDescriptor
per server plus a combined interface MCPServers for pool typing.
3. Use the generated types:
import type { GithubServer } from './src/mcp-types.generated'
import { createMCPClient } from '@tanstack/ai-mcp'
const client = await createMCPClient<GithubServer>({
transport: { type: 'http', url: 'https://mcp.github.com/mcp' },
})
const tools = await client.tools()
import type { MCPServers } from './src/mcp-types.generated'
const pool = await createMCPClients<MCPServers>({
github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } },
})
Codegen deps (json-schema-to-typescript, jiti) are bundled into the CLI bin
and do NOT appear in the library's runtime dependency graph.
Error classes
MCPConnectionError — thrown when a server connection fails or when calling
methods after close().
MCPToolNotFoundError — thrown from client.tools([defs]) when a definition's
name is not exposed by the server.
MCPTaskRequiredToolError — thrown from client.tools([defs]) when the named
tool declares execution.taskSupport: 'required' (experimental MCP tasks).
Such tools only run via the SDK's tasks/callToolStream flow, which
@tanstack/ai-mcp does not support yet; they are silently excluded from
tools() auto-discovery for the same reason.
DuplicateToolNameError — thrown by a single pool's own tools() when two
tools within that pool share the same name (same server or pool clients with no
prefix). Exported from @tanstack/ai-mcp.
MCPDuplicateToolNameError — thrown by chat() when tools from separate
mcp.clients entries collide after merging. Exported from @tanstack/ai
(not @tanstack/ai-mcp), so users can instanceof it at the chat() call site.
import {
MCPConnectionError,
MCPToolNotFoundError,
MCPTaskRequiredToolError,
DuplicateToolNameError,
} from '@tanstack/ai-mcp'
import { MCPDuplicateToolNameError } from '@tanstack/ai'
Complete server-route example
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 { messages } = await request.json()
const pool = await createMCPClients({
github: {
transport: { type: 'http', url: 'https://mcp.github.com/mcp' },
},
linear: {
transport: {
type: 'http',
url: 'https://mcp.linear.app/mcp',
headers: {
Authorization: `Bearer ${process.env.LINEAR_KEY ?? ''}`,
},
},
},
})
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools: await pool.tools(),
middleware: [
{
name: 'mcp-close',
onFinish: () => pool.close(),
onAbort: () => pool.close(),
onError: () => pool.close(),
},
],
})
return toServerSentEventsResponse(stream)
},
},
},
})
Common Mistakes
a. HIGH: closing the client before the stream finishes
chat() executes tools lazily as the model calls them during streaming.
If you close the MCP client before the response stream is fully consumed,
in-flight tool calls will fail.
Wrong:
const tools = await client.tools()
const stream = chat({ adapter, messages, tools })
await client.close()
return toServerSentEventsResponse(stream)
This includes try/finally around the return, and await using at function
scope — both close before the returned Response body streams.
Correct — close in middleware terminal hooks (exactly one of
onFinish/onAbort/onError fires per run), or consume the stream in scope
before closing:
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { createMCPClient } from '@tanstack/ai-mcp'
const client = await createMCPClient({
transport: { type: 'http', url: '...' },
})
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools: await client.tools(),
middleware: [
{
name: 'mcp-close',
onFinish: () => client.close(),
onAbort: () => client.close(),
onError: () => client.close(),
},
],
})
return toServerSentEventsResponse(stream)
b. HIGH: importing stdioTransport from the main entry point
stdioTransport is only available from @tanstack/ai-mcp/stdio. Importing it
from @tanstack/ai-mcp will fail with a module-not-found error and would
bundle Node.js child-process code into edge bundles.
Wrong:
import { stdioTransport } from '@tanstack/ai-mcp'
Correct:
import { stdioTransport } from '@tanstack/ai-mcp/stdio'
c. MEDIUM: using client.tools([defs]) without matching names
The name field on each toolDefinition must exactly match the tool name the MCP
server exposes. Mismatches throw MCPToolNotFoundError at call time, not at
type-check time (unless generated types are in use).
d. MEDIUM: not setting a prefix when multiple servers share tool names
Two different errors can arise depending on where the collision is detected:
- Within a single
createMCPClients pool — calling pool.tools() throws
DuplicateToolNameError (from @tanstack/ai-mcp) when two servers in that
pool expose the same name with no prefix to separate them.
- Across separate
mcp.clients entries in chat() — chat() throws
MCPDuplicateToolNameError (from @tanstack/ai) after merging discovered
tools from all mcp.clients entries.
In both cases, the fix is the same: use createMCPClients (which auto-prefixes
by config key) or set an explicit prefix on each createMCPClient call.
Cross-References
- See also: ai-core/tool-calling/SKILL.md — MCP tools are ServerTools; all tool
patterns (approval, lazy, client-side) apply.
- See also: ai-core/chat-experience/SKILL.md — wiring tools into
chat().