UNPKG

@tanstack/ai-mcp

Version:

Host-side Model Context Protocol client for TanStack AI: discover and run MCP server tools, resources, and prompts in any adapter's chat() loop, with generated end-to-end types.

1,283 lines (1,037 loc) • 45.7 kB
--- name: ai-mcp description: > Host-side Model Context Protocol (MCP) for TanStack AI: connect to external MCP servers, host your own tools with createMCPServer, discover and run 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.2.5' 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:packages/ai-mcp/src/server/create-server.ts' - 'TanStack/ai:packages/ai-mcp/src/server/stdio.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. If you host the tools yourself, use `createMCPServer`. ## 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. - You want to expose your own tools as an MCP server over HTTP or stdio. Do NOT use this package for browser/client-side code — MCP connections are server-side only. ## Install ```bash pnpm add @tanstack/ai-mcp ``` The package has these subpaths: - `.` exports `createMCPClient`, `createMCPClients`, converters, and types. - `./stdio` exports the Node-only client transport `stdioTransport`. - `./server` exports `createMCPServer`. - `./server/stdio` exports `serveMCPStdio`. - `./apps` exports `createMcpAppCallHandler`. Import `./stdio` and `./server/stdio` only from Node code. Those entries use Node I/O. ## Host an MCP server Import `createMCPServer` from `@tanstack/ai-mcp/server`. Pass tools from `toolDefinition().server()`. Call `server.fetch(request)` in your HTTP route. ```typescript import { toolDefinition } from '@tanstack/ai' import { createMCPServer } from '@tanstack/ai-mcp/server' import { z } from 'zod' const getWeather = toolDefinition({ name: 'get_weather', description: 'Current weather for a city', inputSchema: z.object({ city: z.string() }), }).server(async ({ city }) => { return { city, temperature: 18, conditions: 'clear' } }) const server = createMCPServer({ name: 'weather', version: '1.0.0', tools: [getWeather], }) export function handleMcp(request: Request) { return server.fetch(request) } // Mount handleMcp on GET, POST, and DELETE. // GET is the spec 2025 stream. // DELETE closes a spec 2025 session. ``` `createMCPServer` speaks spec `2026-07-28`. `createMCPServer` also speaks spec 2025. By default it keeps no spec 2025 session. `stdioTransport` from `@tanstack/ai-mcp/stdio` connects your client to a command. `serveMCPStdio` from `@tanstack/ai-mcp/server/stdio` serves your server on stdin and stdout. Write logs with `console.error`. stdout carries only protocol messages. ```typescript import { toolDefinition } from '@tanstack/ai' import { createMCPServer } from '@tanstack/ai-mcp/server' import { serveMCPStdio } from '@tanstack/ai-mcp/server/stdio' import { z } from 'zod' const getWeather = toolDefinition({ name: 'get_weather', description: 'Current weather for a city', inputSchema: z.object({ city: z.string() }), }).server(async ({ city }) => { return { city, temperature: 18, conditions: 'clear' } }) const server = createMCPServer({ name: 'weather', version: '1.0.0', tools: [getWeather], }) serveMCPStdio(server) ``` You can also pass `resources` and `prompts`. Build them with `resourceDefinition` and `promptDefinition` from `@tanstack/ai-mcp/server`. A resource `read(uri, variables, ctx)` gets the requested URI, the template variables, and `ctx.context` (the `handle` context plus `authInfo`). A template resource can take `list(ctx)`, which returns `{ resources }` for `resources/list`. A tool reads its hooks on `ctx.context`. Give `.server()` the type `MCPToolContext` from `@tanstack/ai-mcp/server`. Then `ctx.context.requestInput` and `ctx.context.sample` type-check. On spec 2026, `ctx.context.requestInput` throws, and the handler returns `input_required`. The client runs the tool again with the answer. Code before `requestInput` runs on each call, so it can run more than once. Put work that must run once after `requestInput` returns. On spec 2026, a tool asks one question per call. A second `requestInput` throws an Error. If the user declines or cancels, `requestInput` throws an Error, and the call ends with a tool error. In an `execution: 'task'` tool, `ctx.context.requestInput` throws an error. On spec 2025 with `sessions: 'memory'`, `requestInput` waits on the open session. Without a session, it throws. The same tool call then continues. ```typescript import { toolDefinition } from '@tanstack/ai' import { createMCPServer } from '@tanstack/ai-mcp/server' import type { MCPToolContext } from '@tanstack/ai-mcp/server' import { z } from 'zod' const askCity = toolDefinition({ name: 'ask_city', description: 'Ask which city to use', inputSchema: z.object({}), }).server<MCPToolContext>(async (_args, ctx) => { const city = await ctx.context.requestInput({ message: 'Which city?' }) return { city } }) const server = createMCPServer({ name: 'weather', version: '1.0.0', tools: [askCity], }) export function handleMcp(request: Request) { return server.fetch(request) } ``` Mount `handleMcp` on GET, POST, and DELETE. If a tool calls `ctx.context.sample` on spec 2026, pass `sample` to `createMCPServer`. On spec 2026, `ctx.context.sample` calls the `sample` function. On spec 2025, `ctx.context.sample` asks the MCP client. A tool with `execution: 'task'` returns a spec 2025 task handle. Spec 2026 has no tasks, so that tool runs inline there. ### Require a bearer token The server is an OAuth resource server. Your authorization server issues the token. Pass `auth` with a `verifier`. It is the `OAuthTokenVerifier` type from the MCP SDK. `jwtVerifier` checks a JWT against the JWKS of the provider. `introspectionVerifier` checks an opaque token at an RFC 7662 endpoint. A missing or bad token returns 401. A token without a scope in `requiredScopes` returns 403. A tool reads the token as `ctx.context.authInfo`. Sessions and tasks belong to the `clientId` plus the `sub` claim of the token. Serve the OAuth discovery documents with `oauthMetadataResponse` at the app root. ```typescript import { createMCPServer, jwtVerifier } from '@tanstack/ai-mcp/server' const server = createMCPServer({ name: 'notes', version: '1.0.0', auth: { verifier: jwtVerifier({ jwksUrl: 'https://auth.example.com/.well-known/jwks.json', issuer: 'https://auth.example.com/', audience: 'https://mcp.example.com/mcp', }), requiredScopes: ['notes:read'], }, }) ``` ### Use the auth the app already has When a middleware already verified the caller, pass the result to `server.handle`. `server.fetch(request)` stays a plain Fetch handler. `server.handle` takes options. `options.authInfo` is the SDK `AuthInfo`. The server skips its `auth` gate for that request. `options.context` reaches every tool call, resource read, and resource list of that request on `ctx.context`. Type the values with `MCPToolContext<{ db: Db }>`. `authInfo`, `requestInput`, and `sample` win over a same-named value in `context`. ```typescript import { server } from './mcp-server' import { verifyCaller } from './auth' export async function handleMcp(request: Request) { const caller = await verifyCaller(request) if (caller instanceof Response) return caller return server.handle(request, { authInfo: caller.authInfo, context: { db: caller.db }, }) } ``` ### Describe a tool to the host Set `metadata.title` and `metadata.annotations` on the tool definition. The host gets them as the MCP tool title and annotations. Use the MCP names: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. A host skips its confirmation for a tool with `readOnlyHint: true`. Set `metadata._meta` to send the MCP tool `_meta`, for example `{ ui: { resourceUri: 'ui://view' } }` for an MCP Apps view. Pass `onerror` to `createMCPServer` to log transport and protocol errors from the SDK. `serveMCPStdio` also sends its transport errors there. A tool with no `outputSchema` can return an MCP `CallToolResult`. The server sends it as is: its content blocks, its `structuredContent`, and its `isError`. ### Spec 2025 on a host with many instances The default is `sessions: 'stateless'`. It works on a host with many instances, such as Cloudflare Workers. A new server answers each spec 2025 request, and no session is kept. In that mode, `ctx.context.requestInput` throws for a spec 2025 client. `ctx.context.sample` calls the `sample` option, or throws when it is not set. Set `sessions: 'reject'` to serve spec 2026 only. A spec 2025 request then gets the SDK rejection. Set `sessions: 'memory'` to keep spec 2025 sessions in the process for 30 idle minutes. Route them with sticky sessions on the `mcp-session-id` header. `serveMCPStdio` uses `'memory'` when `sessions` is not set. ### Call a `createMCPServer` server with its types For a deployed server, pass `typeof server` and a transport. Import the server with `import type`, so its code stays out of the app. The client speaks MCP, so the server `auth` option runs. `callTool` accepts only the server tool names and their input types. `callTool` returns the raw MCP result. For a tool with an `outputSchema`, `structuredContent` has the tool output type. `getPrompt` accepts only the server prompt names and their argument types. `readResource` accepts only the server resource URIs. ```typescript import { createMCPClient } from '@tanstack/ai-mcp' import type { server } from './mcp-server' const remote = await createMCPClient<typeof server>({ transport: { type: 'http', url: 'https://mcp.example.com/api/mcp' }, }) await remote.callTool('get_weather', { city: 'Paris' }) ``` `createMCPClient({ server })` is a different client. It calls the tool functions in the same process and returns the tool output, parsed with the `outputSchema`. `readResource(uri, context)` puts `context` on the resource `ctx.context`. Without it, `ctx.context` is `{}`. It opens no connection, and the server `auth` option does not run. It has no `tools()`, so do not pass it to `chat()`. Use it only when the app and the server run in one process. ## `createMCPClient` — single server ```typescript import { createMCPClient } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, prefix: 'weather', // optional: prefixes all tool names (e.g. 'weather_get_forecast') name: 'my-app', // optional: client identity sent to the server }) ``` `createMCPClient` connects immediately and returns an `MCPClient`. If the connection fails, `createMCPClient` throws `MCPConnectionError`. `createMCPClient` tries spec `2026-07-28` first. If the server does not support that spec, the client uses the 2025 initialize handshake. The client keeps negotiation mode `auto`. ### Transports #### Streamable HTTP (default for internet-facing servers) ```typescript import { createMCPClient } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp', headers: { Authorization: 'Bearer sk-...' }, }, }) ``` #### SSE ```typescript import { createMCPClient } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'sse', url: 'https://mcp.example.com/sse', headers: { Authorization: 'Bearer sk-...' }, }, }) ``` #### stdio (Node-only — import from `/stdio` subpath) ```typescript 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 `Transport` from `@modelcontextprotocol/client`: ```typescript // InMemoryTransport comes from @modelcontextprotocol/client. // @tanstack/ai-mcp re-exports it. Any Transport from that package works here. import { createMCPClient, InMemoryTransport } from '@tanstack/ai-mcp' 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` or `sse` config. The value is an `OAuthClientProvider` from `@modelcontextprotocol/client`. The transport attaches tokens, refreshes them, and retries on 401. ```typescript import { createMCPClient } from '@tanstack/ai-mcp' // An OAuthClientProvider from @modelcontextprotocol/client. // You persist the tokens on the server. import { myOAuthProvider } from './oauth-provider' 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. Import `StreamableHTTPClientTransport` from `@modelcontextprotocol/client`. ## 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. ```typescript import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) const tools = await client.tools() // tools: McpServerTool[] (args unknown) const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [{ role: 'user', content: 'What is the weather in Paris?' }], tools, }) ``` Use `{ lazy: true }` to defer schema sending via the existing `LazyToolManager`: ```typescript import { createMCPClient } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) 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' `name`s). Throws `MCPToolNotFoundError` if the server does not expose a tool with that name. ```typescript 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' }, }) // Returns MappedServerTools<typeof defs> — fully typed per definition. 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. ## Tool policy: `toolFilter` and `needsApproval` By default every server tool reaches the model and runs without approval. Set a policy on the client. It applies in `tools()`, in `chat({ mcp })`, and per server in `createMCPClients`. Both callbacks receive the raw MCP tool definition (native unprefixed `name`, `title`, `annotations`). ```typescript import { createMCPClient } from '@tanstack/ai-mcp' const mcp = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, // Hide tools from the model. Unannotated tools fail this check. toolFilter: (tool) => tool.annotations?.readOnlyHint === true, // Pause for approval before these tools run (auto-discovery only). needsApproval: (tool) => tool.annotations?.destructiveHint !== false, }) ``` - `toolFilter` also applies to `tools([defs])`: a hidden definition throws `MCPToolNotFoundError`. MCP Apps widget calls also honor it. It does not apply to `callTool()`. - `needsApproval` does not change `tools([defs])`: each `toolDefinition` keeps its own `needsApproval`. - MCP Apps widget calls have no approval step, so the call handler refuses a tool that `needsApproval` marks (`{ ok: false, error: 'Tool needs approval: <name>' }`). - Annotations are server-declared hints. For an untrusted server, filter by `tool.name` instead. ## 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). ```typescript import { chat, toServerSentEventsResponse } from '@tanstack/ai' import type { ModelMessage } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' // Option 1: middleware terminal hooks (streaming route handlers) export async function POST(request: Request) { const { messages } = await request.json() const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) 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) } // Option 2: explicit close after in-scope consumption export async function runToCompletion(messages: Array<ModelMessage>) { const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) try { const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools: await client.tools(), }) for await (const chunk of stream) { // stream fully consumed inside this block } } finally { await client.close() } } // Option 3: await using (TypeScript 5.2+ with Symbol.asyncDispose) — // same rule: consume the stream before the scope exits. export async function runWithUsing(messages: Array<ModelMessage>) { await using client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools: await client.tools(), }) for await (const chunk of stream) { // ... consume the stream in this scope; close() runs at scope exit } } ``` ## `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. ```typescript // ChatMCPOptions shape: // mcp: { // clients: Array<MCPClient | MCPClients>, // connection?: 'close' | 'keep-alive', // default: 'close' // lazyTools?: boolean, // onDiscoveryError?: (error: unknown, source) => void, // } ``` **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:** ```typescript // Any framework route handler that receives a Request works (TanStack Start, // Next.js, Hono, ...). import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' // Created once at module scope; connection: 'keep-alive' below keeps it warm // across requests. const mcpClient = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) export async function POST(request: Request) { const { messages } = await request.json() const stream = chat({ adapter: openaiText('gpt-5.5'), messages, mcp: { clients: [mcpClient], connection: 'keep-alive', // chat() won't close it — reuse across requests onDiscoveryError: (err, source) => { console.warn('MCP discovery failed for source, skipping:', err) // returning skips this source; throw to fail the whole call fast }, }, }) return toServerSentEventsResponse(stream) // connection: 'keep-alive' — chat() never closes mcpClient; it stays warm for the next request. } ``` You can also pass an `MCPClients` pool directly: ```typescript import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClients } from '@tanstack/ai-mcp' const pool = await createMCPClients({ github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } }, linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } }, }) export async function POST(request: Request) { const { messages } = await request.json() const stream = chat({ adapter: openaiText('gpt-5.5'), messages, mcp: { clients: [pool], connection: 'keep-alive' }, }) return toServerSentEventsResponse(stream) } ``` ## MCP input request When `chat()` receives an MCP input request, the run outcome is an interrupt. The stream ends with `RUN_FINISHED`. The outcome type is `interrupt`. The stream does not emit `RUN_ERROR` for this pause. Read each interrupt whose `reason` is `mcp_input`. The payload key is `tanstack:interruptPayload`. `form` means the server asks the user for input. `sampling` means the server asks for a model result. The interrupt id is `mcp_input_` plus the tool call id. ```typescript import { chat, INTERRUPT_PAYLOAD_METADATA_KEY } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) try { const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [{ role: 'user', content: 'What is the weather in Paris?' }], tools: await client.tools(), }) for await (const chunk of stream) { if (chunk.type !== 'RUN_FINISHED') continue if (chunk.outcome?.type !== 'interrupt') continue for (const item of chunk.outcome.interrupts) { if (item.reason !== 'mcp_input') continue const payload = item.metadata?.[INTERRUPT_PAYLOAD_METADATA_KEY] if (typeof payload !== 'object' || payload === null) continue if (!('kind' in payload)) continue // payload.kind is 'form' or 'sampling' // payload.request is the MCP input body } } } finally { await client.close() } ``` The interrupt has a generic binding. In `useChat`, the item `kind` is `generic`. Call `resolveInterrupt(answer)` or `cancel()` on the item. For a `form`, the answer is an object that matches `request.requestedSchema`. A `createMCPServer` server asks for `{ value: string }`. For `sampling`, the answer is the reply text or a full `CreateMessageResult`. The route must pass `parentRunId` and `resume` to `chat()`. The next run calls the tool again, and the tool reads `ctx.inputResponse`. On spec 2026, the MCP client sends that answer with `inputResponses` and the server `requestState`. On spec 2025, the tool call fails, because `chat()` cannot pause that call. ## `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. ```typescript 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' } }, }) // Tool names auto-prefixed: 'github_search_repos', 'linear_create_issue', etc. const tools = await pool.tools() // Forward lazy flag to every server: const lazyTools = await pool.tools({ lazy: true }) // Per-server typed access (keys are typed as string here; generated // MCPServers types make them literal — see Codegen CLI below): 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: ```typescript import { createMCPClients } from '@tanstack/ai-mcp' await using pool = await createMCPClients({ // 'gh_search_repos' github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, prefix: 'gh', }, // 'create_issue' (no prefix) linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' }, prefix: '', }, }) ``` ## Abort signal — cancelling in-flight MCP calls TanStack AI stops waiting for MCP tool calls when the chat run's `AbortController` fires (e.g. client disconnect, server abort). The `abortSignal` is threaded through `ToolExecutionContext` into every tool call with no extra code. For a task-required tool, aborting stops the local task stream and sends a best-effort `tasks/cancel` for a remote task the MCP server has already created. Cancel is best-effort: a server that ignores `tasks/cancel` may keep running until TTL. You can also read it in a hand-written server tool that wraps an MCP call: ```typescript import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' const fetchData = toolDefinition({ name: 'fetch_data', description: 'Fetch a record from a slow upstream API', inputSchema: z.object({ id: z.string() }), }) const myTool = fetchData.server(async (args, ctx) => { // Forward to any async work that accepts an AbortSignal. const result = await fetch(`https://slow.api/data/${args.id}`, { signal: ctx?.abortSignal, }) return result.json() }) ``` ## Resources ```typescript import { createMCPClient, mcpResourceToContentPart } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) // List all resources the server exposes. const resources = await client.resources() // Read a specific resource by URI. const resource = await client.readResource(resources[0]!.uri) // Convert one content block to a TanStack ContentPart. const part = mcpResourceToContentPart(resource.contents[0]!) // part: ContentPart (type: 'text' always for v1) ``` Inject resources into a chat turn: ```typescript import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient, mcpResourceToContentPart } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) 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 ```typescript import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient, mcpPromptToMessages } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) // List prompts the server exposes. const prompts = await client.prompts() // Get a prompt (with optional arguments). const prompt = await client.getPrompt('review_code', { language: 'TypeScript' }) // Convert to TanStack ModelMessage[] for use in chat(). const messages = mcpPromptToMessages(prompt) // messages: ModelMessage[] (role: 'user' | 'assistant') const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...messages, { role: 'user', content: 'Review src/index.ts.' }], }) ``` ## 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. ```typescript import type { UIResourcePart } from '@tanstack/ai' // UIResourcePart shape (on the assistant UIMessage): // { // type: 'ui-resource' // resource: { uri: string; mimeType: string; text?: string; blob?: string } // serverId?: string // pool prefix / config key — routes interactive calls // toolCallId: string // links to the originating tool call // toolName: string // server-native MCP tool name whose UI this renders // meta?: Record<string, unknown> // reserved — currently always undefined // } ``` ### 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). ```typescript group=mcp-app-handler import { createMCPClients } from '@tanstack/ai-mcp' import { createMcpAppCallHandler, inMemoryMcpSessionStore, } from '@tanstack/ai-mcp/apps' // Reuse the same pool you pass to chat({ mcp: { clients: [mcp] } }). const mcp = await createMCPClients({ weather: { transport: { type: 'http', url: 'https://mcp-app.example.com/mcp' }, }, }) // Minimal — reconnect-per-call via getServers() descriptor. const handler = createMcpAppCallHandler({ clients: mcp }) // Options: // clients — MCPClient | MCPClients | Array<MCPClient | MCPClients> (required). // The handler reads transport descriptors via client.getInfo() / // pool.getServers() — the client does not need a live connection. // store — optional dynamic/stateful session store (e.g. // inMemoryMcpSessionStore()); used alongside clients. // allowTool — optional authorizer receiving the WHOLE request: // (req: McpAppCallRequest) => boolean | Promise<boolean>. // The server-exposure check is ALWAYS enforced (the handler // rejects any tool the server does not expose). `allowTool` // is an ADDITIONAL restriction AND-ed on top: a request must // satisfy BOTH the server-exposure check and allowTool. const handlerWithStore = createMcpAppCallHandler({ clients: mcp, store: inMemoryMcpSessionStore(), allowTool: (req) => req.toolName === 'place_order', }) ``` The handler invokes the server (`body: { threadId, serverId?, toolName, args?, messageId? }`): ```typescript group=mcp-app-handler export async function POST(request: Request) { const body = await request.json() const result = await handler(body) // { ok: true; result: unknown } | { ok: false; error: string } return Response.json(result) } ``` ### 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. ```tsx 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 }) }, // Opt in to link navigation — absent means links are dropped. 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:** ```typescript import { defineConfig } from '@tanstack/ai-mcp' export default defineConfig({ servers: { github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, // prefix must match the runtime createMCPClient({ prefix }) value }, }, outFile: './src/mcp-types.generated.ts', }) ``` **2. Run the generator:** ```bash 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:** ```typescript // Single server — narrows tools() return to descriptor-keyed tool names. import type { GithubServer } from './src/mcp-types.generated' import { createMCPClient, createMCPClients } from '@tanstack/ai-mcp' const client = await createMCPClient<GithubServer>({ transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, }) const tools = await client.tools() // typed to GithubServer's tool names // Multiple servers via the generated MCPServers map. import type { MCPServers } from './src/mcp-types.generated' const pool = await createMCPClients<MCPServers>({ github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } }, }) // pool.clients.github is MCPClient<GithubServer> // missing/extra keys are a compile error ``` 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, or the client's `toolFilter` hides it. - `MCPTaskRequiredToolError` — thrown when a task-required tool is bound via `tools([defs])` or called via `callTool()` and the server does not declare the tasks capability for `tools/call`. Auto-discovery skips those tools instead of throwing. - `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. ```typescript import { MCPConnectionError, MCPToolNotFoundError, MCPTaskRequiredToolError, DuplicateToolNameError, } from '@tanstack/ai-mcp' import { MCPDuplicateToolNameError } from '@tanstack/ai' ``` ## Complete server-route example ```typescript // src/routes/api.chat.ts — mount POST in your framework's route handler // (TanStack Start server route, Next.js route handler, Hono, ...). import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClients } from '@tanstack/ai-mcp' export async function POST(request: 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(), // Close after the run ends — tools execute while the response streams, // so `await using` / try-finally would close the pool too early here. 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: ```typescript 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 } = await request.json() const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) const tools = await client.tools() const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools }) await client.close() // closes before the stream runs tools 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: ```typescript 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 } = await request.json() const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) 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: ```typescript ignore import { stdioTransport } from '@tanstack/ai-mcp' // does not exist here ``` Correct: ```typescript 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. ### e. HIGH: importing `@modelcontextprotocol/sdk` Use `@modelcontextprotocol/client` for client transports. Use `@modelcontextprotocol/server` for server helpers. `@tanstack/ai-mcp` re-exports `InMemoryTransport` from the client package. Wrong: ```typescript ignore import { InMemoryTransport } from '@modelcontextprotocol/sdk' ``` Correct: ```typescript import { InMemoryTransport } from '@modelcontextprotocol/client' ``` ## 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()`.