Skip to content
Back to skills

Ai Mcp

ASecurity

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.

  • 3,166 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 2, 2026
ai-agentstypescriptrustgobashreactvueangularnextjsnodegit

Works with

  • terminal
  • cli
  • api
  • mcp

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 6, 2026

npx -y skills add TanStack/ai --skill ai-mcp --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Ai Mcp?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Ai Mcp
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tanstack-ai-mcp/badge)](https://www.skillsdirectory.com/skills/tanstack-ai-mcp)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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.
Its tools, resources, and prompts are static. It advertises no list-change
capability and rejects `subscriptions/listen` with JSON-RPC `-32601`.

`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`.
`client.instructions` holds the server's instructions from the handshake, or `undefined` when the server sends none.
Put them in the system prompt: `systemPrompts: client.instructions ? [client.instructions] : []`.

### 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()`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.