Skip to content
MCP

MCP Server Tools: Advanced

This page is the reference for createMCPClient and createMCPClients. To connect your first server, start at MCP Server Tools.

Authentication

Static tokens (headers)

If the server takes an API key or a bearer token, pass headers on the http or sse transport. The client sends them with each request:

ts
import { createMCPClient } from '@tanstack/ai-mcp'

const mcp = await createMCPClient({
  transport: {
    type: 'http',
    url: 'https://my-mcp-server.example.com/mcp',
    headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` },
  },
})

OAuth (authProvider)

If the server uses the MCP authorization spec (OAuth 2.1), pass an authProvider on the http or sse transport. The value is an OAuthClientProvider from @modelcontextprotocol/client.

The transport does these steps for you:

  • It attaches the token.
  • It refreshes the token.
  • It sends the request again on a 401.
ts
import { createMCPClient } from '@tanstack/ai-mcp'
import type { OAuthClientProvider } from '@modelcontextprotocol/client'
import { myTokenStore } from './token-store'

// Server-side: back the provider with tokens you persist (database, KV, ...).
// `tokens()` returning a valid (or refreshable) token set is all the SDK
// needs to authenticate requests.
const myOAuthProvider: OAuthClientProvider = myTokenStore.provider()

const mcp = await createMCPClient({
  transport: {
    type: 'http',
    url: 'https://my-mcp-server.example.com/mcp',
    authProvider: myOAuthProvider,
  },
})

For a provider with stored tokens and a working refresh, this form is all you need.

An interactive redirect flow needs one more step. After the redirect, you must call finishAuth(code) on the transport. createMCPClient makes the transport internally, so you cannot reach it. Make the transport yourself instead:

  1. Make a StreamableHTTPClientTransport with your authProvider, and keep a reference to it.
  2. In your OAuth callback route, call transport.finishAuth(code).
  3. Pass the transport to createMCPClient({ transport }). See Custom transport.

Custom transport

You can pass any Transport instance as the transport option. For tests in the same process, @tanstack/ai-mcp exports InMemoryTransport:

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 a Transport from @modelcontextprotocol/client:

ts
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'

const transport = new StreamableHTTPClientTransport(new URL('https://example.com/mcp'))
const mcp = await createMCPClient({ transport })

Timeouts

By default, each request waits up to 60 seconds, then fails. A long tool, such as a report or a build, fails at that limit too.

Set requestOptions to change the limit. It applies to the connect handshake, tool lists, tool calls, resource requests, and prompt requests:

ts
import { createMCPClient } from '@tanstack/ai-mcp'

const mcp = await createMCPClient({
  transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
  requestOptions: {
    // Wait up to 2 minutes for each request.
    timeout: 120_000,
    // Start the 2 minutes again each time the server reports progress.
    resetTimeoutOnProgress: true,
  },
})
  • timeout: the limit in milliseconds. The SDK default is 60,000.
  • resetTimeoutOnProgress: start the timeout again when the server sends a progress notification. A tool call on protocol 2026-07-28 gets no progress notifications, so only timeout applies there.

Edge cases:

  • On a stdio server, connect can take up to two times timeout. When the version check runs out of time, the SDK tries the older handshake with its own timeout.
  • The subscriptions/listen stream and task status polls keep the SDK defaults.
  • A request that runs out of time throws an SdkError from @modelcontextprotocol/client, with the code SdkErrorCode.RequestTimeout.

Each server in a pool can have its own requestOptions. MCP Apps widget calls use the options of the same client.

Three Modes of Type Safety

Mode 1: auto-discovery

Call tools() with no arguments to get every tool of the server. This needs no setup. The tool arguments have the type unknown at compile time. The client uses the MCP JSON Schema to check them at runtime.

ts
import { createMCPClient } from '@tanstack/ai-mcp'

const mcp = await createMCPClient({
  transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
})
const tools = await mcp.tools()
// tools: ServerTool[]  (args typed unknown at compile time)

Some providers reject an object schema without properties. To keep those tools working, the client adds defaults:

  • A schema without properties gets properties: {}.
  • A schema without type gets type: 'object'.
  • A schema that has both fields does not change.

MCP schemas often use $ref or oneOf. On adapters built on the OpenAI API, those tools go out without strict mode, and the adapter warns in development. See Tools that cannot use strict mode.

Task-based tools work too:

  • A tool with execution.taskSupport: 'required' runs through the experimental tasks/callToolStream flow of the MCP SDK. TanStack AI waits through the task status updates and gives the final result to the model.
  • A tool with taskSupport: 'optional' uses a normal callTool.
  • Task execution needs the server to declare the tasks capability for tools/call. If the server lists a task-required tool without it, auto-discovery skips the tool, because nothing can call it.
  • If the chat run aborts, TanStack AI stops the wait and sends a best-effort tasks/cancel for a remote task that the server already made.

Mode 2: explicit definitions

Pass TanStack toolDefinition() instances to get full TypeScript types and Zod validation. You get only the named tools back. If a name is not on the server, tools() throws MCPToolNotFoundError. Task-required tools use the same task execution as Mode 1.

ts
import { toolDefinition } from '@tanstack/ai'
import { createMCPClient } from '@tanstack/ai-mcp'
import { z } from 'zod'

const searchDef = toolDefinition({
  name: 'search',
  description: 'Search for items',
  inputSchema: z.object({ query: z.string() }),
  outputSchema: z.array(z.object({ id: z.string(), title: z.string() })),
})

const mcp = await createMCPClient({
  transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
})
const tools = await mcp.tools([searchDef])
// tools[0].execute is typed: (args: { query: string }) => ...

Mode 3: generated types

Run the CLI against a live server to generate an interface for each server. Then pass the type as a generic to createMCPClient. You get:

  • Tool names narrowed to the literal names of the server.
  • Compile-checked config keys on a pool.
  • No runtime cost.

Tool arguments stay unknown on this path. For typed arguments, use Mode 2 too.

See MCP Type Generation for the mcp.config.ts setup, the generate CLI, and how to use the types.

Tool Titles & Annotations

An MCP server can send display and behavior data with each tool:

  • title: a name for people to read.
  • annotations: hints such as readOnlyHint, destructiveHint, idempotentHint, openWorldHint, and an older annotations.title.

@tanstack/ai-mcp puts this data on metadata.mcp of each tool. It does this in Mode 1 and in Mode 2. Use it to label tools in your UI and to pick which tools need a confirmation step.

metadata.mcp fieldValue
titlestring. The display name, in this order of precedence: the title of the tool, then annotations.title, then name. Always set.
annotationsA frozen copy of the annotations object of the server. Not set when the server sends none. To change a value, copy it first: { ...tool.metadata.mcp.annotations, readOnlyHint: false }.
serverToolNamestring. The tool name on the server, without a prefix.
serverIdThe prefix of the client. undefined when there is no prefix.
uiResourceUriThe MCP Apps widget link, when the tool declares one.

tools() returns McpServerTool items. Each one is a normal ServerTool, so it goes into chat({ tools }) as is. Its metadata.mcp has a static type, so you can read it with no cast. A misspelled field is a compile error:

ts
import { createMCPClient } from '@tanstack/ai-mcp'

const url = 'https://my-mcp-server.example.com/mcp'

// Trust comes from YOUR configuration (an allowlist of servers that you run
// or checked), never from anything that the server sends.
const trustedServers = new Set(['https://my-mcp-server.example.com/mcp'])
const serverIsTrusted = trustedServers.has(url)

const mcp = await createMCPClient({ transport: { type: 'http', url } })

const tools = (await mcp.tools()).map((tool) => {
  const meta = tool.metadata.mcp
  const advertisedReadOnly = meta.annotations?.readOnlyHint === true
  return {
    ...tool,
    // Approval is the default. A hint can relax it only on a server that
    // you trust for other reasons. On any other server, the hint is only
    // a label, and it does not change approval.
    needsApproval: !(serverIsTrusted && advertisedReadOnly),
  }
})

Warning

Do not use annotations as a security boundary. Every field, title too, is a hint from the server. A bad server can mark a tool that deletes records with readOnlyHint: true.

  • On a server that you do not trust, a hint must never skip approval, sandboxing, or authorization. Show it as a label only.
  • On a server that you trust for other reasons, a hint can relax a confirmation step, as in the sample above.

Titles are for display only:

  • A title never changes the tool name that the model gets.
  • A prefix applies to the name (wx_get_weather), not to the title.

McpToolMetadata, McpServerTool, and ToolAnnotations (from the MCP SDK) are exported. Use them to name the types in your own signatures. You do not need them to read the data.

To label tools in your UI, send the data from a server route. The MCP client must stay on the server:

ts
// src/routes/api.mcp-tools.ts
import { createFileRoute } from '@tanstack/react-router'
import { createMCPClient } from '@tanstack/ai-mcp'

export const Route = createFileRoute('/api/mcp-tools')({
  server: {
    handlers: {
      GET: async () => {
        await using mcp = await createMCPClient({
          transport: { type: 'http', url: process.env.MCP_URL! },
        })
        const catalog = (await mcp.tools()).map((tool) => ({
          name: tool.name,
          // `title` is always set. The fallback order already ran.
          title: tool.metadata.mcp.title,
          description: tool.description,
          readOnly: tool.metadata.mcp.annotations?.readOnlyHint === true,
        }))
        return Response.json({ tools: catalog })
      },
    },
  },
})
tsx
// src/components/ToolCatalog.tsx
import { useEffect, useState } from 'react'

interface ToolSummary {
  name: string
  title: string
  description?: string
  readOnly: boolean
}

export function ToolCatalog() {
  const [tools, setTools] = useState<Array<ToolSummary>>([])

  useEffect(() => {
    fetch('/api/mcp-tools')
      .then((res) => res.json())
      .then((body: { tools: Array<ToolSummary> }) => setTools(body.tools))
  }, [])

  return (
    <ul>
      {tools.map((tool) => (
        <li key={tool.name}>
          {/* Server-declared title, with the hint driving the badge */}
          <strong>{tool.title}</strong> {tool.readOnly ? '(read-only)' : '(writes)'}
          <div>{tool.description}</div>
        </li>
      ))}
    </ul>
  )
}

Limit and Gate Tools

By default, the model gets every tool that the server lists, and each tool runs without approval. A server can expose tools that write or delete data. Two client options control this:

  • toolFilter: a function that returns false to hide a tool, or a list of the tool names to keep.
  • needsApproval: return true to pause the run for approval before each call to the tool.

Both options are off by default. A toolFilter function and needsApproval get the tool definition from the server. It has the server name (without a prefix), the title, and the annotations.

Send only the tools that the server marks read-only:

ts
import { createMCPClient } from '@tanstack/ai-mcp'

const mcp = await createMCPClient({
  transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
  toolFilter: (tool) => tool.annotations?.readOnlyHint === true,
})

const tools = await mcp.tools() // only read-only tools

A tool with no annotations does not pass this filter.

On a server that you do not trust, list the tools by name. You control the names, and the server controls the hints:

ts
import { createMCPClient } from '@tanstack/ai-mcp'

const mcp = await createMCPClient({
  transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
  toolFilter: ['search_issues', 'get_issue'],
})

const tools = await mcp.tools() // search_issues, then get_issue

A list is strict:

  • The tools come back in the list order.
  • A name that the server does not have, or a name listed twice, throws MCPToolFilterError. Its missing, repeated, and available fields name the tools. A typo fails the tools() call, so the model never runs with fewer tools than you expect.
  • A listed tool that needs task execution, on a server without task support, throws MCPTaskRequiredToolError.
  • An empty list keeps no tools.

A function does not fail when a tool is missing. If the tool list of the server can change, use a function.

Keep every tool, but ask the user before a tool runs:

ts
import { createMCPClient } from '@tanstack/ai-mcp'

const url = 'https://my-mcp-server.example.com/mcp'
// Trust comes from your configuration, not from the server.
const trustedServers = new Set(['https://my-mcp-server.example.com/mcp'])
const serverIsTrusted = trustedServers.has(url)

const mcp = await createMCPClient({
  transport: { type: 'http', url },
  // A read-only hint skips approval only on a server that you trust.
  needsApproval: (tool) =>
    !(serverIsTrusted && tool.annotations?.readOnlyHint === true),
})

The run stops with an approval interrupt for each gated call. The client approves or denies it the same way as for any other tool. See Tool Approval Flow.

In a pool, each server gets its own options:

ts
import { createMCPClients } from '@tanstack/ai-mcp'

const pool = await createMCPClients({
  github: {
    transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },
    toolFilter: (tool) => tool.annotations?.readOnlyHint === true,
  },
  docs: { transport: { type: 'http', url: process.env.DOCS_MCP_URL! } },
})

Where the options apply:

CalltoolFilterneedsApproval
tools()Hides tools.Marks tools.
tools([...defs])Applies. A definition that the filter hides throws MCPToolNotFoundError.Does not apply. Each toolDefinition has its own needsApproval.
chat({ mcp })Applies, because chat() calls tools(). See Managed MCP.Applies.
MCP Apps widget callsApplies. A widget cannot call a hidden tool.Applies. A widget call has no approval step, so a call to a marked tool returns { ok: false }.
callTool()Does not apply. Your code calls the tool, not the model.Does not apply.

Warning

The hints come from the server. A compromised server can mark a delete tool readOnlyHint: true. For a server that you do not trust, use a name list or needsApproval.

If you need to name the type of the tool argument, use the exported McpTool type.

Multi-Server Pool

createMCPClients connects to many servers at the same time and merges their tools into one flat array. Each tool name gets the config key as a prefix, so names from two servers do not collide.

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 the tools of all servers. If two names are the same after the prefix, it throws DuplicateToolNameError.

Per-server access

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! } },
})

const linearTools = await pool.clients.linear!.tools()
const resources = await pool.clients.github!.resources()

Disable or override the prefix

ts
import { createMCPClients } from '@tanstack/ai-mcp'

const pool = await createMCPClients({
  github: {
    transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },
    prefix: 'gh',           // override: "gh_search_repos"
  },
  internal: {
    transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! },
    prefix: '',             // disable prefix entirely
  },
})

Closing the pool

ts
await pool.close()
// or
await using pool = await createMCPClients({ /* server configs */ })

If one server fails to connect, the pool closes the clients that did connect, then throws. No connection stays open.

Lifecycle

Tip

If you pass clients to chat() in the mcp option, you can skip this section. chat() closes the connections when the run ends. See Managed MCP with chat().

When you spread tools into tools yourself, you own the client. chat() never closes it.

Tools run while the response stream is read. So close the client only after the stream is fully read.

In a route that returns a streaming Response, a try/finally around the return closes the client too early. The same is true for await using at function scope. The client closes before the body streams, and tool calls in progress fail. Close it in a middleware hook instead.

Streaming route handlers: close via middleware

After the agent loop ends, exactly one of onFinish, onAbort, or onError runs:

ts
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'

export async function POST(request: Request) {
  const { messages, threadId } = await request.json()
  const url = 'https://my-mcp-server.example.com/mcp'
  const mcp = await createMCPClient({ transport: { type: 'http', url } })
  const stream = chat({
    adapter: openaiText('gpt-6.1-sol'),
    messages,
    threadId,
    tools: await mcp.tools(),
    middleware: [
      {
        name: 'mcp-close',
        onFinish: () => mcp.close(),
        onAbort: () => mcp.close(),
        onError: () => mcp.close(),
      },
    ],
  })
  return toServerSentEventsResponse(stream)
}

Manual close: when you read the stream in scope

try/finally is correct when you read the full stream before the scope ends:

ts
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'

const messages = [{ role: 'user' as const, content: 'Hello' }]
const url = 'https://my-mcp-server.example.com/mcp'
const mcp = await createMCPClient({ transport: { type: 'http', url } })
try {
  const stream = chat({
    adapter: openaiText('gpt-6.1-sol'),
    messages,
    threadId: 'thread-1',
    tools: await mcp.tools(),
  })
  for await (const chunk of stream) {
    // handle chunks: the stream is fully read inside this block
  }
} finally {
  await mcp.close()
}

await using (Explicit Resource Management)

This needs a runtime with Symbol.asyncDispose: Node 18.2+ with TypeScript target: "es2022" and lib: ["esnext"]. The same rule applies: read the stream in scope. The client closes when the block ends:

ts
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'

const messages = [{ role: 'user' as const, content: 'Hello' }]
const url = 'https://my-mcp-server.example.com/mcp'
await using mcp = await createMCPClient({ transport: { type: 'http', url } })
const stream = chat({
  adapter: openaiText('gpt-6.1-sol'),
  messages,
  threadId: 'thread-1',
  tools: await mcp.tools(),
})
for await (const chunk of stream) {
  // handle chunks
}
// mcp.close() is called automatically when the block exits

Tool Name Collisions

When you mix tools from many sources, two tools with the same name throw DuplicateToolNameError:

ts
import { createMCPClients, DuplicateToolNameError } from '@tanstack/ai-mcp'

const pool = await createMCPClients({
  github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } },
  linear: { transport: { type: 'http', url: process.env.LINEAR_MCP_URL! } },
})

try {
  const tools = await pool.tools()
} catch (err) {
  if (err instanceof DuplicateToolNameError) {
    console.error('Conflicting tool name:', err.toolName)
    // Fix: set a unique prefix on one of the clients
  }
}

To prevent a collision, give each client a unique prefix. createMCPClients does this for you with the config key.

Name Tools Yourself

The model gets <prefix>_<tool>, or the server tool name when there is no prefix. Some apps need another pattern. For example:

  • A fixed mcp__<server>__<tool> form.
  • Names without characters that a provider rejects.

Pass toolName. It gets the tool definition from the server and returns the name that the model gets. It wins over prefix:

ts
import { createMCPClient } from '@tanstack/ai-mcp'

// Keep only letters, digits, `_`, and `-` in the name.
const clean = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, '_')

const mcp = await createMCPClient({
  transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
  toolName: (tool) => `mcp__github__${clean(tool.name)}`,
})

const tools = await mcp.tools() // mcp__github__search_issues, ...
  • The name applies to tools() and to tools([...defs]).
  • metadata.mcp.serverToolName keeps the server name of the tool. MCP Apps widget calls use it, and callTool() takes it, so both still reach the tool.
  • metadata.mcp.serverId is still the client prefix.
  • MCP Apps routes widget calls by prefix. If you use MCP Apps with toolName, keep a prefix on each client.
  • Two tools with the same final name throw DuplicateToolNameError, in one client and across a pool.

In a pool, each server gets its own toolName:

ts
import { createMCPClients } from '@tanstack/ai-mcp'

const pool = await createMCPClients({
  github: {
    transport: { type: 'http', url: process.env.GITHUB_MCP_URL! },
    toolName: (tool) => `mcp__github__${tool.name}`,
  },
  linear: {
    transport: { type: 'http', url: process.env.LINEAR_MCP_URL! },
    toolName: (tool) => `mcp__linear__${tool.name}`,
  },
})

const tools = await pool.tools() // mcp__github__..., mcp__linear__...

Lazy Tool Discovery

A server with many tools sends many schemas to the model, and each schema costs tokens. Pass { lazy: true } to send a schema only when the model asks for it:

ts
import { createMCPClient } from '@tanstack/ai-mcp'

const mcp = await createMCPClient({
  transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' },
})
const tools = await mcp.tools({ lazy: true })
// All tools are marked lazy: true

It works on a pool too:

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! } },
})
const tools = await pool.tools({ lazy: true })

See Lazy Tool Discovery for how the model finds lazy tools at runtime.

Error Reference

Error classWhen thrown
MCPConnectionErrorcreateMCPClient cannot connect, or you call a method after close().
DuplicateToolNameErrorTwo tools have the same name in one client or across the pool.
MCPToolNotFoundErrorA toolDefinition name passed to tools([...defs]) is not on the server, or the toolFilter of the client hides it.
MCPToolFilterErrorA toolFilter list names a tool that the server does not have, or names a tool twice. In a pool, pool.tools() throws MCPConnectionError with this error as its cause.
MCPTaskRequiredToolErrorA task-required tool came from tools([...defs]), a toolFilter list, or callTool(), but the server does not declare the tasks capability for tools/call. The call can never run.

For the MCPDuplicateToolNameError that a chat({ mcp }) run throws when it merges tools from many sources, see Managed MCP with chat().