{"slug": "tanstack-ai-your-mcp-your-way", "title": "TanStack AI: Your MCP, your way", "summary": "TanStack released @tanstack/ai-mcp, a host-side Model Context Protocol client that converts any MCP server into standard ServerTool arrays for use with any AI adapter or agent loop. The library, built on the official @modelcontextprotocol/sdk, supports edge-deployable Streamable HTTP transport and offers flexible configuration including multi-server pooling, tool prefixing, and optional codegen for strict typing.", "body_md": "*by Alem Tuzlak on Jun 5, 2026.*\n\nMost \"we support MCP now\" announcements hand you exactly one way to use it. Connect to a server, get some tools, hope the lifecycle works out.\n\n@tanstack/ai-mcp takes the opposite stance. It is a **host-side Model Context Protocol client** that turns any MCP server into ordinary ServerTool[] you spread into chat(). Because the output is just tools, every layer above it stays the same: any adapter (OpenAI, Anthropic, Gemini, Ollama), any agent loop, any framework integration. TanStack AI never knows MCP was involved.\n\nThat single design decision is what lets you use MCP **your way**: one server or fifty, fully managed or hand-wired, untyped-and-fast or generated-and-strict. This post walks the entire surface, every flag and every config, with the exact types from the package.\n\nBuilt on the official @modelcontextprotocol/sdk, the runtime stays edge-deployable. The Streamable HTTP transport is node:-free, the Node-only stdio transport is isolated behind a subpath, and the codegen CLI's heavy dependencies are bundled into the bin only, never into the library you ship.\n\nThere is exactly one idea to internalize:\n\nAn MCP client is a tool factory. You get back ServerTool[]. You spread them into chat({ tools }).\n\n``` js\nimport { chat } from '@tanstack/ai'\nimport { openaiText } from '@tanstack/ai-openai/adapters'\nimport { createMCPClient } from '@tanstack/ai-mcp'\n\nconst mcp = await createMCPClient({\n  transport: { type: 'http', url: process.env.MCP_URL! },\n})\n\nconst stream = chat({\n  adapter: openaiText('gpt-5.5'),\n  messages,\n  tools: await mcp.tools(),\n})\n\nawait mcp.close()\njs\nimport { chat } from '@tanstack/ai'\nimport { openaiText } from '@tanstack/ai-openai/adapters'\nimport { createMCPClient } from '@tanstack/ai-mcp'\n\nconst mcp = await createMCPClient({\n  transport: { type: 'http', url: process.env.MCP_URL! },\n})\n\nconst stream = chat({\n  adapter: openaiText('gpt-5.5'),\n  messages,\n  tools: await mcp.tools(),\n})\n\nawait mcp.close()\n```\n\nEverything else in this post is a variation on that line: how you build the client, how you type the tools, how many servers you fan in, and who owns close().\n\n``` php\nflowchart LR\n  S1[MCP server] -->|callTool| C1[createMCPClient]\n  S2[MCP server] --> P[createMCPClients pool]\n  S3[MCP server] --> P\n  C1 -->|ServerTool array| CHAT[\"chat({ tools }) or chat({ mcp })\"]\n  P -->|prefixed ServerTool array| CHAT\n  CLI[npx @tanstack/ai-mcp generate] -.->|compile-time types| C1\n  CLI -.->|compile-time types| P\n  CHAT --> ADP[any adapter: OpenAI / Anthropic / Gemini / Ollama]\n```\n\nMCP tool execution is\n\nserver-side only. createMCPClient lives in a server route or serverless function, never in browser code.\n\nA single client connects to a single server. The options are small and every field is load-bearing.\n\n```\ninterface MCPClientOptions {\n  transport: TransportInput // config object or a raw SDK Transport\n  prefix?: string // tool-name prefix, e.g. 'github' -> 'github_search'\n  name?: string // client identity sent to the server (default 'tanstack-ai-mcp')\n  version?: string // client version (default '0.0.1')\n}\ninterface MCPClientOptions {\n  transport: TransportInput // config object or a raw SDK Transport\n  prefix?: string // tool-name prefix, e.g. 'github' -> 'github_search'\n  name?: string // client identity sent to the server (default 'tanstack-ai-mcp')\n  version?: string // client version (default '0.0.1')\n}\n```\n\nThe returned MCPClient exposes the full protocol surface:\n\n```\ninterface MCPClient {\n  readonly capabilities: Record<string, unknown> // server capabilities from the handshake\n  tools(options?): Promise<ServerTool[]> // discovery (overloaded, see below)\n  resources(): Promise<Resource[]>\n  readResource(uri: string): Promise<ReadResourceResult>\n  resourceTemplates(): Promise<ResourceTemplate[]>\n  prompts(): Promise<Prompt[]>\n  getPrompt(name, args?): Promise<GetPromptResult>\n  close(): Promise<void>\n  [Symbol.asyncDispose](): Promise<void> // for `await using`\n}\ninterface MCPClient {\n  readonly capabilities: Record<string, unknown> // server capabilities from the handshake\n  tools(options?): Promise<ServerTool[]> // discovery (overloaded, see below)\n  resources(): Promise<Resource[]>\n  readResource(uri: string): Promise<ReadResourceResult>\n  resourceTemplates(): Promise<ResourceTemplate[]>\n  prompts(): Promise<Prompt[]>\n  getPrompt(name, args?): Promise<GetPromptResult>\n  close(): Promise<void>\n  [Symbol.asyncDispose](): Promise<void> // for `await using`\n}\n```\n\nFour ways to connect, one consistent shape.\n\n**HTTP (Streamable HTTP)** is the preferred transport for remote servers and the only one that is fully edge-safe.\n\n``` js\nconst mcp = await createMCPClient({\n  transport: {\n    type: 'http',\n    url: 'https://my-mcp-server.example.com/mcp',\n    headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` },\n    fetch: customFetch, // optional: bring your own fetch\n    authProvider: myOAuth, // optional: OAuth 2.1 (see Authentication)\n  },\n})\njs\nconst mcp = await createMCPClient({\n  transport: {\n    type: 'http',\n    url: 'https://my-mcp-server.example.com/mcp',\n    headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` },\n    fetch: customFetch, // optional: bring your own fetch\n    authProvider: myOAuth, // optional: OAuth 2.1 (see Authentication)\n  },\n})\n```\n\n**SSE** is for servers that still implement the legacy Server-Sent Events transport. Same fields as HTTP (url, headers?, fetch?, authProvider?).\n\n``` js\nconst mcp = await createMCPClient({\n  transport: { type: 'sse', url: 'https://example.com/sse' },\n})\njs\nconst mcp = await createMCPClient({\n  transport: { type: 'sse', url: 'https://example.com/sse' },\n})\n```\n\n**stdio** spawns a local MCP process. Because it imports Node-native modules, it is isolated behind the @tanstack/ai-mcp/stdio subpath so your edge bundles stay clean.\n\n``` js\nimport { stdioTransport } from '@tanstack/ai-mcp/stdio'\nimport { createMCPClient } from '@tanstack/ai-mcp'\n\nconst mcp = await createMCPClient({\n  transport: stdioTransport({\n    command: 'node',\n    args: ['./my-mcp-server.js'],\n    env: { API_KEY: process.env.API_KEY ?? '' },\n    cwd: process.cwd(), // optional\n  }),\n})\njs\nimport { stdioTransport } from '@tanstack/ai-mcp/stdio'\nimport { createMCPClient } from '@tanstack/ai-mcp'\n\nconst mcp = await createMCPClient({\n  transport: stdioTransport({\n    command: 'node',\n    args: ['./my-mcp-server.js'],\n    env: { API_KEY: process.env.API_KEY ?? '' },\n    cwd: process.cwd(), // optional\n  }),\n})\n```\n\nPassing a { type: 'stdio' } config object to createMCPClient directly throws on purpose, with a message pointing you at the subpath. That keeps the Node-only code path out of edge builds unless you opt in.\n\n**Custom transport** is the escape hatch: pass any SDK Transport instance straight through. InMemoryTransport is re-exported for in-process testing.\n\n``` js\nimport { createMCPClient, InMemoryTransport } from '@tanstack/ai-mcp'\n\nconst [clientTransport] = InMemoryTransport.createLinkedPair()\nconst mcp = await createMCPClient({ transport: clientTransport })\njs\nimport { createMCPClient, InMemoryTransport } from '@tanstack/ai-mcp'\n\nconst [clientTransport] = InMemoryTransport.createLinkedPair()\nconst mcp = await createMCPClient({ transport: clientTransport })\n```\n\nThis escape hatch is also how you handle interactive OAuth redirect flows: build a StreamableHTTPClientTransport yourself, keep a reference so you can call transport.finishAuth(code) in your callback route, then hand it to createMCPClient({ transport }).\n\nTwo paths, both passed on the http/sse transport config.\n\n``` python\nimport type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'\n\ndeclare const myOAuthProvider: OAuthClientProvider\n\nconst mcp = await createMCPClient({\n  transport: {\n    type: 'http',\n    url: 'https://my-mcp-server.example.com/mcp',\n    authProvider: myOAuthProvider,\n  },\n})\npython\nimport type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'\n\ndeclare const myOAuthProvider: OAuthClientProvider\n\nconst mcp = await createMCPClient({\n  transport: {\n    type: 'http',\n    url: 'https://my-mcp-server.example.com/mcp',\n    authProvider: myOAuthProvider,\n  },\n})\n```\n\nThis is where \"your way\" gets literal. The same client supports three levels of typing, and you pick per call site.\n\nCall tools() with no arguments to get every tool the server exposes. No setup. Tool arguments are unknown at compile time and validated at runtime against the server's JSON Schema.\n\n``` js\nconst tools = await mcp.tools()\n// tools: ServerTool[] - names known, args typed `unknown`\njs\nconst tools = await mcp.tools()\n// tools: ServerTool[] - names known, args typed `unknown`\n```\n\nTwo behaviors worth knowing:\n\nPass TanStack toolDefinition() instances to get full TypeScript types and Zod validation. This is an **allowlist**: only the named tools come back.\n\n``` js\nimport { toolDefinition } from '@tanstack/ai'\nimport { z } from 'zod'\n\nconst searchDef = toolDefinition({\n  name: 'search',\n  description: 'Search for items',\n  inputSchema: z.object({ query: z.string() }),\n  outputSchema: z.array(z.object({ id: z.string(), title: z.string() })),\n})\n\nconst tools = await mcp.tools([searchDef])\n// tools[0].execute is typed: (args: { query: string }) => ...\njs\nimport { toolDefinition } from '@tanstack/ai'\nimport { z } from 'zod'\n\nconst searchDef = toolDefinition({\n  name: 'search',\n  description: 'Search for items',\n  inputSchema: z.object({ query: z.string() }),\n  outputSchema: z.array(z.object({ id: z.string(), title: z.string() })),\n})\n\nconst tools = await mcp.tools([searchDef])\n// tools[0].execute is typed: (args: { query: string }) => ...\n```\n\nTwo errors guard this path:\n\nThis mode reuses the existing toolDefinition() primitive. There is no parallel schema system to learn, and the per-tuple return type is preserved so each tool keeps its own input/output types.\n\nRun the codegen CLI against a live server to emit per-server interface types, then pass the type as a generic. Tool names narrow to the server's literal names, so a typo becomes a compile error, with zero runtime cost.\n\n``` python\nimport type { GithubServer } from './mcp-types.generated'\n\nconst mcp = await createMCPClient<GithubServer>({\n  transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },\n})\n\nconst tools = await mcp.tools()\n// each tool name is narrowed from GithubServer['tools']\npython\nimport type { GithubServer } from './mcp-types.generated'\n\nconst mcp = await createMCPClient<GithubServer>({\n  transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },\n})\n\nconst tools = await mcp.tools()\n// each tool name is narrowed from GithubServer['tools']\n```\n\nMode 3 types the tool *names*; tool *arguments* stay untyped on the discovery path. Combine it with Mode 2 when you want both narrowed names and typed args. The full CLI workflow is in its own section below.\n\nMCP servers expose more than tools. The client surfaces resources and prompts directly, plus two converters that turn them into shapes chat() understands.\n\n``` js\nconst resources = await mcp.resources()\nconst file = await mcp.readResource('file:///readme.md')\nconst templates = await mcp.resourceTemplates()\n\nconst prompts = await mcp.prompts()\nconst review = await mcp.getPrompt('code-review', { language: 'ts' })\njs\nconst resources = await mcp.resources()\nconst file = await mcp.readResource('file:///readme.md')\nconst templates = await mcp.resourceTemplates()\n\nconst prompts = await mcp.prompts()\nconst review = await mcp.getPrompt('code-review', { language: 'ts' })\n```\n\nTo seed a conversation with that content, use the converters:\n\n``` js\nimport { mcpResourceToContentPart, mcpPromptToMessages } from '@tanstack/ai-mcp'\n\n// fetch a resource and a prompt from the server\nconst file = await mcp.readResource('file:///readme.md')\nconst review = await mcp.getPrompt('code-review', { language: 'ts' })\n\n// resource content block -> ContentPart (text, with a sensible fallback for blobs)\nconst readmePart = mcpResourceToContentPart(file.contents[0])\n\n// MCP prompt -> ModelMessage[] you can prepend to chat({ messages })\nconst seeded = mcpPromptToMessages(review)\n\nconst stream = chat({\n  adapter: openaiText('gpt-5.5'),\n  messages: [{ role: 'user', content: [readmePart] }, ...seeded, ...messages],\n  tools: await mcp.tools(),\n})\njs\nimport { mcpResourceToContentPart, mcpPromptToMessages } from '@tanstack/ai-mcp'\n\n// fetch a resource and a prompt from the server\nconst file = await mcp.readResource('file:///readme.md')\nconst review = await mcp.getPrompt('code-review', { language: 'ts' })\n\n// resource content block -> ContentPart (text, with a sensible fallback for blobs)\nconst readmePart = mcpResourceToContentPart(file.contents[0])\n\n// MCP prompt -> ModelMessage[] you can prepend to chat({ messages })\nconst seeded = mcpPromptToMessages(review)\n\nconst stream = chat({\n  adapter: openaiText('gpt-5.5'),\n  messages: [{ role: 'user', content: [readmePart] }, ...seeded, ...messages],\n  tools: await mcp.tools(),\n})\n```\n\nmcpResourceToContentPart maps a text field to a text part, a blob field to a [binary resource <uri>] placeholder, and anything else to stringified JSON. mcpPromptToMessages normalizes each message to a user/assistant role with text content.\n\nA standalone client is **caller-owned**. chat() never closes a client you spread manually, which is what makes warm reuse across requests possible.\n\n``` js\n// caller owns close()\nconst mcp = await createMCPClient({ transport })\ntry {\n  const stream = chat({\n    adapter: openaiText('gpt-5.5'),\n    messages,\n    tools: await mcp.tools(),\n  })\n  return toServerSentEventsResponse(stream)\n} finally {\n  // careful: see the streaming note below\n}\njs\n// caller owns close()\nconst mcp = await createMCPClient({ transport })\ntry {\n  const stream = chat({\n    adapter: openaiText('gpt-5.5'),\n    messages,\n    tools: await mcp.tools(),\n  })\n  return toServerSentEventsResponse(stream)\n} finally {\n  // careful: see the streaming note below\n}\n```\n\nTools execute **lazily while the response stream is consumed**, so the client must stay open until the stream is fully drained. In a route handler that returns a streaming Response, a try/finally around the return closes the client before the body streams and in-flight tool calls fail. Close in a middleware terminal hook (onFinish/onAbort/onError, exactly one fires per run) instead, or let the managed mcp option handle it.\n\nFor scoped usage, the client implements Symbol.asyncDispose:\n\n```\nawait using mcp = await createMCPClient({ transport })\n// closed automatically at end of scope\nawait using mcp = await createMCPClient({ transport })\n// closed automatically at end of scope\n```\n\nThe core @tanstack/ai package gained an optional abortSignal on ToolExecutionContext, and chat() threads the run's signal (the caller's AbortController combined with any middleware abort()) into every tool execution.\n\n@tanstack/ai-mcp forwards that signal straight into the SDK's callTool:\n\n``` js\n// inside the generated execute body\nctx?.abortSignal?.throwIfAborted()\nconst result = await client.callTool(\n  { name, arguments: args ?? {} },\n  undefined,\n  { signal: ctx?.abortSignal },\n)\njs\n// inside the generated execute body\nctx?.abortSignal?.throwIfAborted()\nconst result = await client.callTool(\n  { name, arguments: args ?? {} },\n  undefined,\n  { signal: ctx?.abortSignal },\n)\n```\n\nThe practical effect: when a chat run is aborted, a long-running MCP callTool is cancelled with it instead of running to completion in the background. The change is additive and backward-compatible.\n\nOne server is the simple case. Real agents pull tools from several. createMCPClients connects to many servers in parallel and merges their tools into one flat array.\n\n``` js\nimport { createMCPClients } from '@tanstack/ai-mcp'\n\nconst pool = await createMCPClients({\n  github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },\n  linear: { transport: { type: 'http', url: process.env.LINEAR_MCP_URL! } },\n})\n\n// tools: [github_search_repos, github_create_issue, linear_create_issue, ...]\nconst tools = await pool.tools()\njs\nimport { createMCPClients } from '@tanstack/ai-mcp'\n\nconst pool = await createMCPClients({\n  github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },\n  linear: { transport: { type: 'http', url: process.env.LINEAR_MCP_URL! } },\n})\n\n// tools: [github_search_repos, github_create_issue, linear_create_issue, ...]\nconst tools = await pool.tools()\n```\n\nThe config is a Record<string, MCPClientOptions> - the same options as a single client, keyed by a name you choose. The pool gives you:\n\n``` js\nconst linearTools = await pool.clients.linear.tools()\nconst repoReadme = await pool.clients.github.readResource('repo://readme')\njs\nconst linearTools = await pool.clients.linear.tools()\nconst repoReadme = await pool.clients.github.readResource('repo://readme')\n```\n\nThe default prefix is the config key. Override it with a string, or disable it entirely with an empty string.\n\n``` js\nconst pool = await createMCPClients({\n  github: {\n    transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },\n    prefix: 'gh', // -> gh_search_repos\n  },\n  internal: {\n    transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! },\n    prefix: '', // no prefix at all\n  },\n})\n\nawait pool.close()\n// or: await using pool = await createMCPClients({ ... })\njs\nconst pool = await createMCPClients({\n  github: {\n    transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },\n    prefix: 'gh', // -> gh_search_repos\n  },\n  internal: {\n    transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! },\n    prefix: '', // no prefix at all\n  },\n})\n\nawait pool.close()\n// or: await using pool = await createMCPClients({ ... })\n```\n\nA pool satisfies the same structural contract as a single client (tools() plus close()), so anywhere a client works, a pool works too - including the managed mcp option next.\n\nSpreading tools manually gives you full control. Most of the time you do not want that control, you want the tools discovered and the connections closed for you. That is the mcp option, and it is the shortest path to a working integration.\n\n``` js\nconst mcp = await createMCPClient({\n  transport: { type: 'http', url: process.env.MCP_URL! },\n})\n\nconst stream = chat({\n  adapter: openaiText('gpt-5.5'),\n  messages,\n  mcp: { clients: [mcp] }, // chat() discovers the tools AND closes the client\n})\n\nreturn toServerSentEventsResponse(stream)\njs\nconst mcp = await createMCPClient({\n  transport: { type: 'http', url: process.env.MCP_URL! },\n})\n\nconst stream = chat({\n  adapter: openaiText('gpt-5.5'),\n  messages,\n  mcp: { clients: [mcp] }, // chat() discovers the tools AND closes the client\n})\n\nreturn toServerSentEventsResponse(stream)\n```\n\nHere is every field on the option, with exact semantics.\n\n```\ninterface ChatMCPOptions {\n  clients: Array<MCPToolSource> // clients and/or pools\n  connection?: 'close' | 'keep-alive' // default 'close'\n  lazyTools?: boolean // default false\n  onDiscoveryError?: (\n    error: unknown,\n    source: MCPToolSource,\n  ) => void | Promise<void>\n}\ninterface ChatMCPOptions {\n  clients: Array<MCPToolSource> // clients and/or pools\n  connection?: 'close' | 'keep-alive' // default 'close'\n  lazyTools?: boolean // default false\n  onDiscoveryError?: (\n    error: unknown,\n    source: MCPToolSource,\n  ) => void | Promise<void>\n}\n```\n\nAn array of anything that satisfies MCPToolSource, which is the structural shape { tools(options?), close() }. Both MCPClient and MCPClients (a pool) match by shape, so you can mix single clients and pools in one array. The core @tanstack/ai package does not import @tanstack/ai-mcp - the dependency only points one way.\n\nDiscovered tools are appended to any tools you already passed via tools, so mcp and a hand-written tools array compose cleanly.\n\nControls what happens to the connections when the run ends.\n\n``` js\n// warm pool reused across many requests\nconst pool = await createMCPClients({\n  github: { transport: gh },\n  linear: { transport: ln },\n})\n\nfunction handler(messages) {\n  return chat({\n    adapter: openaiText('gpt-5.5'),\n    messages,\n    mcp: { clients: [pool], connection: 'keep-alive' }, // pool outlives the run\n  })\n}\njs\n// warm pool reused across many requests\nconst pool = await createMCPClients({\n  github: { transport: gh },\n  linear: { transport: ln },\n})\n\nfunction handler(messages) {\n  return chat({\n    adapter: openaiText('gpt-5.5'),\n    messages,\n    mcp: { clients: [pool], connection: 'keep-alive' }, // pool outlives the run\n  })\n}\n```\n\nWhen true, chat() calls each source's tools({ lazy: true }), which marks the tools lazy so their schemas are deferred. Useful when a server exposes a large catalog and you do not want to pay the full schema cost up front. Defaults to false.\n\nCalled when discovery fails for a single source, with the error and the source that produced it.\n\nAsync handlers are awaited, so a rejected promise also fails fast.\n\n```\nmcp: {\n  clients: [primary, flaky],\n  onDiscoveryError(error, source) {\n    metrics.increment('mcp.discovery_error')\n    // returning (not throwing) => skip this source, keep the others\n  },\n}\nmcp: {\n  clients: [primary, flaky],\n  onDiscoveryError(error, source) {\n    metrics.increment('mcp.discovery_error')\n    // returning (not throwing) => skip this source, keep the others\n  },\n}\n```\n\nWhen chat({ mcp }) runs, an internal MCPManager is built from the option (and is an inert no-op when mcp is undefined, so there is no branching cost otherwise). On each run it:\n\nIf discovery itself throws, the manager disposes any connected sources first (when the policy is 'close') so a failed run does not leak connections.\n\nModes 1 and 2 need no build step. Mode 3 does, and the CLI is how you get there. It introspects live servers and emits compile-time-only types that slot into both standalone clients and pools.\n\n``` js\nimport { defineConfig } from '@tanstack/ai-mcp'\n\nexport default defineConfig({\n  servers: {\n    github: {\n      transport: { type: 'http', url: 'https://github-mcp.example.com/mcp' },\n    },\n    linear: {\n      transport: { type: 'http', url: 'https://linear-mcp.example.com/mcp' },\n      prefix: 'linear', // must match the runtime createMCPClient({ prefix })\n    },\n  },\n  outFile: './mcp-types.generated.ts',\n})\njs\nimport { defineConfig } from '@tanstack/ai-mcp'\n\nexport default defineConfig({\n  servers: {\n    github: {\n      transport: { type: 'http', url: 'https://github-mcp.example.com/mcp' },\n    },\n    linear: {\n      transport: { type: 'http', url: 'https://linear-mcp.example.com/mcp' },\n      prefix: 'linear', // must match the runtime createMCPClient({ prefix })\n    },\n  },\n  outFile: './mcp-types.generated.ts',\n})\n```\n\ndefineConfig is purely for editor autocomplete and type checking of the config itself. Each CodegenServerConfig carries a transport and an optional prefix. That prefix must match whatever you pass at runtime, because it changes the tool names the types describe.\n\n```\nnpx @tanstack/ai-mcp generate\nnpx @tanstack/ai-mcp generate\n```\n\nThe CLI connects to each declared server, introspects its tools, resources, and prompts, and writes the result to outFile. Its heavier dependencies are bundled into the bin only, so they never reach the library you deploy.\n\nOne interface per server (extending ServerDescriptor) plus a combined pool map:\n\n``` python\n// AUTO-GENERATED by `npx @tanstack/ai-mcp generate`. Do not edit.\nimport type { ServerDescriptor } from '@tanstack/ai-mcp'\n\nexport interface GithubServer extends ServerDescriptor {\n  tools: {\n    search_repositories: {\n      input: { query: string; limit?: number }\n      output: unknown\n    }\n    create_issue: {\n      input: { repo: string; title: string; body?: string }\n      output: unknown\n    }\n  }\n  resources: {}\n  prompts: {}\n  capabilities: { tools: {} } & Record<string, unknown>\n}\n\nexport interface MCPServers extends Record<string, ServerDescriptor> {\n  github: GithubServer\n  linear: LinearServer\n}\npython\n// AUTO-GENERATED by `npx @tanstack/ai-mcp generate`. Do not edit.\nimport type { ServerDescriptor } from '@tanstack/ai-mcp'\n\nexport interface GithubServer extends ServerDescriptor {\n  tools: {\n    search_repositories: {\n      input: { query: string; limit?: number }\n      output: unknown\n    }\n    create_issue: {\n      input: { repo: string; title: string; body?: string }\n      output: unknown\n    }\n  }\n  resources: {}\n  prompts: {}\n  capabilities: { tools: {} } & Record<string, unknown>\n}\n\nexport interface MCPServers extends Record<string, ServerDescriptor> {\n  github: GithubServer\n  linear: LinearServer\n}\n```\n\nFor a single client, pass the per-server interface:\n\n``` python\nimport type { GithubServer } from './mcp-types.generated'\n\nconst mcp = await createMCPClient<GithubServer>({\n  transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },\n})\npython\nimport type { GithubServer } from './mcp-types.generated'\n\nconst mcp = await createMCPClient<GithubServer>({\n  transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },\n})\n```\n\nFor a pool, pass the combined MCPServers map. This is the part that makes codegen and pools click together: the generated map **constrains the pool config keys**, so a missing or misspelled server key is a compile error, and each pool.clients[key] is typed to that server's descriptor.\n\n``` python\nimport type { MCPServers } from './mcp-types.generated'\n\nconst pool = await createMCPClients<MCPServers>({\n  github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },\n  linear: {\n    transport: { type: 'http', url: process.env.LINEAR_MCP_URL! },\n    prefix: 'linear', // matches the config; keeps generated names accurate\n  },\n})\n\nconst tools = await pool.tools() // typed, then hand to chat({ mcp })\npython\nimport type { MCPServers } from './mcp-types.generated'\n\nconst pool = await createMCPClients<MCPServers>({\n  github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },\n  linear: {\n    transport: { type: 'http', url: process.env.LINEAR_MCP_URL! },\n    prefix: 'linear', // matches the config; keeps generated names accurate\n  },\n})\n\nconst tools = await pool.tools() // typed, then hand to chat({ mcp })\n```\n\nThe types are a compile-time overlay. At runtime the pool builds the same descriptor-agnostic clients it always would, which is why the generated map costs nothing in your bundle and nothing at execution time.\n\nThe three concepts compose into one summary: a single server, a pool, and generated types all feed the same chat().\n\n``` js\nimport { chat, toServerSentEventsResponse } from '@tanstack/ai'\nimport { openaiText } from '@tanstack/ai-openai/adapters'\nimport { createMCPClient, createMCPClients } from '@tanstack/ai-mcp'\nimport type { MCPServers } from './mcp-types.generated'\n\n// one server, managed lifecycle\nconst single = await createMCPClient({\n  transport: { type: 'http', url: oneUrl },\n})\n\n// many servers, typed pool, kept warm\nconst pool = await createMCPClients<MCPServers>({\n  github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },\n  linear: {\n    transport: { type: 'http', url: process.env.LINEAR_MCP_URL! },\n    prefix: 'linear',\n  },\n})\n\nconst stream = chat({\n  adapter: openaiText('gpt-5.5'),\n  messages,\n  mcp: {\n    clients: [single, pool],\n    connection: 'keep-alive', // you own `pool`; close it on shutdown\n    lazyTools: true,\n    onDiscoveryError: (err, source) => log.warn('mcp discovery failed', err),\n  },\n})\n\nreturn toServerSentEventsResponse(stream)\njs\nimport { chat, toServerSentEventsResponse } from '@tanstack/ai'\nimport { openaiText } from '@tanstack/ai-openai/adapters'\nimport { createMCPClient, createMCPClients } from '@tanstack/ai-mcp'\nimport type { MCPServers } from './mcp-types.generated'\n\n// one server, managed lifecycle\nconst single = await createMCPClient({\n  transport: { type: 'http', url: oneUrl },\n})\n\n// many servers, typed pool, kept warm\nconst pool = await createMCPClients<MCPServers>({\n  github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },\n  linear: {\n    transport: { type: 'http', url: process.env.LINEAR_MCP_URL! },\n    prefix: 'linear',\n  },\n})\n\nconst stream = chat({\n  adapter: openaiText('gpt-5.5'),\n  messages,\n  mcp: {\n    clients: [single, pool],\n    connection: 'keep-alive', // you own `pool`; close it on shutdown\n    lazyTools: true,\n    onDiscoveryError: (err, source) => log.warn('mcp discovery failed', err),\n  },\n})\n\nreturn toServerSentEventsResponse(stream)\n```\n\nOne server or many. Managed or manual. Untyped or generated. Warm or closed. Same chat(), every time.\n\nA few decisions are worth calling out because they affect where you can deploy:\n\nInstall the package:\n\n```\npnpm add @tanstack/ai-mcp\npnpm add @tanstack/ai-mcp\n```\n\nThat is all you need - @modelcontextprotocol/sdk ships as a dependency, so it comes along automatically. Add it to your own package.json only if you import from it directly (for example the OAuthClientProvider type or a hand-built StreamableHTTPClientTransport in the escape-hatch path).\n\nThen connect a server, hand it to chat(), and ship. Read the full guides at [tanstack.com/ai](https://tanstack.com/ai): start with the MCP overview, then the managed chat() integration, the manual typed-tools path, and the codegen workflow.\n\nWhatever shape your MCP setup takes - one server or a fleet, fully managed or hand-wired, loosely typed or generated end-to-end - @tanstack/ai-mcp meets you there. It is your MCP, your way.", "url": "https://wpnews.pro/news/tanstack-ai-your-mcp-your-way", "canonical_source": "https://tanstack.com/blog/your-mcp-your-way", "published_at": "2026-06-05 12:00:00+00:00", "updated_at": "2026-06-05 18:03:08.143836+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure", "ai-agents", "large-language-models", "generative-ai"], "entities": ["TanStack AI", "Model Context Protocol", "OpenAI", "Anthropic", "Gemini", "Ollama", "Alem Tuzlak", "Streamable HTTP"], "alternates": {"html": "https://wpnews.pro/news/tanstack-ai-your-mcp-your-way", "markdown": "https://wpnews.pro/news/tanstack-ai-your-mcp-your-way.md", "text": "https://wpnews.pro/news/tanstack-ai-your-mcp-your-way.txt", "jsonld": "https://wpnews.pro/news/tanstack-ai-your-mcp-your-way.jsonld"}}