diff --git a/.changeset/chat-mcp-option.md b/.changeset/chat-mcp-option.md new file mode 100644 index 000000000..be74b8005 --- /dev/null +++ b/.changeset/chat-mcp-option.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': minor +--- + +Add an `mcp` option to `chat()` for managing MCP clients directly: `chat({ mcp: { clients, connection, lazyTools, onDiscoveryError } })` discovers the given MCP clients'/pools' tools at run start, merges them into the run, and (by default, `connection: 'close'`) closes them when the run ends — or keeps them warm with `connection: 'keep-alive'`. Also exports `MCPToolSource`, `ChatMCPOptions`, `MCPConnectionPolicy`, and `MCPDuplicateToolNameError` (the error thrown when tools from separate `mcp.clients` entries collide after merging; catchable with `instanceof`). diff --git a/.changeset/mcp-server-support.md b/.changeset/mcp-server-support.md new file mode 100644 index 000000000..6ddb1a954 --- /dev/null +++ b/.changeset/mcp-server-support.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai-mcp': minor +'@tanstack/ai': minor +--- + +Add `@tanstack/ai-mcp`: a host-side Model Context Protocol client. Discover and run MCP server tools (and read resources/prompts) inside any adapter's `chat()` loop, with three type-safety modes (auto-discovery, hand-written `toolDefinition()` binding, and generated end-to-end types via `npx @tanstack/ai-mcp generate`). Includes `createMCPClients` for connecting to multiple servers with auto-prefixed tool names. Also exposes `abortSignal` on `ToolExecutionContext` so long-running tools (e.g. MCP `callTool`) cancel with the chat run. diff --git a/.changeset/strip-unsupported-openai-formats.md b/.changeset/strip-unsupported-openai-formats.md new file mode 100644 index 000000000..61d9f39df --- /dev/null +++ b/.changeset/strip-unsupported-openai-formats.md @@ -0,0 +1,5 @@ +--- +'@tanstack/openai-base': patch +--- + +Strip JSON Schema `format` values that OpenAI's strict Structured Outputs subset rejects (e.g. `uri`, `uri-reference`, `iri`) from tool and response schemas before sending. Tools whose input schemas declare an unsupported `format` — common with MCP server tools — previously caused the entire request to fail with `400 ... '' is not a valid format`. Supported formats (`date-time`, `time`, `date`, `duration`, `email`, `hostname`, `ipv4`, `ipv6`, `uuid`) are preserved, and the caller's original tool definition is never mutated. diff --git a/docs/config.json b/docs/config.json index f45ce573c..9efb1a5af 100644 --- a/docs/config.json +++ b/docs/config.json @@ -86,6 +86,22 @@ { "label": "Lazy Tool Discovery", "to": "tools/lazy-tool-discovery" + }, + { + "label": "MCP Server Tools", + "to": "tools/mcp" + }, + { + "label": "Managed MCP with chat()", + "to": "tools/mcp-managed" + }, + { + "label": "Manual MCP: Tools, Resources & Prompts", + "to": "tools/mcp-manual" + }, + { + "label": "MCP Type Generation", + "to": "tools/mcp-codegen" } ] }, diff --git a/docs/tools/mcp-codegen.md b/docs/tools/mcp-codegen.md new file mode 100644 index 000000000..ee7039020 --- /dev/null +++ b/docs/tools/mcp-codegen.md @@ -0,0 +1,122 @@ +--- +title: MCP Type Generation +id: mcp-codegen +order: 11 +description: "Generate per-server TypeScript interface types from a live MCP server and wire them into createMCPClient for typed tool names and compile-checked pool keys." +keywords: + - tanstack ai + - mcp + - model context protocol + - codegen + - type safety + - mcp.config.ts + - defineConfig + - createMCPClient + - generated types +--- + +You have a running MCP server and you call its tools through [`createMCPClient`](./mcp), but nothing checks the tool names you reference at compile time. By the end of this guide you'll have generated per-server `interface` types from the live server and wired them into `createMCPClient`, narrowing discovered tool names to the server's literal names and compile-checking pool config keys — with zero runtime overhead. This is [Mode 3](./mcp#mode-3--generated-types-createmcpclientgeneratedserver) of MCP type safety. (Tool *arguments* stay untyped on the discovery path — for Zod-validated, TypeScript-typed arguments, combine with the [`tools([toolDefinition(...)])` overload](./mcp#mode-2--explicit-definitions-clienttoolsdefs).) + +The `generate` CLI introspects a live MCP server and emits TypeScript interface types that you pass as a generic to `createMCPClient` / `createMCPClients`. + +## 1. Create `mcp.config.ts` + +Declare each server you want to generate types for. The `defineConfig` helper gives the config file full type checking and autocomplete. + +```ts +// mcp.config.ts +import { defineConfig } from '@tanstack/ai-mcp' + +export default defineConfig({ + servers: { + github: { + transport: { type: 'http', url: 'https://github-mcp.example.com/mcp' }, + }, + linear: { + transport: { type: 'http', url: 'https://linear-mcp.example.com/mcp' }, + prefix: 'linear', // must match runtime createMCPClient({ prefix }) + }, + }, + outFile: './mcp-types.generated.ts', +}) +``` + +## 2. Run the generator + +```bash +npx @tanstack/ai-mcp generate +``` + +The CLI connects to each declared server, introspects its tools, resources, and prompts, and writes the result to `outFile`. + +## 3. Inspect the output + +The generator emits one interface per server plus a combined pool map: + +```ts +// AUTO-GENERATED by `npx @tanstack/ai-mcp generate`. Do not edit. + +import type { ServerDescriptor } from '@tanstack/ai-mcp' + +export interface GithubServer extends ServerDescriptor { + tools: { + 'search_repositories': { input: { query: string; limit?: number }; output: unknown } + 'create_issue': { input: { repo: string; title: string; body?: string }; output: unknown } + } + resources: {} + prompts: {} + capabilities: { tools: {} } & Record +} + +export interface LinearServer extends ServerDescriptor { + tools: { + 'linear_create_issue': { input: { title: string; teamId: string }; output: unknown } + } + resources: {} + prompts: {} + capabilities: { tools: {} } & Record +} + +export interface MCPServers extends Record { + 'github': GithubServer + 'linear': LinearServer +} +``` + +## 4. Use generated types at runtime + +Pass the generated type as a generic to [`createMCPClient`](./mcp) (single server) or `createMCPClients` (pool). Tool names are narrowed to the literal types declared by the server, so a typo is a compile error. + +**Single server:** + +```ts +import type { GithubServer } from './mcp-types.generated' +import { createMCPClient } from '@tanstack/ai-mcp' + +const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.GITHUB_MCP_URL! }, +}) + +const tools = await mcp.tools() +// Each tool name is narrowed from GithubServer['tools'] +``` + +**Multi-server pool:** + +```ts +import type { MCPServers } from './mcp-types.generated' +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! }, + prefix: 'linear', + }, +}) + +// Config keys are constrained to the declared servers — a typo is a compile error +const tools = await pool.tools() +``` + +Now that tool names and pool keys are compile-checked, hand the generated client to `chat()`. See [Managed MCP with `chat()`](./mcp-managed) to let `chat()` own discovery and lifecycle. diff --git a/docs/tools/mcp-managed.md b/docs/tools/mcp-managed.md new file mode 100644 index 000000000..3db4f8022 --- /dev/null +++ b/docs/tools/mcp-managed.md @@ -0,0 +1,306 @@ +--- +title: Managed MCP with chat() +id: mcp-managed +order: 9 +description: "Hand live MCP clients and pools to chat() via the mcp option and let it own tool discovery and connection lifecycle for you." +keywords: + - tanstack ai + - mcp + - model context protocol + - chat mcp + - mcp clients + - keep-alive + - lazyTools + - onDiscoveryError +--- + +You have one or more live [MCP clients](./mcp) (or pools) and you want the model to use their tools — without writing boilerplate `await client.tools()` calls and `try/finally close()` blocks for every route. By the end of this guide you'll hand those clients to `chat()` via the `mcp` option and let it handle both discovery and lifecycle for you. + +> **Managed (`mcp` prop) vs manual (`tools` spread)** +> +> - Use `mcp: { clients: [...] }` when you want **discovery + lifecycle** managed for you and you are happy with runtime-typed (`unknown`-argument) tools. +> - Use `tools: [...await client.tools([toolDefinition(...)])]` when you need **fully-typed MCP tools** — the defs overload gives you Zod-validated, TypeScript-typed arguments. See [Manual MCP: typed tools, resources & prompts](./mcp-manual) and [Three Modes of Type Safety](./mcp#three-modes-of-type-safety). +> +> Both coexist in the same `chat()` call. Tools from `mcp.clients` are merged with any tools you pass explicitly via `tools`. + +## Hand a client to `chat()` + +The simplest path: create a client, hand it to `chat()`, and let the run clean it up. `connection` defaults to `'close'`, so the client is closed automatically once the run ends — on success, error, or abort. + +```ts +// src/routes/api.chat.ts +import { createFileRoute } from '@tanstack/react-router' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export const Route = createFileRoute('/api/chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const { messages } = await request.json() + + const mcpClient = await createMCPClient({ + transport: { + type: 'http', + url: process.env.MCP_URL!, + headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, + }, + }) + + // chat() discovers mcpClient's tools and closes the connection when done. + // No try/finally needed. + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { + clients: [mcpClient], + // connection: 'close' is the default — shown here for clarity + connection: 'close', + }, + }) + + return toServerSentEventsResponse(stream) + }, + }, + }, +}) +``` + +The examples below show only the part that changes — the client setup and the `chat()` call. They all drop into the same route handler shape as above. + +## Multiple servers and pools + +Pass any mix of `MCPClient` instances and `MCPClients` pools. Their tools are discovered in parallel and merged into one flat tool set. Pools auto-prefix each server's tools with the config key to prevent name collisions. + +```ts +import { createMCPClient, createMCPClients } from '@tanstack/ai-mcp' + +// A pool of two servers — their tools are prefixed "github_" and "linear_" +const githubLinearPool = await createMCPClients({ + github: { + transport: { + type: 'http', + url: process.env.GITHUB_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, + }, + }, + linear: { + transport: { + type: 'http', + url: process.env.LINEAR_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, + }, + }, +}) + +// A standalone client for an internal server +const internalClient = await createMCPClient({ + transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! }, +}) + +// All three servers' tools are merged: github_*, linear_*, plus internal tools +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { + clients: [githubLinearPool, internalClient], + connection: 'close', + }, +}) +``` + +## Keep connections warm + +Creating a new MCP connection on every request adds latency. For production routes with high request rates, create your pool once at module level and pass `connection: 'keep-alive'` so `chat()` never closes it. The pool stays ready for the next request. (Shown as a full route because the placement — module scope vs. handler scope — is the point.) + +**Server route (`src/routes/api.chat.ts`):** + +```ts +import { createFileRoute } from '@tanstack/react-router' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClients } from '@tanstack/ai-mcp' + +// Created once when the module loads. Shared across all requests. +const sharedPool = await createMCPClients({ + github: { + transport: { + type: 'http', + url: process.env.GITHUB_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, + }, + }, + linear: { + transport: { + type: 'http', + url: process.env.LINEAR_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, + }, + }, +}) + +export const Route = createFileRoute('/api/chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const { messages } = await request.json() + + // keep-alive: sharedPool is never closed by chat(); stays warm for next call + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { + clients: [sharedPool], + connection: 'keep-alive', + }, + }) + + return toServerSentEventsResponse(stream) + }, + }, + }, +}) +``` + +**Client component (`src/components/Chat.tsx`):** + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +export function Chat() { + const { messages, sendMessage, status } = useChat(chatOptions) + + return ( +
+
    + {messages.map((m) => ( +
  • + {m.role}: {m.content} +
  • + ))} +
+ +
+ ) +} +``` + +## Lazy tool discovery + +When your MCP server exposes dozens of tools, sending every schema to the model inflates prompt size and cost. Set `lazyTools: true` to defer sending tool schemas until the model explicitly requests them. + +```ts +const mcpClient = await createMCPClient({ + transport: { type: 'http', url: process.env.LARGE_MCP_URL! }, +}) + +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { + clients: [mcpClient], + connection: 'close', + // Tools are registered but schemas are withheld until the model asks + lazyTools: true, + }, +}) +``` + +`lazyTools: true` is forwarded to each source's `tools({ lazy: true })` call. See [Lazy Tool Discovery](./lazy-tool-discovery) for how the model discovers and loads lazy tools at runtime, and [the standalone lazy discovery section](./mcp#lazy-tool-discovery) for using `{ lazy: true }` directly with `client.tools()`. + +## Handling discovery failures + +By default, if any source fails during discovery, `chat()` throws immediately (fail-fast). When `connection: 'close'`, any sources that did connect are cleaned up before the error propagates — no leaked connections. + +**Fail-fast (default):** + +```ts +const mcpClient = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, +}) + +// If discovery fails, chat() throws before the first model call. +// mcpClient is closed automatically (connection: 'close' default). +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { + clients: [mcpClient], + }, +}) +``` + +**Skip a flaky server and proceed:** + +Use `onDiscoveryError` to log the problem and return normally — the failing source is skipped and the run continues with the remaining clients' tools. + +```ts +const primaryClient = await createMCPClient({ + transport: { type: 'http', url: process.env.PRIMARY_MCP_URL! }, +}) + +const optionalClient = await createMCPClient({ + transport: { type: 'http', url: process.env.OPTIONAL_MCP_URL! }, +}) + +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { + clients: [primaryClient, optionalClient], + connection: 'close', + onDiscoveryError(error, source) { + // Log the failure but let the run proceed without this source's tools. + // Throw here (or re-throw `error`) to fail the whole run instead. + console.warn('MCP discovery failed for a source, skipping.', error) + }, + }, +}) +``` + +> Sources passed to `onDiscoveryError` may have already connected before discovery failed. When `connection: 'close'`, they are still closed at the end of the run — even if their tools were skipped. + +## Tool name collisions + +If two sources in `mcp.clients` expose a tool with the same name, the run fails with an `MCPDuplicateToolNameError` (exported from `@tanstack/ai`) after merging the discovered tools. Note that `chat()` runs lazily — discovery happens when the stream is first consumed, so the error surfaces **through the stream** (the SSE response errors), not as a synchronous throw you can `try/catch` at the `chat()` call site. The fix is to prevent the collision up front: assign a `prefix` to one of the clients, or use `createMCPClients` (which auto-prefixes using the config key). + +```ts +// Both servers expose a tool called "search". Without prefixes the run +// would fail with MCPDuplicateToolNameError. The prefix option resolves +// the clash. +const serverA = await createMCPClient({ + transport: { type: 'http', url: process.env.SERVER_A_URL! }, + prefix: 'alpha', // tools become "alpha_search", etc. +}) + +const serverB = await createMCPClient({ + transport: { type: 'http', url: process.env.SERVER_B_URL! }, + prefix: 'beta', // tools become "beta_search", etc. +}) + +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { + clients: [serverA, serverB], + connection: 'close', + }, +}) +``` + +For the standalone `pool.tools()` collision behavior and the general `prefix` strategy, see [Tool Name Collisions](./mcp#tool-name-collisions) and [Disable or override the prefix](./mcp#disable-or-override-the-prefix). + +## Going further + +> **Need fully-typed tools, resources, or prompts in the run?** The `mcp` prop gives you runtime-typed tools and discovery. To spread `toolDefinition`-typed MCP tools, inject MCP resources and prompts, or cancel in-flight MCP calls, see [Manual MCP: typed tools, resources & prompts](./mcp-manual). diff --git a/docs/tools/mcp-manual.md b/docs/tools/mcp-manual.md new file mode 100644 index 000000000..87f5e55af --- /dev/null +++ b/docs/tools/mcp-manual.md @@ -0,0 +1,291 @@ +--- +title: "Manual MCP: typed tools, resources & prompts" +id: mcp-manual +order: 10 +description: "Spread fully-typed MCP tools into chat(), inject MCP resources and prompts as content and messages, and cancel in-flight MCP tool calls." +keywords: + - tanstack ai + - mcp + - model context protocol + - mcp resources + - mcp prompts + - mcpResourceToContentPart + - mcpPromptToMessages + - cancellation + - abortController +--- + +You have a live [MCP client](./mcp) and want to do more than auto-discover tools: spread fully-typed tools into a `chat()` run, inject the server's resources and prompts into the conversation, and cancel in-flight MCP calls when the run aborts. By the end of this guide you'll have wired all of these into a single `chat()` call. + +> **Manual (`tools` spread) vs managed (`mcp` prop)** +> +> This page covers the **manual** path — you call `client.tools()` / `client.resources()` / `client.getPrompt()` yourself and own `close()`. If you only need runtime-typed tools with discovery and lifecycle handled for you, use the `mcp` prop instead — see [Managed MCP with `chat()`](./mcp-managed). Both paths build on the [`createMCPClient` basics](./mcp). + +## Fully-typed tools via the `tools` spread + +Pass `toolDefinition()` instances to `client.tools([...])` to get Zod-validated, TypeScript-typed arguments ([Mode 2](./mcp#mode-2--explicit-definitions-clienttoolsdefs)), then spread the result into `chat()`'s `tools` option. You own the client, so you must close it — but **not before the stream is consumed**: `chat()` executes tools lazily while the response streams, so closing in a `finally` around the `return` would kill in-flight tool calls. Close in a middleware terminal hook instead (exactly one of `onFinish`/`onAbort`/`onError` fires per run). + +```ts +// src/routes/api.chat.ts +import { createFileRoute } from '@tanstack/react-router' +import { chat, toServerSentEventsResponse, toolDefinition } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' +import { z } from 'zod' + +const searchDef = toolDefinition({ + name: 'search', + description: 'Search for items', + inputSchema: z.object({ query: z.string() }), + outputSchema: z.array(z.object({ id: z.string(), title: z.string() })), +}) + +export const Route = createFileRoute('/api/chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const { messages } = await request.json() + + const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + }) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + // Fully-typed MCP tools, merged with any other tools you pass + tools: [...(await mcp.tools([searchDef]))], + // Close after the run ends — tools execute while the response streams. + middleware: [ + { + name: 'mcp-close', + onFinish: () => mcp.close(), + onAbort: () => mcp.close(), + onError: () => mcp.close(), + }, + ], + }) + + return toServerSentEventsResponse(stream) + }, + }, + }, +}) +``` + +## Resources + +MCP resources are context documents (files, database records, web pages) the server exposes. Fetch them and inject them into `chat()` as content parts. + +```ts +import { mcpResourceToContentPart } from '@tanstack/ai-mcp' + +const resources = await mcp.resources() +// resources: Array<{ uri: string; name: string; ... }> + +const readResult = await mcp.readResource(resources[0].uri) +const parts = readResult.contents.map(mcpResourceToContentPart) + +// Inject as part of a user message +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: [ + { + role: 'user', + content: [ + ...parts, + { type: 'text', content: 'Summarize the above document.' }, + ], + }, + ], +}) +``` + +`mcpResourceToContentPart` maps each MCP content block to a `ContentPart`: +- `text` field present → `{ type: 'text', content: text }` +- `blob` field present → `{ type: 'text', content: '[binary resource ]' }` +- otherwise → `{ type: 'text', content: JSON.stringify(content) }` + +### Resource templates + +```ts +const templates = await mcp.resourceTemplates() +// templates: Array +``` + +## Prompts + +MCP prompts are reusable message templates the server exposes. Fetch a prompt, convert it to `ModelMessage[]` with `mcpPromptToMessages`, and spread it into `chat()` to seed the conversation with server-defined context or instructions. + +```ts +import { createFileRoute } from '@tanstack/react-router' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient, mcpPromptToMessages } from '@tanstack/ai-mcp' + +export const Route = createFileRoute('/api/chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const { messages } = await request.json() + + const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + }) + + try { + // List all available prompts on the server + const available = await mcp.prompts() + // available: Array<{ name: string; description?: string; arguments?: ... }> + + // Fetch a specific prompt, optionally passing template arguments + const prompt = await mcp.getPrompt('summarize', { language: 'english' }) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: [ + // Seed the conversation with the server-defined prompt messages + ...mcpPromptToMessages(prompt), + // Then append the user's own messages + ...messages, + ], + }) + + return toServerSentEventsResponse(stream) + } finally { + // Safe here: all MCP calls (prompts/getPrompt) completed before chat() + // started, and no MCP tools are passed to the run. If you also spread + // MCP tools into `tools`, close in a middleware terminal hook instead + // (see "Fully-typed tools via the `tools` spread" above). + await mcp.close() + } + }, + }, + }, +}) +``` + +`mcpPromptToMessages` maps each MCP prompt message to a `ModelMessage`: +- `role === 'assistant'` → `{ role: 'assistant', content: text }` +- any other role → `{ role: 'user', content: text }` +- non-text content → `content` is `JSON.stringify`'d + +`getPrompt(name, args?)` accepts an optional `args` parameter typed as `Record` for filling in template variables declared by the prompt. + +## Cancellation + +When the chat run is cancelled (e.g. the user navigates away or an `AbortController` fires), in-flight MCP `callTool` requests are cancelled automatically. The abort signal from the chat run is threaded through `ToolExecutionContext.abortSignal` into each tool's execute function. + +```ts +const controller = new AbortController() + +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: await mcp.tools(), + abortController: controller, +}) + +// Cancel the run and all in-flight MCP tool calls: +controller.abort() +``` + +## Full Server + Client Example + +Here is a complete TanStack Start API route that connects to two MCP servers and streams the response to the browser. + +**Server route (`src/routes/api.chat.ts`):** + +```ts +import { createFileRoute } from '@tanstack/react-router' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClients } from '@tanstack/ai-mcp' + +export const Route = createFileRoute('/api/chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const body = await request.json() + + if (typeof body !== 'object' || body === null || !Array.isArray(body.messages)) { + return new Response('Bad request', { status: 400 }) + } + + const pool = await createMCPClients({ + github: { + transport: { + type: 'http', + url: process.env.GITHUB_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, + }, + }, + linear: { + transport: { + type: 'http', + url: process.env.LINEAR_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, + }, + }, + }) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: body.messages, + tools: await pool.tools(), + // Close after the run ends — tools execute while the response streams. + middleware: [ + { + name: 'mcp-close', + onFinish: () => pool.close(), + onAbort: () => pool.close(), + onError: () => pool.close(), + }, + ], + }) + + return toServerSentEventsResponse(stream) + }, + }, + }, +}) +``` + +**Client component (`src/components/Chat.tsx`):** + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +export function Chat() { + const { messages, sendMessage, status } = useChat(chatOptions) + + return ( +
+
    + {messages.map((m) => ( +
  • + {m.role}: {m.content} +
  • + ))} +
+ +
+ ) +} +``` + +## Going further + +> **Want `chat()` to discover tools and close clients for you?** If you don't need the manual `tools` spread, resources, or prompts, the `mcp` prop removes the close-middleware boilerplate entirely. See [Managed MCP with `chat()`](./mcp-managed). + +> **Want compile-checked tool names on the discovery path?** Generate per-server interface types from your live servers and pass them as a generic to `createMCPClient` — discovered tool names narrow to the server's literal names, with zero runtime overhead. See [MCP Type Generation](./mcp-codegen). diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md new file mode 100644 index 000000000..d44e879c9 --- /dev/null +++ b/docs/tools/mcp.md @@ -0,0 +1,427 @@ +--- +title: MCP Server Tools +id: mcp +order: 8 +description: "Connect TanStack AI to any Model Context Protocol server with createMCPClient to discover and execute its tools." +keywords: + - tanstack ai + - mcp + - model context protocol + - mcp tools + - mcp client + - server tools + - createMCPClient + - createMCPClients + - type safety +--- + +`@tanstack/ai-mcp` is a host-side [Model Context Protocol](https://modelcontextprotocol.io) client for TanStack AI. It connects your server route to any MCP-compliant server and makes that server's tools, resources, and prompts available inside `chat()`. + +> MCP tool execution is **server-side only**. The `createMCPClient` call lives in a server route (or serverless function) — never in browser code. + +## Installation + +```bash +pnpm add @tanstack/ai-mcp @modelcontextprotocol/sdk +``` + +## Quick Start + +The simplest integration is the managed `mcp` option: hand the client to `chat()` and it discovers the tools and closes the connection when the run ends — no lifecycle code at all. + +```ts +// src/routes/api.chat.ts (TanStack Start) +import { createFileRoute } from '@tanstack/react-router' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export const Route = createFileRoute('/api/chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const { messages } = await request.json() + + const mcp = await createMCPClient({ + transport: { + type: 'http', + url: 'https://my-mcp-server.example.com/mcp', + }, + }) + + // chat() discovers the tools and closes the client when the run ends. + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { clients: [mcp] }, + }) + + return toServerSentEventsResponse(stream) + }, + }, + }, +}) +``` + +> Need fully-typed tool arguments, resources, prompts, or your own lifecycle? Spread tools manually instead — see [Manual MCP: typed tools, resources & prompts](./mcp-manual) and the [Lifecycle](#lifecycle) section below. + +On the client side, consume the stream with `useChat` exactly as you would any other TanStack AI endpoint: + +```tsx +// src/components/Chat.tsx +import { useChat } from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' + +export function Chat() { + const { messages, sendMessage, status } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + }) + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: {m.content} +
+ ))} + +
+ ) +} +``` + +## Transports + +### HTTP (Streamable HTTP) + +The preferred transport for remote servers. Uses the MCP Streamable HTTP protocol. + +```ts +const mcp = await createMCPClient({ + transport: { + type: 'http', + url: 'https://my-mcp-server.example.com/mcp', + headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, + }, +}) +``` + +### SSE (Server-Sent Events) + +For servers that implement the legacy SSE transport. + +```ts +const mcp = await createMCPClient({ + transport: { + type: 'sse', + url: 'https://my-mcp-server.example.com/sse', + headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, + }, +}) +``` + +### stdio (Node.js only) + +For spawning a local MCP process. Because stdio imports Node-native modules, it is isolated behind a subpath import so edge bundles stay clean. + +```ts +import { stdioTransport } from '@tanstack/ai-mcp/stdio' +import { createMCPClient } from '@tanstack/ai-mcp' + +const mcp = await createMCPClient({ + transport: stdioTransport({ + command: 'node', + args: ['./my-mcp-server.js'], + env: { API_KEY: process.env.API_KEY ?? '' }, + }), +}) +``` + +### Custom transport (escape hatch) + +Pass any `Transport` instance directly as the `transport` option. For in-process testing, `InMemoryTransport` is re-exported from `@tanstack/ai-mcp`: + +```ts +import { createMCPClient, InMemoryTransport } from '@tanstack/ai-mcp' + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() +const mcp = await createMCPClient({ transport: clientTransport }) +``` + +For a custom network transport, pass any SDK `Transport`-compatible instance: + +```ts +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +const transport = new StreamableHTTPClientTransport(new URL('https://example.com/mcp')) +const mcp = await createMCPClient({ transport }) +``` + +## Authentication + +### Static tokens (headers) + +For servers that take a pre-provisioned API key or bearer token, pass `headers` on the `http`/`sse` transport config — they are sent with every request: + +```ts +const mcp = await createMCPClient({ + transport: { + type: 'http', + url: 'https://my-mcp-server.example.com/mcp', + headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, + }, +}) +``` + +### OAuth (`authProvider`) + +For servers implementing the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) (OAuth 2.1), pass an `authProvider` on the `http`/`sse` transport config. It accepts any `OAuthClientProvider` from the official SDK (`@modelcontextprotocol/sdk/client/auth.js`); the SDK transport then handles attaching tokens, refreshing them, and retrying on 401 — no extra wiring in TanStack AI. + +```ts +import { createMCPClient } from '@tanstack/ai-mcp' +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' + +// 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. +declare const myOAuthProvider: OAuthClientProvider + +const mcp = await createMCPClient({ + transport: { + type: 'http', + url: 'https://my-mcp-server.example.com/mcp', + authProvider: myOAuthProvider, + }, +}) +``` + +> **Interactive authorization (redirect flows).** Completing an authorization-code +> grant requires calling `finishAuth(code)` on the transport after the user is +> redirected back — and `createMCPClient` constructs the transport internally, +> so it cannot expose it. If you need the interactive flow, build the transport +> yourself and pass it in (the escape hatch above): construct a +> `StreamableHTTPClientTransport` with your `authProvider`, keep a reference, +> call `transport.finishAuth(code)` in your OAuth callback route, then hand the +> transport to `createMCPClient({ transport })`. For typical server-side use — +> a provider backed by pre-provisioned or stored tokens with working refresh — +> the config form shown above is all you need. + +## Three Modes of Type Safety + +### Mode 1 — Auto-discovery (`client.tools()`) + +Call `tools()` with no arguments to discover every tool the server exposes. This requires no extra setup. Tool argument types are `unknown` at compile time; the MCP JSON Schema is used for runtime validation. + +```ts +const tools = await mcp.tools() +// tools: ServerTool[] — args typed unknown at compile time +``` + +> **Task-based tools are excluded.** Tools that declare +> `execution.taskSupport: 'required'` (the experimental MCP tasks feature) +> can only run through the SDK's `tasks/callToolStream` flow, which +> `@tanstack/ai-mcp` does not support yet — plain `callTool` is rejected by +> the server with `-32600`. Discovery skips them so the model is never +> offered a tool that cannot succeed. + +### Mode 2 — Explicit definitions (`client.tools([...defs])`) + +Pass TanStack `toolDefinition()` instances to get full TypeScript types and Zod validation. Only the named tools are returned (allowlist). `MCPToolNotFoundError` is thrown if a name isn't on the server, and `MCPTaskRequiredToolError` if the named tool requires task-based execution (see the Mode 1 note). + +```ts +import { toolDefinition } from '@tanstack/ai' +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 tools = await mcp.tools([searchDef]) +// tools[0].execute is typed: (args: { query: string }) => ... +``` + +### Mode 3 — Generated types (`createMCPClient`) + +Run the CLI against a live server to generate per-server `interface` types, then pass the generated type as a generic — tool names are narrowed to the server's literal names and pool config keys are compile-checked, with zero runtime overhead. (Tool *arguments* stay untyped on the discovery path — combine with Mode 2 for typed args.) + +> See [MCP Type Generation](./mcp-codegen) for the full `mcp.config.ts` setup, the `generate` CLI, and how to wire the generated types into `createMCPClient` and `createMCPClients`. + +## Multi-Server Pool + +`createMCPClients` connects to many servers in parallel and merges their tools into one flat array. Each server's tools are automatically prefixed with the config key to prevent name collisions. + +```ts +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 all servers' tools and throws `DuplicateToolNameError` if any two names collide after prefixing. + +### Per-server access + +```ts +const linearTools = await pool.clients.linear.tools() +const resources = await pool.clients.github.resources() +``` + +### Disable or override the prefix + +```ts +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 + }, +}) +``` + +### Closing the pool + +```ts +await pool.close() +// or +await using pool = await createMCPClients({ ... }) +``` + +If any server fails to connect, already-connected clients are closed before the error is thrown — no leaks. + +## Lifecycle + +> **You can skip this entire section** by passing clients to `chat()` via the `mcp` option (as in the Quick Start) — `chat()` discovers tools and closes the connections when the run ends. See [Managed MCP with `chat()`](./mcp-managed). Read on only if you spread tools manually and own `close()` yourself. + +When you manage the client manually, it is **caller-owned**: `chat()` never closes it. + +Tools execute **lazily while the response stream is consumed**, so only close the client after the stream is fully drained. In a route handler that returns a streaming `Response`, a `try/finally` around the `return` (or `await using` at function scope) closes the client *before* the body streams — in-flight tool calls would fail. Close in a middleware terminal hook instead. + +### Streaming route handlers — close via middleware + +Exactly one of `onFinish`/`onAbort`/`onError` fires per run, after the agent loop ends: + +```ts +const mcp = await createMCPClient({ transport: { type: 'http', url } }) +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: await mcp.tools(), + middleware: [ + { + name: 'mcp-close', + onFinish: () => mcp.close(), + onAbort: () => mcp.close(), + onError: () => mcp.close(), + }, + ], +}) +return toServerSentEventsResponse(stream) +``` + +### Manual close — when you consume the stream in scope + +`try/finally` is correct when the stream is drained before the scope exits: + +```ts +const mcp = await createMCPClient({ transport: { type: 'http', url } }) +try { + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: await mcp.tools(), + }) + for await (const chunk of stream) { + // handle chunks — the stream is fully consumed inside this block + } +} finally { + await mcp.close() +} +``` + +### `await using` (Explicit Resource Management) + +If your runtime supports `Symbol.asyncDispose` (Node 18.2+ with TypeScript `target: "es2022"` + `lib: ["esnext"]`), the same in-scope-consumption rule applies — the client closes when the block exits: + +```ts +await using mcp = await createMCPClient({ transport: { type: 'http', url } }) +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: await mcp.tools(), +}) +for await (const chunk of stream) { + // handle chunks +} +// mcp.close() is called automatically when the block exits +``` + +## Tool Name Collisions + +When mixing tools from multiple sources, duplicate names throw `DuplicateToolNameError`: + +```ts +import { DuplicateToolNameError } from '@tanstack/ai-mcp' + +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 + } +} +``` + +Use a unique `prefix` on each client to avoid collisions — `createMCPClients` does this automatically using the config key. + +## Lazy Tool Discovery + +Pass `{ lazy: true }` to defer sending tool schemas to the LLM until it explicitly asks for them. This reduces token usage when working with tool-heavy servers. + +```ts +const tools = await mcp.tools({ lazy: true }) +// All tools are marked lazy: true +``` + +Works with the pool too: + +```ts +const tools = await pool.tools({ lazy: true }) +``` + +See [Lazy Tool Discovery](./lazy-tool-discovery) for how the LLM discovers lazy tools at runtime. + +## Using MCP with `chat()` + +The Quick Start above hands tools to `chat()` manually via `tools: await mcp.tools()` and closes the client yourself. Two follow-on guides cover richer integrations: + +> **Let `chat()` own discovery and lifecycle.** Pass live clients and pools to `chat()` via the `mcp` option and it discovers tools and closes connections for you — no `try/finally` per route. See [Managed MCP with `chat()`](./mcp-managed). + +> **Resources, prompts, and fully-typed manual tools.** Inject MCP resources and prompts into a `chat()` run, cancel in-flight MCP calls, and spread `toolDefinition`-typed tools. See [Manual MCP: typed tools, resources & prompts](./mcp-manual). + +## Error Reference + +| Error class | When thrown | +|---|---| +| `MCPConnectionError` | `createMCPClient` fails to connect, or a method is called after `close()` | +| `DuplicateToolNameError` | Two tools have the same name within one client or across the pool | +| `MCPToolNotFoundError` | A `toolDefinition` name passed to `tools([...defs])` is not found on the server | +| `MCPTaskRequiredToolError` | A `toolDefinition` passed to `tools([...defs])` names a tool that requires task-based execution (`execution.taskSupport: 'required'`) — such tools are also excluded from `tools()` auto-discovery | + +For the `MCPDuplicateToolNameError` thrown when merging tools from multiple sources inside a `chat({ mcp })` run, see [Managed MCP with `chat()`](./mcp-managed#tool-name-collisions). diff --git a/docs/tools/tools.md b/docs/tools/tools.md index 356a40e95..7dd8bfab1 100644 --- a/docs/tools/tools.md +++ b/docs/tools/tools.md @@ -30,7 +30,7 @@ Tools enable your AI application to: ## Framework Support TanStack AI works with **any** JavaScript framework: -- Next.js, Express, Remix, Fastify, etc. +- TanStack Start, Next.js, Express, Remix, Fastify, etc. - React, Vue, Solid, Svelte, vanilla JS, etc. TanStack AI works with any JavaScript framework. @@ -380,3 +380,4 @@ Once arguments (and approval, if required) are in, the result appears as `part.o - [Client Tools](./client-tools) - Learn about client-side tool execution - [Tool Approval Flow](./tool-approval) - Implement approval workflows - [How Tools Work](./tool-architecture) - Deep dive into the tool architecture +- [MCP Server Tools](./mcp) - Connect to external MCP servers for additional tools diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index ed6769348..49425e213 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -19,6 +19,7 @@ "@tanstack/ai-gemini": "workspace:*", "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-groq": "workspace:*", + "@tanstack/ai-mcp": "workspace:*", "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 7d7b7f333..0e581b321 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -14,6 +14,7 @@ import { Mic, MessageSquare, Music, + Plug, Server, Video, X, @@ -266,6 +267,19 @@ export default function Header() { Server Function Chat + + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + MCP Servers + diff --git a/examples/ts-react-chat/src/lib/mcp-providers.ts b/examples/ts-react-chat/src/lib/mcp-providers.ts new file mode 100644 index 000000000..82086c6e6 --- /dev/null +++ b/examples/ts-react-chat/src/lib/mcp-providers.ts @@ -0,0 +1,70 @@ +import { openaiText } from '@tanstack/ai-openai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { geminiText } from '@tanstack/ai-gemini' +import { groqText } from '@tanstack/ai-groq' +import { openRouterText } from '@tanstack/ai-openrouter' + +/** + * Providers the MCP demo can route a chat through. MCP tool discovery and + * execution are provider-agnostic — the same `mcp: { clients }` config works no + * matter which text adapter runs the agent loop. Switching providers here is how + * you confirm that (e.g. that Anthropic tool-calling drives the MCP servers just + * like OpenAI does). + * + * Each provider needs its own API key in the environment; the LLM key is + * separate from the (keyless) MCP servers. + */ +export const MCP_PROVIDERS = [ + { + value: 'openrouter', + label: 'OpenRouter', + model: 'openai/gpt-5.5', + envKey: 'OPENROUTER_API_KEY', + }, + { + value: 'openai', + label: 'OpenAI', + model: 'gpt-5.5', + envKey: 'OPENAI_API_KEY', + }, + { + value: 'anthropic', + label: 'Anthropic', + model: 'claude-sonnet-4-6', + envKey: 'ANTHROPIC_API_KEY', + }, + { + value: 'gemini', + label: 'Gemini', + model: 'gemini-2.5-flash', + envKey: 'GOOGLE_API_KEY', + }, + { + value: 'groq', + label: 'Groq', + model: 'llama-3.3-70b-versatile', + envKey: 'GROQ_API_KEY', + }, +] as const + +export type McpProvider = (typeof MCP_PROVIDERS)[number]['value'] + +/** + * Resolve a request's `provider` (sent from the client via the chat body / + * AG-UI forwardedProps) to a configured text adapter. Defaults to OpenAI. + */ +export function resolveTextAdapter(provider: unknown) { + switch (provider) { + case 'openrouter': + return openRouterText('openai/gpt-5.5') + case 'anthropic': + return anthropicText('claude-sonnet-4-6') + case 'gemini': + return geminiText('gemini-2.5-flash') + case 'groq': + return groqText('llama-3.3-70b-versatile') + case 'openai': + default: + return openaiText('gpt-5.5') + } +} diff --git a/examples/ts-react-chat/src/lib/mcp-servers.ts b/examples/ts-react-chat/src/lib/mcp-servers.ts new file mode 100644 index 000000000..c53ec6e4c --- /dev/null +++ b/examples/ts-react-chat/src/lib/mcp-servers.ts @@ -0,0 +1,29 @@ +import { stdioTransport } from '@tanstack/ai-mcp/stdio' +import type { Transport } from '@tanstack/ai-mcp' + +/** + * Keyless official MCP reference servers (run via npx -y; no API keys needed). + * Each factory returns a fresh Transport instance — transports are single-use + * and must not be shared across requests or reused after close(). + */ + +/** @modelcontextprotocol/server-everything — demo tools, resources, and prompts. */ +export const everythingTransport = (): Transport => + stdioTransport({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-everything'], + }) + +/** @modelcontextprotocol/server-memory — persistent knowledge-graph memory tool. */ +export const memoryTransport = (): Transport => + stdioTransport({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-memory'], + }) + +/** @modelcontextprotocol/server-sequential-thinking — step-by-step reasoning tool. */ +export const sequentialThinkingTransport = (): Transport => + stdioTransport({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }) diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 467b07418..83da3a117 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ThreadsRouteImport } from './routes/threads' import { Route as ServerFnChatRouteImport } from './routes/server-fn-chat' import { Route as RealtimeRouteImport } from './routes/realtime' +import { Route as McpDemoRouteImport } from './routes/mcp-demo' import { Route as Issue176ToolResultRouteImport } from './routes/issue-176-tool-result' import { Route as ImageToolReproRouteImport } from './routes/image-tool-repro' import { Route as ImageGenRouteImport } from './routes/image-gen' @@ -31,6 +32,10 @@ import { Route as ApiTanchatRouteImport } from './routes/api.tanchat' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' import { Route as ApiStructuredOutputRouteImport } from './routes/api.structured-output' import { Route as ApiStructuredChatRouteImport } from './routes/api.structured-chat' +import { Route as ApiMcpStatusRouteImport } from './routes/api.mcp-status' +import { Route as ApiMcpPoolRouteImport } from './routes/api.mcp-pool' +import { Route as ApiMcpManualRouteImport } from './routes/api.mcp-manual' +import { Route as ApiMcpChatRouteImport } from './routes/api.mcp-chat' import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-repro' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' @@ -55,6 +60,11 @@ const RealtimeRoute = RealtimeRouteImport.update({ path: '/realtime', getParentRoute: () => rootRouteImport, } as any) +const McpDemoRoute = McpDemoRouteImport.update({ + id: '/mcp-demo', + path: '/mcp-demo', + getParentRoute: () => rootRouteImport, +} as any) const Issue176ToolResultRoute = Issue176ToolResultRouteImport.update({ id: '/issue-176-tool-result', path: '/issue-176-tool-result', @@ -153,6 +163,26 @@ const ApiStructuredChatRoute = ApiStructuredChatRouteImport.update({ path: '/api/structured-chat', getParentRoute: () => rootRouteImport, } as any) +const ApiMcpStatusRoute = ApiMcpStatusRouteImport.update({ + id: '/api/mcp-status', + path: '/api/mcp-status', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMcpPoolRoute = ApiMcpPoolRouteImport.update({ + id: '/api/mcp-pool', + path: '/api/mcp-pool', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMcpManualRoute = ApiMcpManualRouteImport.update({ + id: '/api/mcp-manual', + path: '/api/mcp-manual', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMcpChatRoute = ApiMcpChatRouteImport.update({ + id: '/api/mcp-chat', + path: '/api/mcp-chat', + getParentRoute: () => rootRouteImport, +} as any) const ApiImageToolReproRoute = ApiImageToolReproRouteImport.update({ id: '/api/image-tool-repro', path: '/api/image-tool-repro', @@ -200,11 +230,16 @@ export interface FileRoutesByFullPath { '/image-gen': typeof ImageGenRoute '/image-tool-repro': typeof ImageToolReproRoute '/issue-176-tool-result': typeof Issue176ToolResultRoute + '/mcp-demo': typeof McpDemoRoute '/realtime': typeof RealtimeRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute + '/api/mcp-chat': typeof ApiMcpChatRoute + '/api/mcp-manual': typeof ApiMcpManualRoute + '/api/mcp-pool': typeof ApiMcpPoolRoute + '/api/mcp-status': typeof ApiMcpStatusRoute '/api/structured-chat': typeof ApiStructuredChatRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute @@ -232,11 +267,16 @@ export interface FileRoutesByTo { '/image-gen': typeof ImageGenRoute '/image-tool-repro': typeof ImageToolReproRoute '/issue-176-tool-result': typeof Issue176ToolResultRoute + '/mcp-demo': typeof McpDemoRoute '/realtime': typeof RealtimeRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute + '/api/mcp-chat': typeof ApiMcpChatRoute + '/api/mcp-manual': typeof ApiMcpManualRoute + '/api/mcp-pool': typeof ApiMcpPoolRoute + '/api/mcp-status': typeof ApiMcpStatusRoute '/api/structured-chat': typeof ApiStructuredChatRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute @@ -265,11 +305,16 @@ export interface FileRoutesById { '/image-gen': typeof ImageGenRoute '/image-tool-repro': typeof ImageToolReproRoute '/issue-176-tool-result': typeof Issue176ToolResultRoute + '/mcp-demo': typeof McpDemoRoute '/realtime': typeof RealtimeRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute + '/api/mcp-chat': typeof ApiMcpChatRoute + '/api/mcp-manual': typeof ApiMcpManualRoute + '/api/mcp-pool': typeof ApiMcpPoolRoute + '/api/mcp-status': typeof ApiMcpStatusRoute '/api/structured-chat': typeof ApiStructuredChatRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute @@ -299,11 +344,16 @@ export interface FileRouteTypes { | '/image-gen' | '/image-tool-repro' | '/issue-176-tool-result' + | '/mcp-demo' | '/realtime' | '/server-fn-chat' | '/threads' | '/api/image-gen' | '/api/image-tool-repro' + | '/api/mcp-chat' + | '/api/mcp-manual' + | '/api/mcp-pool' + | '/api/mcp-status' | '/api/structured-chat' | '/api/structured-output' | '/api/summarize' @@ -331,11 +381,16 @@ export interface FileRouteTypes { | '/image-gen' | '/image-tool-repro' | '/issue-176-tool-result' + | '/mcp-demo' | '/realtime' | '/server-fn-chat' | '/threads' | '/api/image-gen' | '/api/image-tool-repro' + | '/api/mcp-chat' + | '/api/mcp-manual' + | '/api/mcp-pool' + | '/api/mcp-status' | '/api/structured-chat' | '/api/structured-output' | '/api/summarize' @@ -363,11 +418,16 @@ export interface FileRouteTypes { | '/image-gen' | '/image-tool-repro' | '/issue-176-tool-result' + | '/mcp-demo' | '/realtime' | '/server-fn-chat' | '/threads' | '/api/image-gen' | '/api/image-tool-repro' + | '/api/mcp-chat' + | '/api/mcp-manual' + | '/api/mcp-pool' + | '/api/mcp-status' | '/api/structured-chat' | '/api/structured-output' | '/api/summarize' @@ -396,11 +456,16 @@ export interface RootRouteChildren { ImageGenRoute: typeof ImageGenRoute ImageToolReproRoute: typeof ImageToolReproRoute Issue176ToolResultRoute: typeof Issue176ToolResultRoute + McpDemoRoute: typeof McpDemoRoute RealtimeRoute: typeof RealtimeRoute ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiImageToolReproRoute: typeof ApiImageToolReproRoute + ApiMcpChatRoute: typeof ApiMcpChatRoute + ApiMcpManualRoute: typeof ApiMcpManualRoute + ApiMcpPoolRoute: typeof ApiMcpPoolRoute + ApiMcpStatusRoute: typeof ApiMcpStatusRoute ApiStructuredChatRoute: typeof ApiStructuredChatRoute ApiStructuredOutputRoute: typeof ApiStructuredOutputRoute ApiSummarizeRoute: typeof ApiSummarizeRoute @@ -446,6 +511,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RealtimeRouteImport parentRoute: typeof rootRouteImport } + '/mcp-demo': { + id: '/mcp-demo' + path: '/mcp-demo' + fullPath: '/mcp-demo' + preLoaderRoute: typeof McpDemoRouteImport + parentRoute: typeof rootRouteImport + } '/issue-176-tool-result': { id: '/issue-176-tool-result' path: '/issue-176-tool-result' @@ -579,6 +651,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStructuredChatRouteImport parentRoute: typeof rootRouteImport } + '/api/mcp-status': { + id: '/api/mcp-status' + path: '/api/mcp-status' + fullPath: '/api/mcp-status' + preLoaderRoute: typeof ApiMcpStatusRouteImport + parentRoute: typeof rootRouteImport + } + '/api/mcp-pool': { + id: '/api/mcp-pool' + path: '/api/mcp-pool' + fullPath: '/api/mcp-pool' + preLoaderRoute: typeof ApiMcpPoolRouteImport + parentRoute: typeof rootRouteImport + } + '/api/mcp-manual': { + id: '/api/mcp-manual' + path: '/api/mcp-manual' + fullPath: '/api/mcp-manual' + preLoaderRoute: typeof ApiMcpManualRouteImport + parentRoute: typeof rootRouteImport + } + '/api/mcp-chat': { + id: '/api/mcp-chat' + path: '/api/mcp-chat' + fullPath: '/api/mcp-chat' + preLoaderRoute: typeof ApiMcpChatRouteImport + parentRoute: typeof rootRouteImport + } '/api/image-tool-repro': { id: '/api/image-tool-repro' path: '/api/image-tool-repro' @@ -644,11 +744,16 @@ const rootRouteChildren: RootRouteChildren = { ImageGenRoute: ImageGenRoute, ImageToolReproRoute: ImageToolReproRoute, Issue176ToolResultRoute: Issue176ToolResultRoute, + McpDemoRoute: McpDemoRoute, RealtimeRoute: RealtimeRoute, ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, ApiImageGenRoute: ApiImageGenRoute, ApiImageToolReproRoute: ApiImageToolReproRoute, + ApiMcpChatRoute: ApiMcpChatRoute, + ApiMcpManualRoute: ApiMcpManualRoute, + ApiMcpPoolRoute: ApiMcpPoolRoute, + ApiMcpStatusRoute: ApiMcpStatusRoute, ApiStructuredChatRoute: ApiStructuredChatRoute, ApiStructuredOutputRoute: ApiStructuredOutputRoute, ApiSummarizeRoute: ApiSummarizeRoute, diff --git a/examples/ts-react-chat/src/routes/api.mcp-chat.ts b/examples/ts-react-chat/src/routes/api.mcp-chat.ts new file mode 100644 index 000000000..952378bcd --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.mcp-chat.ts @@ -0,0 +1,117 @@ +/** + * /api/mcp-chat — Managed MCP lifecycle via chat({ mcp }). + * + * Demonstrates the chat({ mcp }) prop pattern with MULTIPLE clients: + * 1. Two MCP clients are created (everything + memory servers, both keyless). + * 2. They are passed to chat() via mcp.clients — chat() handles tool + * discovery and closes both clients when the stream drains (connection: 'close'). + * 3. No manual client.tools() or client.close() calls needed. + * + * Uses @modelcontextprotocol/server-everything and @modelcontextprotocol/server-memory + * (both keyless, via npx). Only OPENAI_API_KEY is required — no MCP-specific keys needed. + */ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { createMCPClient } from '@tanstack/ai-mcp' +import { resolveTextAdapter } from '@/lib/mcp-providers' +import { everythingTransport, memoryTransport } from '@/lib/mcp-servers' + +export const Route = createFileRoute('/api/mcp-chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const requestSignal = request.signal + + if (requestSignal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + try { + // Connect two keyless MCP servers in parallel. + // Prefixes disambiguate tools if both servers expose same-named tools. + // OPENAI_API_KEY is used by the LLM adapter (separate from the + // keyless MCP server transports which need no credentials). + // Settle (don't Promise.all) so that if one server fails to connect, + // the sibling that DID connect is closed before rethrowing — no + // leaked stdio subprocess. (createMCPClients does this for you; + // shown manually here because this route demonstrates individual clients.) + const settled = await Promise.allSettled([ + createMCPClient({ + transport: everythingTransport(), + prefix: 'everything', + }), + createMCPClient({ + transport: memoryTransport(), + prefix: 'memory', + }), + ]) + const rejected = settled.find( + (r): r is PromiseRejectedResult => r.status === 'rejected', + ) + if (rejected) { + await Promise.allSettled( + settled.map((r) => + r.status === 'fulfilled' ? r.value.close() : Promise.resolve(), + ), + ) + throw rejected.reason + } + const clients = settled.flatMap((r) => + r.status === 'fulfilled' ? [r.value] : [], + ) + + // chat() discovers tools from both clients and closes them when the + // stream drains — connection: 'close' (the default; shown explicitly). + // The model is encoded in the adapter; do not pass it separately. + const stream = chat({ + adapter: resolveTextAdapter(params.forwardedProps.provider), + messages: params.messages, + mcp: { + clients, + connection: 'close', + }, + agentLoopStrategy: maxIterations(20), + threadId: params.threadId, + runId: params.runId, + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error: any) { + console.error('[api.mcp-chat] Error:', { + message: error?.message, + name: error?.name, + stack: error?.stack, + }) + if (error.name === 'AbortError' || abortController.signal.aborted) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ error: error.message || 'An error occurred' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.mcp-manual.ts b/examples/ts-react-chat/src/routes/api.mcp-manual.ts new file mode 100644 index 000000000..179671d04 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.mcp-manual.ts @@ -0,0 +1,168 @@ +/** + * /api/mcp-manual — MANUAL MCP client pattern. + * + * Demonstrates the fully-manual use-case: + * 1. The caller creates the MCP client and owns its lifecycle. + * 2. Tools are discovered via client.tools() and spread into chat() explicitly. + * 3. Resources and prompts from the server are fetched and injected into the + * conversation as extra context before the user's messages. + * 4. The MCP client is closed AFTER the SSE stream fully drains (tool calls + * fire mid-stream, so an earlier close would abort them). + * + * Uses @modelcontextprotocol/server-everything (keyless, via npx) as the MCP + * server. Only OPENAI_API_KEY is required — no MCP-specific API keys needed. + */ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { + createMCPClient, + mcpPromptToMessages, + mcpResourceToContentPart, +} from '@tanstack/ai-mcp' +import type { ModelMessage, StreamChunk } from '@tanstack/ai' +import type { MCPClient } from '@tanstack/ai-mcp' +import { resolveTextAdapter } from '@/lib/mcp-providers' +import { everythingTransport } from '@/lib/mcp-servers' + +/** + * Wrap the chat stream so the MCP client is closed AFTER the stream has fully + * drained (or errored). Tool calls fire mid-stream; closing the client earlier + * would abort any in-flight MCP tool call. + */ +async function* closeMcpOnDrain( + stream: AsyncIterable, + mcp: MCPClient, +): AsyncGenerator { + try { + for await (const chunk of stream) { + yield chunk + } + } finally { + await mcp.close() + } +} + +export const Route = createFileRoute('/api/mcp-manual')({ + server: { + handlers: { + POST: async ({ request }) => { + // Capture signal before reading body (it may be aborted after consumption) + const requestSignal = request.signal + + if (requestSignal.aborted) { + return new Response(null, { status: 499 }) // 499 = Client Closed Request + } + + const abortController = new AbortController() + + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + // Held in the outer scope so the catch below can close it on any + // error path that happens before the SSE stream takes ownership. + let client: MCPClient | undefined + try { + // --- MCP: create and connect to the everything server (keyless, stdio) --- + client = await createMCPClient({ + transport: everythingTransport(), + }) + + // Auto-discover all tools from the MCP server. + const tools = await client.tools() + + // --- MCP: resources — inject the first resource as context (if any) --- + const contextMessages: Array = [] + + try { + const resources = await client.resources() + if (resources.length > 0) { + // Read the first resource and convert each content block to a ContentPart. + const readResult = await client.readResource(resources[0].uri) + const parts = readResult.contents.map(mcpResourceToContentPart) + if (parts.length > 0) { + contextMessages.push({ + role: 'user', + content: [ + ...parts, + { + type: 'text', + content: + '[MCP resource context injected from server-everything — use this as background information if relevant]', + }, + ], + }) + } + } + } catch { + // Resources are optional — proceed without them if unavailable. + } + + // --- MCP: prompts — prepend the first available prompt (if any) --- + try { + const availablePrompts = await client.prompts() + if (availablePrompts.length > 0) { + const firstPrompt = availablePrompts[0]! + const promptResult = await client.getPrompt(firstPrompt.name) + const promptMessages = mcpPromptToMessages(promptResult) + // Prepend prompt messages before resource context and user messages. + contextMessages.unshift(...promptMessages) + } + } catch { + // Prompts are optional — proceed without them if unavailable. + } + + // OPENAI_API_KEY is used by the LLM adapter (separate from the + // keyless MCP server transport which needs no credentials). + // The model is encoded in the adapter; do not pass it separately. + const stream = chat({ + adapter: resolveTextAdapter(params.forwardedProps.provider), + messages: [...contextMessages, ...params.messages], + tools, + agentLoopStrategy: maxIterations(20), + threadId: params.threadId, + runId: params.runId, + abortController, + }) + + // Close the MCP client only after the SSE stream fully drains. + return toServerSentEventsResponse(closeMcpOnDrain(stream, client), { + abortController, + }) + } catch (error: any) { + // The stream never took ownership of the client — close it here so + // the stdio MCP process isn't leaked. + if (client) { + await client.close().catch(() => undefined) + } + console.error('[api.mcp-manual] Error:', { + message: error?.message, + name: error?.name, + stack: error?.stack, + }) + if (error.name === 'AbortError' || abortController.signal.aborted) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ error: error.message || 'An error occurred' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.mcp-pool.ts b/examples/ts-react-chat/src/routes/api.mcp-pool.ts new file mode 100644 index 000000000..4b847b89f --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.mcp-pool.ts @@ -0,0 +1,100 @@ +/** + * /api/mcp-pool — createMCPClients pool pattern. + * + * Demonstrates the createMCPClients() pool API with THREE servers: + * 1. A pool of three keyless MCP servers is created in one call. + * 2. createMCPClients() auto-prefixes each server's tools with its config key + * (everything_*, memory_*, thinking_*) to prevent name collisions. + * 3. The pool is passed to chat() via mcp.clients — chat() owns discovery + * and closes all three connections when the stream drains (connection: 'close'). + * + * Uses @modelcontextprotocol/server-everything, @modelcontextprotocol/server-memory, + * and @modelcontextprotocol/server-sequential-thinking (all keyless, via npx). + * Only OPENAI_API_KEY is required — no MCP-specific API keys needed. + */ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { resolveTextAdapter } from '@/lib/mcp-providers' +import { createMCPClients } from '@tanstack/ai-mcp' +import { + everythingTransport, + memoryTransport, + sequentialThinkingTransport, +} from '@/lib/mcp-servers' + +export const Route = createFileRoute('/api/mcp-pool')({ + server: { + handlers: { + POST: async ({ request }) => { + const requestSignal = request.signal + + if (requestSignal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + try { + // createMCPClients connects all three servers in parallel and + // auto-prefixes tools with the config key (everything_*, memory_*, + // thinking_*) to prevent collisions. + // OPENAI_API_KEY is used by the LLM adapter (separate from the + // keyless MCP server transports which need no credentials). + const pool = await createMCPClients({ + everything: { transport: everythingTransport() }, + memory: { transport: memoryTransport() }, + thinking: { transport: sequentialThinkingTransport() }, + }) + + // chat() manages discovery and closes all pool connections on drain. + // The model is encoded in the adapter; do not pass it separately. + const stream = chat({ + adapter: resolveTextAdapter(params.forwardedProps.provider), + messages: params.messages, + mcp: { + clients: [pool], + connection: 'close', + }, + agentLoopStrategy: maxIterations(20), + threadId: params.threadId, + runId: params.runId, + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error: any) { + console.error('[api.mcp-pool] Error:', { + message: error?.message, + name: error?.name, + stack: error?.stack, + }) + if (error.name === 'AbortError' || abortController.signal.aborted) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ error: error.message || 'An error occurred' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.mcp-status.ts b/examples/ts-react-chat/src/routes/api.mcp-status.ts new file mode 100644 index 000000000..5af82a2e5 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.mcp-status.ts @@ -0,0 +1,83 @@ +/** + * /api/mcp-status — connectivity + capability probe for the demo's MCP servers. + * + * Hit `GET /api/mcp-status` (optionally `?server=everything`) to get a JSON + * snapshot of each keyless reference server: whether it connects, and the + * tools / resources / prompts it exposes. Handy for validating MCP wiring from + * the client without driving a full chat turn. + * + * Each server is probed with a fresh single-use client that is closed before + * responding — this endpoint never keeps connections warm. + */ +import { createFileRoute } from '@tanstack/react-router' +import { createMCPClient } from '@tanstack/ai-mcp' +import type { Transport } from '@tanstack/ai-mcp' +import { + everythingTransport, + memoryTransport, + sequentialThinkingTransport, +} from '@/lib/mcp-servers' + +const SERVERS: Array<{ name: string; transport: () => Transport }> = [ + { name: 'everything', transport: everythingTransport }, + { name: 'memory', transport: memoryTransport }, + { name: 'thinking', transport: sequentialThinkingTransport }, +] + +interface ServerStatus { + name: string + connected: boolean + tools: Array + resources: Array + prompts: Array + error?: string +} + +async function probe( + name: string, + makeTransport: () => Transport, +): Promise { + try { + const client = await createMCPClient({ transport: makeTransport() }) + try { + const tools = (await client.tools()).map((t) => t.name) + // Check the advertised capabilities instead of catch-all-ing the list + // calls: a server without the capability reports "none", while a real + // transport/protocol failure propagates to the outer catch and is + // surfaced as a probe error (not silently collapsed to []). + const resources = client.capabilities.resources + ? (await client.resources()).map((x) => x.uri) + : [] + const prompts = client.capabilities.prompts + ? (await client.prompts()).map((x) => x.name) + : [] + return { name, connected: true, tools, resources, prompts } + } finally { + await client.close() + } + } catch (error) { + return { + name, + connected: false, + tools: [], + resources: [], + prompts: [], + error: error instanceof Error ? error.message : String(error), + } + } +} + +export const Route = createFileRoute('/api/mcp-status')({ + server: { + handlers: { + GET: async ({ request }) => { + const only = new URL(request.url).searchParams.get('server') + const targets = only ? SERVERS.filter((s) => s.name === only) : SERVERS + const servers = await Promise.all( + targets.map((s) => probe(s.name, s.transport)), + ) + return Response.json({ servers }) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/mcp-demo.tsx b/examples/ts-react-chat/src/routes/mcp-demo.tsx new file mode 100644 index 000000000..2bc99b5c6 --- /dev/null +++ b/examples/ts-react-chat/src/routes/mcp-demo.tsx @@ -0,0 +1,409 @@ +import { useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { Check, Loader2, Send, Square, Wrench } from 'lucide-react' +import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import rehypeHighlight from 'rehype-highlight' +import remarkGfm from 'remark-gfm' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { ThinkingPart } from '@tanstack/ai-react-ui' +import type { UIMessage } from '@tanstack/ai-react' +import { MCP_PROVIDERS, type McpProvider } from '@/lib/mcp-providers' + +type McpMode = 'manual' | 'chat' | 'pool' + +const MODES: Array<{ + value: McpMode + label: string + endpoint: string + description: string +}> = [ + { + value: 'manual', + label: 'Manual', + endpoint: '/api/mcp-manual', + description: + 'Manually spread tools + inject resources/prompts as context before user messages.', + }, + { + value: 'chat', + label: 'chat({ mcp })', + endpoint: '/api/mcp-chat', + description: + 'Pass MCP clients directly to chat(); it handles tool discovery and lifecycle.', + }, + { + value: 'pool', + label: 'Pool', + endpoint: '/api/mcp-pool', + description: + 'createMCPClients() spins up a 3-server pool with auto-prefixed tool names.', + }, +] + +type ToolCallPart = Extract +type ToolResultPart = Extract< + UIMessage['parts'][number], + { type: 'tool-result' } +> + +function formatValue(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +/** Renders an MCP (or any) tool call: name, arguments, live state, and result. */ +function ToolCallView({ part }: { part: ToolCallPart }) { + let args: unknown = part.input + if (args === undefined && part.arguments) { + try { + args = JSON.parse(part.arguments) + } catch { + args = part.arguments + } + } + const done = part.state === 'complete' || part.output !== undefined + + return ( +
+
+ + {part.name} + + {done ? ( + <> + done + + ) : ( + <> + {part.state} + + )} + +
+
+
+

+ Arguments +

+
+            {formatValue(args ?? {})}
+          
+
+ {part.output !== undefined && ( +
+

+ Result +

+
+              {formatValue(part.output)}
+            
+
+ )} +
+
+ ) +} + +/** Renders a standalone tool-result part (when emitted separately from the call). */ +function ToolResultView({ part }: { part: ToolResultPart }) { + return ( +
+
+ + Tool result + {part.state === 'error' && ( + error + )} +
+
+        {part.error ?? formatValue(part.content)}
+      
+
+ ) +} + +function Messages({ messages }: { messages: Array }) { + const messagesContainerRef = useRef(null) + + const visibleMessages = messages.filter((message) => + message.parts.some( + (part) => + (part.type === 'text' && part.content.trim()) || + part.type === 'thinking' || + part.type === 'tool-call' || + part.type === 'tool-result', + ), + ) + + if (!visibleMessages.length) { + return ( +
+
+

+ Select a mode above and send a message to try it out. +

+
+
+ ) + } + + return ( +
+ {visibleMessages.map((message) => ( +
+
+ {message.role === 'assistant' ? ( +
+ AI +
+ ) : ( +
+ U +
+ )} +
+ {message.parts.map((part, index) => { + if (part.type === 'thinking') { + const isComplete = message.parts + .slice(index + 1) + .some((p) => p.type === 'text') + return ( +
+ +
+ ) + } + + if (part.type === 'text' && part.content) { + return ( +
+ + {part.content} + +
+ ) + } + + if (part.type === 'tool-call') { + return + } + + if (part.type === 'tool-result') { + return + } + + return null + })} +
+
+
+ ))} +
+ ) +} + +function ChatSurface({ + endpoint, + threadId, + provider, +}: { + endpoint: string + threadId: string + provider: McpProvider +}) { + const { messages, sendMessage, isLoading, error, stop } = useChat({ + // A stable threadId is sent to the server (AG-UI `RunAgentInput.threadId`) + // and used to group this conversation's runs in the TanStack AI devtools. + threadId, + connection: fetchServerSentEvents(endpoint), + // `provider` is forwarded to the route (AG-UI forwardedProps); the route + // resolves it to the matching text adapter. MCP works the same regardless. + body: { provider }, + }) + + const [input, setInput] = useState('') + + const handleSend = () => { + if (!input.trim()) return + sendMessage(input.trim()) + setInput('') + } + + return ( +
+ + + {error && ( +
+ {error.message} +
+ )} + +
+
+ {isLoading && ( +
+ +
+ )} +
+
+