This page is the reference for createMCPClient and createMCPClients. To connect your first server, start at MCP Server Tools.
If the server takes an API key or a bearer token, pass headers on the http or sse transport. The client sends them with each request:
import { createMCPClient } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: {
type: 'http',
url: 'https://my-mcp-server.example.com/mcp',
headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` },
},
})If the server uses the MCP authorization spec (OAuth 2.1), pass an authProvider on the http or sse transport. The value is an OAuthClientProvider from @modelcontextprotocol/client.
The transport does these steps for you:
import { createMCPClient } from '@tanstack/ai-mcp'
import type { OAuthClientProvider } from '@modelcontextprotocol/client'
import { myTokenStore } from './token-store'
// Server-side: back the provider with tokens you persist (database, KV, ...).
// `tokens()` returning a valid (or refreshable) token set is all the SDK
// needs to authenticate requests.
const myOAuthProvider: OAuthClientProvider = myTokenStore.provider()
const mcp = await createMCPClient({
transport: {
type: 'http',
url: 'https://my-mcp-server.example.com/mcp',
authProvider: myOAuthProvider,
},
})For a provider with stored tokens and a working refresh, this form is all you need.
An interactive redirect flow needs one more step. After the redirect, you must call finishAuth(code) on the transport. createMCPClient makes the transport internally, so you cannot reach it. Make the transport yourself instead:
You can pass any Transport instance as the transport option. For tests in the same process, @tanstack/ai-mcp exports InMemoryTransport:
import { createMCPClient, InMemoryTransport } from '@tanstack/ai-mcp'
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
const mcp = await createMCPClient({ transport: clientTransport })For a custom network transport, pass a Transport from @modelcontextprotocol/client:
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
const transport = new StreamableHTTPClientTransport(new URL('https://example.com/mcp'))
const mcp = await createMCPClient({ transport })By default, each request waits up to 60 seconds, then fails. A long tool, such as a report or a build, fails at that limit too.
Set requestOptions to change the limit. It applies to the connect handshake, tool lists, tool calls, resource requests, and prompt requests:
import { createMCPClient } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
requestOptions: {
// Wait up to 2 minutes for each request.
timeout: 120_000,
// Start the 2 minutes again each time the server reports progress.
resetTimeoutOnProgress: true,
},
})Edge cases:
Each server in a pool can have its own requestOptions. MCP Apps widget calls use the options of the same client.
Call tools() with no arguments to get every tool of the server. This needs no setup. The tool arguments have the type unknown at compile time. The client uses the MCP JSON Schema to check them at runtime.
import { createMCPClient } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
})
const tools = await mcp.tools()
// tools: ServerTool[] (args typed unknown at compile time)Some providers reject an object schema without properties. To keep those tools working, the client adds defaults:
MCP schemas often use $ref or oneOf. On adapters built on the OpenAI API, those tools go out without strict mode, and the adapter warns in development. See Tools that cannot use strict mode.
Task-based tools work too:
Pass TanStack toolDefinition() instances to get full TypeScript types and Zod validation. You get only the named tools back. If a name is not on the server, tools() throws MCPToolNotFoundError. Task-required tools use the same task execution as Mode 1.
import { toolDefinition } from '@tanstack/ai'
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() })),
})
const mcp = await createMCPClient({
transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
})
const tools = await mcp.tools([searchDef])
// tools[0].execute is typed: (args: { query: string }) => ...Run the CLI against a live server to generate an interface for each server. Then pass the type as a generic to createMCPClient. You get:
Tool arguments stay unknown on this path. For typed arguments, use Mode 2 too.
See MCP Type Generation for the mcp.config.ts setup, the generate CLI, and how to use the types.
An MCP server can send display and behavior data with each tool:
@tanstack/ai-mcp puts this data on metadata.mcp of each tool. It does this in Mode 1 and in Mode 2. Use it to label tools in your UI and to pick which tools need a confirmation step.
| metadata.mcp field | Value |
|---|---|
| title | string. The display name, in this order of precedence: the title of the tool, then annotations.title, then name. Always set. |
| annotations | A frozen copy of the annotations object of the server. Not set when the server sends none. To change a value, copy it first: { ...tool.metadata.mcp.annotations, readOnlyHint: false }. |
| serverToolName | string. The tool name on the server, without a prefix. |
| serverId | The prefix of the client. undefined when there is no prefix. |
| uiResourceUri | The MCP Apps widget link, when the tool declares one. |
tools() returns McpServerTool items. Each one is a normal ServerTool, so it goes into chat({ tools }) as is. Its metadata.mcp has a static type, so you can read it with no cast. A misspelled field is a compile error:
import { createMCPClient } from '@tanstack/ai-mcp'
const url = 'https://my-mcp-server.example.com/mcp'
// Trust comes from YOUR configuration (an allowlist of servers that you run
// or checked), never from anything that the server sends.
const trustedServers = new Set(['https://my-mcp-server.example.com/mcp'])
const serverIsTrusted = trustedServers.has(url)
const mcp = await createMCPClient({ transport: { type: 'http', url } })
const tools = (await mcp.tools()).map((tool) => {
const meta = tool.metadata.mcp
const advertisedReadOnly = meta.annotations?.readOnlyHint === true
return {
...tool,
// Approval is the default. A hint can relax it only on a server that
// you trust for other reasons. On any other server, the hint is only
// a label, and it does not change approval.
needsApproval: !(serverIsTrusted && advertisedReadOnly),
}
})Warning
Do not use annotations as a security boundary. Every field, title too, is a hint from the server. A bad server can mark a tool that deletes records with readOnlyHint: true.
Titles are for display only:
McpToolMetadata, McpServerTool, and ToolAnnotations (from the MCP SDK) are exported. Use them to name the types in your own signatures. You do not need them to read the data.
To label tools in your UI, send the data from a server route. The MCP client must stay on the server:
// src/routes/api.mcp-tools.ts
import { createFileRoute } from '@tanstack/react-router'
import { createMCPClient } from '@tanstack/ai-mcp'
export const Route = createFileRoute('/api/mcp-tools')({
server: {
handlers: {
GET: async () => {
await using mcp = await createMCPClient({
transport: { type: 'http', url: process.env.MCP_URL! },
})
const catalog = (await mcp.tools()).map((tool) => ({
name: tool.name,
// `title` is always set. The fallback order already ran.
title: tool.metadata.mcp.title,
description: tool.description,
readOnly: tool.metadata.mcp.annotations?.readOnlyHint === true,
}))
return Response.json({ tools: catalog })
},
},
},
})// src/components/ToolCatalog.tsx
import { useEffect, useState } from 'react'
interface ToolSummary {
name: string
title: string
description?: string
readOnly: boolean
}
export function ToolCatalog() {
const [tools, setTools] = useState<Array<ToolSummary>>([])
useEffect(() => {
fetch('/api/mcp-tools')
.then((res) => res.json())
.then((body: { tools: Array<ToolSummary> }) => setTools(body.tools))
}, [])
return (
<ul>
{tools.map((tool) => (
<li key={tool.name}>
{/* Server-declared title, with the hint driving the badge */}
<strong>{tool.title}</strong> {tool.readOnly ? '(read-only)' : '(writes)'}
<div>{tool.description}</div>
</li>
))}
</ul>
)
}By default, the model gets every tool that the server lists, and each tool runs without approval. A server can expose tools that write or delete data. Two client options control this:
Both options are off by default. A toolFilter function and needsApproval get the tool definition from the server. It has the server name (without a prefix), the title, and the annotations.
Send only the tools that the server marks read-only:
import { createMCPClient } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
toolFilter: (tool) => tool.annotations?.readOnlyHint === true,
})
const tools = await mcp.tools() // only read-only toolsA tool with no annotations does not pass this filter.
On a server that you do not trust, list the tools by name. You control the names, and the server controls the hints:
import { createMCPClient } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
toolFilter: ['search_issues', 'get_issue'],
})
const tools = await mcp.tools() // search_issues, then get_issueA list is strict:
A function does not fail when a tool is missing. If the tool list of the server can change, use a function.
Keep every tool, but ask the user before a tool runs:
import { createMCPClient } from '@tanstack/ai-mcp'
const url = 'https://my-mcp-server.example.com/mcp'
// Trust comes from your configuration, not from the server.
const trustedServers = new Set(['https://my-mcp-server.example.com/mcp'])
const serverIsTrusted = trustedServers.has(url)
const mcp = await createMCPClient({
transport: { type: 'http', url },
// A read-only hint skips approval only on a server that you trust.
needsApproval: (tool) =>
!(serverIsTrusted && tool.annotations?.readOnlyHint === true),
})The run stops with an approval interrupt for each gated call. The client approves or denies it the same way as for any other tool. See Tool Approval Flow.
In a pool, each server gets its own options:
import { createMCPClients } from '@tanstack/ai-mcp'
const pool = await createMCPClients({
github: {
transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },
toolFilter: (tool) => tool.annotations?.readOnlyHint === true,
},
docs: { transport: { type: 'http', url: process.env.DOCS_MCP_URL! } },
})Where the options apply:
| Call | toolFilter | needsApproval |
|---|---|---|
| tools() | Hides tools. | Marks tools. |
| tools([...defs]) | Applies. A definition that the filter hides throws MCPToolNotFoundError. | Does not apply. Each toolDefinition has its own needsApproval. |
| chat({ mcp }) | Applies, because chat() calls tools(). See Managed MCP. | Applies. |
| MCP Apps widget calls | Applies. A widget cannot call a hidden tool. | Applies. A widget call has no approval step, so a call to a marked tool returns { ok: false }. |
| callTool() | Does not apply. Your code calls the tool, not the model. | Does not apply. |
Warning
The hints come from the server. A compromised server can mark a delete tool readOnlyHint: true. For a server that you do not trust, use a name list or needsApproval.
If you need to name the type of the tool argument, use the exported McpTool type.
createMCPClients connects to many servers at the same time and merges their tools into one flat array. Each tool name gets the config key as a prefix, so names from two servers do not collide.
import { createMCPClients } from '@tanstack/ai-mcp'
const pool = await createMCPClients({
github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },
linear: { transport: { type: 'http', url: process.env.LINEAR_MCP_URL! } },
})
// tools: [github_search_repos, github_create_issue, linear_create_issue, ...]
const tools = await pool.tools()pool.tools() collects the tools of all servers. If two names are the same after the prefix, it throws DuplicateToolNameError.
import { createMCPClients } from '@tanstack/ai-mcp'
const pool = await createMCPClients({
github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },
linear: { transport: { type: 'http', url: process.env.LINEAR_MCP_URL! } },
})
const linearTools = await pool.clients.linear!.tools()
const resources = await pool.clients.github!.resources()import { createMCPClients } from '@tanstack/ai-mcp'
const pool = await createMCPClients({
github: {
transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },
prefix: 'gh', // override: "gh_search_repos"
},
internal: {
transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! },
prefix: '', // disable prefix entirely
},
})await pool.close()
// or
await using pool = await createMCPClients({ /* server configs */ })If one server fails to connect, the pool closes the clients that did connect, then throws. No connection stays open.
Tip
If you pass clients to chat() in the mcp option, you can skip this section. chat() closes the connections when the run ends. See Managed MCP with chat().
When you spread tools into tools yourself, you own the client. chat() never closes it.
Tools run while the response stream is read. So close the client only after the stream is fully read.
In a route that returns a streaming Response, a try/finally around the return closes the client too early. The same is true for await using at function scope. The client closes before the body streams, and tool calls in progress fail. Close it in a middleware hook instead.
After the agent loop ends, exactly one of onFinish, onAbort, or onError runs:
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
export async function POST(request: Request) {
const { messages, threadId } = await request.json()
const url = 'https://my-mcp-server.example.com/mcp'
const mcp = await createMCPClient({ transport: { type: 'http', url } })
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
tools: await mcp.tools(),
middleware: [
{
name: 'mcp-close',
onFinish: () => mcp.close(),
onAbort: () => mcp.close(),
onError: () => mcp.close(),
},
],
})
return toServerSentEventsResponse(stream)
}try/finally is correct when you read the full stream before the scope ends:
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 url = 'https://my-mcp-server.example.com/mcp'
const mcp = await createMCPClient({ transport: { type: 'http', url } })
try {
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId: 'thread-1',
tools: await mcp.tools(),
})
for await (const chunk of stream) {
// handle chunks: the stream is fully read inside this block
}
} finally {
await mcp.close()
}This needs a runtime with Symbol.asyncDispose: Node 18.2+ with TypeScript target: "es2022" and lib: ["esnext"]. The same rule applies: read the stream in scope. The client closes when the block ends:
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 url = 'https://my-mcp-server.example.com/mcp'
await using mcp = await createMCPClient({ transport: { type: 'http', url } })
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId: 'thread-1',
tools: await mcp.tools(),
})
for await (const chunk of stream) {
// handle chunks
}
// mcp.close() is called automatically when the block exitsWhen you mix tools from many sources, two tools with the same name throw DuplicateToolNameError:
import { createMCPClients, DuplicateToolNameError } from '@tanstack/ai-mcp'
const pool = await createMCPClients({
github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },
linear: { transport: { type: 'http', url: process.env.LINEAR_MCP_URL! } },
})
try {
const tools = await pool.tools()
} catch (err) {
if (err instanceof DuplicateToolNameError) {
console.error('Conflicting tool name:', err.toolName)
// Fix: set a unique prefix on one of the clients
}
}To prevent a collision, give each client a unique prefix. createMCPClients does this for you with the config key.
The model gets <prefix>_<tool>, or the server tool name when there is no prefix. Some apps need another pattern. For example:
Pass toolName. It gets the tool definition from the server and returns the name that the model gets. It wins over prefix:
import { createMCPClient } from '@tanstack/ai-mcp'
// Keep only letters, digits, `_`, and `-` in the name.
const clean = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, '_')
const mcp = await createMCPClient({
transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
toolName: (tool) => `mcp__github__${clean(tool.name)}`,
})
const tools = await mcp.tools() // mcp__github__search_issues, ...In a pool, each server gets its own toolName:
import { createMCPClients } from '@tanstack/ai-mcp'
const pool = await createMCPClients({
github: {
transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },
toolName: (tool) => `mcp__github__${tool.name}`,
},
linear: {
transport: { type: 'http', url: process.env.LINEAR_MCP_URL! },
toolName: (tool) => `mcp__linear__${tool.name}`,
},
})
const tools = await pool.tools() // mcp__github__..., mcp__linear__...A server with many tools sends many schemas to the model, and each schema costs tokens. Pass { lazy: true } to send a schema only when the model asks for it:
import { createMCPClient } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
})
const tools = await mcp.tools({ lazy: true })
// All tools are marked lazy: trueIt works on a pool too:
import { createMCPClients } from '@tanstack/ai-mcp'
const pool = await createMCPClients({
github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },
linear: { transport: { type: 'http', url: process.env.LINEAR_MCP_URL! } },
})
const tools = await pool.tools({ lazy: true })See Lazy Tool Discovery for how the model finds lazy tools at runtime.
| Error class | When thrown |
|---|---|
| MCPConnectionError | createMCPClient cannot connect, or you call a method after close(). |
| DuplicateToolNameError | Two tools have the same name in one client or across the pool. |
| MCPToolNotFoundError | A toolDefinition name passed to tools([...defs]) is not on the server, or the toolFilter of the client hides it. |
| MCPToolFilterError | A toolFilter list names a tool that the server does not have, or names a tool twice. In a pool, pool.tools() throws MCPConnectionError with this error as its cause. |
| MCPTaskRequiredToolError | A task-required tool came from tools([...defs]), a toolFilter list, or callTool(), but the server does not declare the tasks capability for tools/call. The call can never run. |
For the MCPDuplicateToolNameError that a chat({ mcp }) run throws when it merges tools from many sources, see Managed MCP with chat().