Skip to content
Back to skills

Tool Calling

ASecurity

Isomorphic tool system: toolDefinition() with Zod schemas, .server() and .client() implementations, passing tools to both chat() on server and useChat/clientTools on client, tool approval flows with needsApproval and bound interrupts (resolveInterrupt), generic middleware interrupts with defineInterrupt(), lazy tool discovery with lazy:true, rendering ToolCallPart and ToolResultPart in UI.

  • 3,166 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added May 26, 2026
ai-agentstypescriptshellreactnodegitapi

Works with

  • terminal
  • cli
  • api
  • mcp

Security analysis

A100/100

Scanned September 28, 2026

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

Installs into .claude/skills of the current project.

Are you the author of Tool Calling?

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

Security grade badge for Tool Calling
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tanstack-tool-calling/badge)](https://www.skillsdirectory.com/skills/tanstack-tool-calling)

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

Download with Pro
SKILL.md
---
name: ai-core/tool-calling
description: >
  Isomorphic tool system: toolDefinition() with Zod schemas,
  .server() and .client() implementations, passing tools to both
  chat() on server and useChat/clientTools on client, tool approval
  flows with needsApproval and bound interrupts (resolveInterrupt), generic
  middleware interrupts with defineInterrupt(), lazy tool
  discovery with lazy:true, rendering ToolCallPart and ToolResultPart
  in UI.
type: sub-skill
library: tanstack-ai
library_version: '0.42.0'
sources:
  - 'TanStack/ai:docs/tools/tools.md'
  - 'TanStack/ai:docs/tools/server-tools.md'
  - 'TanStack/ai:docs/tools/client-tools.md'
  - 'TanStack/ai:docs/tools/tool-approval.md'
  - 'TanStack/ai:docs/tools/lazy-tool-discovery.md'
---

# Tool Calling

This skill builds on ai-core. Read it first for critical rules.

## Setup

Complete end-to-end example: shared definition, server tool, client tool, server route, React client.
The four files below share one scope, so later files use the earlier exports directly.

```typescript group=product-catalog
// tools/definitions.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

export const getProductsDef = toolDefinition({
  name: 'get_products',
  description: 'Search for products in the catalog',
  inputSchema: z.object({
    query: z.string().meta({ description: 'Search keyword' }),
    limit: z.number().optional().meta({ description: 'Max results' }),
  }),
  outputSchema: z.object({
    products: z.array(
      z.object({ id: z.string(), name: z.string(), price: z.number() }),
    ),
  }),
})

export const updateCartUIDef = toolDefinition({
  name: 'update_cart_ui',
  description: 'Update the shopping cart UI with item count',
  inputSchema: z.object({ itemCount: z.number(), message: z.string() }),
  outputSchema: z.object({ displayed: z.boolean() }),
})
```

```typescript group=product-catalog
// tools/server.ts (uses getProductsDef from tools/definitions.ts)
import { db } from './db'

export const getProducts = getProductsDef.server(async ({ query, limit }) => {
  const results: Array<{ id: string; name: string; price: number }> =
    await db.products.search(query, { limit: limit ?? 10 })
  return {
    products: results.map((p) => ({ id: p.id, name: p.name, price: p.price })),
  }
})
```

```typescript group=product-catalog
// api/chat/route.ts (uses getProducts and updateCartUIDef from tools/)
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'

export async function POST(request: Request) {
  const { messages } = await request.json()
  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [getProducts, updateCartUIDef], // server tool + client definition
  })
  return toServerSentEventsResponse(stream)
}
```

```tsx group=product-catalog
// app/chat.tsx (uses updateCartUIDef from tools/definitions.ts)
import {
  useChat,
  fetchServerSentEvents,
  createChatClientOptions,
  type InferChatMessages,
} from '@tanstack/ai-react'
import { clientTools } from '@tanstack/ai-client'
import { useState } from 'react'

function ChatPage() {
  const [cartCount, setCartCount] = useState(0)

  const updateCartUI = updateCartUIDef.client((input) => {
    setCartCount(input.itemCount)
    return { displayed: true }
  })

  const tools = clientTools(updateCartUI)
  const chatOptions = createChatClientOptions({
    connection: fetchServerSentEvents('/api/chat'),
    tools,
  })
  const { messages, sendMessage } = useChat(chatOptions)
  // InferChatMessages ties part types to the configured tools when needed:
  // type Messages = InferChatMessages<typeof chatOptions>

  return (
    <div>
      <span>Cart: {cartCount}</span>
      {messages.map((msg) => (
        <div key={msg.id}>
          {msg.parts.map((part) => {
            if (part.type === 'text') return <p>{part.content}</p>
            if (part.type === 'tool-call') {
              return (
                <div key={part.id}>
                  Tool: {part.name} ({part.state})
                </div>
              )
            }
            return null
          })}
        </div>
      ))}
    </div>
  )
}
```

## Core Patterns

### Generic middleware interrupts

Use `defineInterrupt()` when middleware needs typed data from the client. This
does not replace `needsApproval`. Tool approval asks whether a tool can run.
Generic interrupts ask for application data at a chat lifecycle boundary.

Define the interrupt once. Register it with both `chat({ interrupts })` and
`useChat({ interrupts })`. Emit it only from `onInterruptBoundary`, then read
the typed result in `onInterruptResolution`.

```typescript
import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
import { z } from 'zod'

const reviewPlan = defineInterrupt({
  id: 'review-plan',
  payloadSchema: z.object({ title: z.string() }),
  responseSchema: z.object({ approved: z.boolean() }),
})

const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
  onInterruptBoundary(ctx) {
    if (ctx.phase !== 'beforeTools') return
    return {
      interrupts: [
        reviewPlan.interrupt({
          key: 'release-plan',
          reason: 'review-required',
          message: 'Approve this plan?',
          payload: { title: 'Release plan' },
        }),
      ],
    }
  },
  onInterruptResolution(_ctx, resumedInterrupts) {
    for (const result of resumedInterrupts.for(reviewPlan)) {
      if (result.status === 'resolved' && !result.response.approved) {
        return { toolResume: 'stop' }
      }
    }
  },
}
```

Several middleware can request generic interrupts at one boundary. They share
one AG-UI interrupt batch with tool approvals. A continuation starts only after
the client resolves or cancels every bound item. `stop` is more restrictive than
`cancel`, which is more restrictive than `continue`.

Do not emit raw AG-UI interrupt events from middleware. Use the boundary hook
so the engine creates one terminal event and persistence records the batch.

### Pattern 1: Server-Only Tool

Define with `toolDefinition()`, implement with `.server()`, pass to `chat({ tools })`.
The server executes it automatically. The client never runs code for this tool.

```typescript
import { chat, toolDefinition, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
import { db } from './db'

const getUserDataDef = toolDefinition({
  name: 'get_user_data',
  description: 'Look up user by ID',
  inputSchema: z.object({
    userId: z.string().meta({ description: "The user's ID" }),
  }),
  outputSchema: z.object({ name: z.string(), email: z.string() }),
})

const getUserData = getUserDataDef.server(async ({ userId }) => {
  const user = await db.users.findUnique({ where: { id: userId } })
  return { name: user.name, email: user.email }
})

// In your route handler:
export async function POST(request: Request) {
  const { messages } = await request.json()
  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [getUserData],
  })
  return toServerSentEventsResponse(stream)
}
```

### Pattern 2: Client-Only Tool

Pass the bare definition (no `.server()`) to `chat({ tools })` so the LLM knows
about it. Pass the `.client()` implementation to `useChat` via `clientTools()`.

```typescript group=notification-tool
// tools/definitions.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

export const showNotificationDef = toolDefinition({
  name: 'show_notification',
  description: 'Display a toast notification to the user',
  inputSchema: z.object({
    message: z.string(),
    type: z.enum(['success', 'error', 'info']),
  }),
  outputSchema: z.object({ shown: z.boolean() }),
})
```

Server -- pass definition only (no execute function):

```typescript group=notification-tool
// api/chat/route.ts (uses showNotificationDef from tools/definitions.ts)
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'

export async function POST(request: Request) {
  const { messages } = await request.json()
  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [showNotificationDef],
  })
  return toServerSentEventsResponse(stream)
}
```

Client -- pass `.client()` implementation:

```tsx group=notification-tool
// app/chat.tsx (uses showNotificationDef from tools/definitions.ts)
import {
  useChat,
  fetchServerSentEvents,
  createChatClientOptions,
} from '@tanstack/ai-react'
import { clientTools } from '@tanstack/ai-client'
import { useState } from 'react'

function ChatPage() {
  const [toast, setToast] = useState<string | null>(null)

  const showNotification = showNotificationDef.client((input) => {
    setToast(input.message)
    setTimeout(() => setToast(null), 3000)
    return { shown: true }
  })

  const { messages, sendMessage } = useChat(
    createChatClientOptions({
      connection: fetchServerSentEvents('/api/chat'),
      tools: clientTools(showNotification),
    }),
  )

  return (
    <div>
      {toast && <div className="toast">{toast}</div>}
      {messages.map((msg) => (
        <div key={msg.id}>
          {msg.parts.map((part) =>
            part.type === 'text' ? <p>{part.content}</p> : null,
          )}
        </div>
      ))}
    </div>
  )
}
```

### Pattern 3: Tool with Approval Flow

Set `needsApproval: true` in the definition. Execution pauses with
`RUN_FINISHED.outcome.type === 'interrupt'`. The primary client API is bound
`interrupts` + `resolveInterrupt` / `resolveInterrupts` / `cancel`.
`addToolApprovalResponse` and `pendingInterrupts` remain as deprecated
compatibility shims during migration.

```typescript group=email-approval
// tools/email.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { emailService } from './email-service'

export const sendEmailDef = toolDefinition({
  name: 'send_email',
  description: 'Send an email to a recipient',
  inputSchema: z.object({
    to: z.string().email(),
    subject: z.string(),
    body: z.string(),
  }),
  outputSchema: z.object({ success: z.boolean(), messageId: z.string() }),
  needsApproval: true,
})

export const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
  const result = await emailService.send({ to, subject, body })
  return { success: true, messageId: result.id }
})
```

Server route must forward `resume` / `parentRunId` (via `chatParamsFromRequest`
or equivalent). Client -- render bound interrupts:

```tsx group=email-approval
// app/chat.tsx (registers sendEmailDef so the approval interrupt is typed)
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'

function ChatPage() {
  const { messages, interrupts, sendMessage } = useChat({
    connection: fetchServerSentEvents('/api/chat'),
    tools: [sendEmailDef],
  })

  return (
    <div>
      {interrupts.map((interrupt) => {
        if (interrupt.kind !== 'tool-approval') return null
        return (
          <div key={interrupt.id}>
            <p>Approve "{interrupt.toolName}"?</p>
            <pre>{JSON.stringify(interrupt.originalArgs, null, 2)}</pre>
            <button onClick={() => interrupt.resolveInterrupt(true)}>
              Approve
            </button>
            <button onClick={() => interrupt.resolveInterrupt(false)}>
              Deny
            </button>
            <button onClick={() => interrupt.cancel()}>Cancel</button>
          </div>
        )
      })}
      {messages.map((msg) => (
        <div key={msg.id}>
          {msg.parts.map((part) =>
            part.type === 'text' ? (
              <p key={part.content}>{part.content}</p>
            ) : null,
          )}
        </div>
      ))}
    </div>
  )
}
```

Batch all pending approvals with `resolveInterrupts` (void — submission is
async; watch `resuming` / `interruptErrors`):

```tsx group=email-approval
function ApproveAllButton() {
  const { resolveInterrupts, resuming } = useChat({
    connection: fetchServerSentEvents('/api/chat'),
    tools: [sendEmailDef],
  })

  // Payloadless tool-approvals only
  const approveAll = () => resolveInterrupts(true)

  // Or per-item:
  const approveEach = () =>
    resolveInterrupts((interrupt) => {
      if (interrupt.kind === 'tool-approval') {
        interrupt.resolveInterrupt(true)
      }
    })

  return (
    <>
      <button disabled={resuming} onClick={approveAll}>
        Approve all
      </button>
      <button disabled={resuming} onClick={approveEach}>
        Approve each
      </button>
    </>
  )
}
```

Migration: `pendingInterrupts` aliases `interrupts`; `addToolApprovalResponse`
forwards to the matching bound approval when present. Prefer the bound methods
above for new code. See `docs/interrupts/`.

### Pattern 4: Lazy Tool Discovery

Set `lazy: true` on rarely-needed tools. The LLM sees their names via a synthetic
`__lazy__tool__discovery__` tool and discovers schemas on demand. Saves tokens.

```typescript group=lazy-tools
import {
  toolDefinition,
  chat,
  toServerSentEventsResponse,
  maxIterations,
  type ModelMessage,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
import { db } from './db'

const getProductsDef = toolDefinition({
  name: 'getProducts',
  description: 'List all products',
  inputSchema: z.object({}),
  outputSchema: z.array(
    z.object({ id: z.number(), name: z.string(), price: z.number() }),
  ),
})
const getProducts = getProductsDef.server(async () => db.products.findMany())

const compareProductsDef = toolDefinition({
  name: 'compareProducts',
  description: 'Compare two or more products side by side',
  inputSchema: z.object({ productIds: z.array(z.number()).min(2) }),
  lazy: true, // not sent to LLM upfront
})
const compareProducts = compareProductsDef.server(async ({ productIds }) => {
  return db.products.findMany({ where: { id: { in: productIds } } })
})

export async function POST(request: Request) {
  const { messages } = await request.json()
  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [getProducts, compareProducts],
    // maxIterations bounds model turns, not tool calls. For tool budgets,
    // use middleware onBeforeToolCall + onShouldContinue (see agentic-cycle docs).
    agentLoopStrategy: maxIterations(20),
  })
  return toServerSentEventsResponse(stream)
}
```

The LLM sees `getProducts` and `__lazy__tool__discovery__` upfront.
To compare, it first calls `__lazy__tool__discovery__({ toolNames: ["compareProducts"] })`,
gets the full schema, then calls `compareProducts` directly.
Once discovered, a tool stays available for the conversation.
When all lazy tools are discovered, the discovery tool is removed automatically.

### Tuning the lazy catalog with `lazyToolsConfig`

By default the discovery-tool catalog lists only bare names (`'none'`). Pass
`lazyToolsConfig` to `chat()` to include more context:

```typescript group=lazy-tools
// Same tools as the route above, with a richer discovery catalog:
export function chatWithCatalog(messages: Array<ModelMessage>) {
  return chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [getProducts, compareProducts],
    agentLoopStrategy: maxIterations(20),
    lazyToolsConfig: { includeDescription: 'first-sentence' },
  })
}
```

`includeDescription` values:

| Value              | Catalog entry                                                                            | When to use                                                                        |
| ------------------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `'none'` (default) | `compareProducts`                                                                        | Smallest prompt; model discovers by name                                           |
| `'first-sentence'` | `compareProducts — Compare two or more products side by side.`                           | Helps the model decide whether to discover without extra tokens                    |
| `'full'`           | `compareProducts — Compare two or more products side by side. Accepts productIds array.` | Use when descriptions are short or the model needs full context to route correctly |

The post-discovery payload always returns the full description and schema regardless of this setting.

## MCP Tools

`@tanstack/ai-mcp` lets a server-side `chat()` call discover and invoke tools
hosted on any MCP server (Streamable HTTP, SSE, or stdio).

`createMCPClient` tries spec `2026-07-28` first.
If the server does not support that spec, the client uses the 2025 initialize handshake.

**MCP tools and UI resources:** When an MCP tool result carries a `ui://`
resource URI (via `_meta.ui.resourceUri`), TanStack AI surfaces it as a
`UIResourcePart` on the assistant `UIMessage` in the client message list.
`UIResourcePart` is a presentational-only part — it never enters model input.
See the `@tanstack/ai-mcp` skill for the full MCP Apps API
(`createMcpAppCallHandler`, `createMcpAppBridge`, `MCPAppResource`).

### Basic usage — auto-discovery

```typescript
// api/chat/route.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 } = await request.json()

  // 1. Connect to the MCP server.
  const mcp = await createMCPClient({
    transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
  })

  // 2. Discover all tools from the server (returns ServerTool[]).
  const mcpTools = await mcp.tools()

  // 3. Spread them into chat() — they work exactly like hand-written tools.
  // Caller owns the lifecycle — chat() never closes the client. Tools run
  // while the response streams, so close in a middleware terminal hook
  // (a try/finally around the return would close before tools execute).
  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [...mcpTools],
    middleware: [
      {
        name: 'mcp-close',
        onFinish: () => mcp.close(),
        onAbort: () => mcp.close(),
        onError: () => mcp.close(),
      },
    ],
  })
  return toServerSentEventsResponse(stream)
}
```

### Typed path — pass toolDefinition instances

Pass bare `toolDefinition()` instances (no `.server()`) to `client.tools([...])`.
The MCP client supplies a `callTool` proxy as the execute function, while
input/output validation and types come from the definitions' Zod schemas.

```typescript
import { chat, toolDefinition } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
import { z } from 'zod'

const getWeather = toolDefinition({
  name: 'get_weather',
  description: 'Current weather for a city',
  inputSchema: z.object({ city: z.string() }),
  outputSchema: z.object({ temperature: z.number(), conditions: z.string() }),
})

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

// Returns ServerTool[] typed to the definitions' input/output schemas.
// Throws MCPToolNotFoundError if the server does not expose a tool with that name.
const tools = await mcp.tools([getWeather])

const messages = [{ role: 'user' as const, content: 'Weather in Paris?' }]
const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools })
```

### Multiple servers with `createMCPClients`

```typescript
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClients } from '@tanstack/ai-mcp'

// Each key becomes the default prefix for that server's tools.
await using pool = await createMCPClients({
  github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } },
  linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } },
})

// Tools auto-prefixed: 'github_search_repos', 'linear_create_issue', etc.
const tools = await pool.tools()

const messages = [{ role: 'user' as const, content: 'Open an issue for #42' }]
const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools })
```

Use `pool.clients.<name>` for typed per-server access (resources, prompts, typed
`tools([defs])` overload).

### `ToolExecutionContext.abortSignal` — cancelling long-running tools

Every server tool's execute function now receives `abortSignal` in its context.
When the chat run aborts (e.g. the client disconnects or calls the run's
`abortController`), the signal fires and any in-flight `callTool` call is
cancelled automatically.

You can also forward it from your own server tools:

```typescript
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

const fetchReportDef = toolDefinition({
  name: 'fetch_report',
  description: 'Fetch a report from the slow reporting API',
  inputSchema: z.object({ reportId: z.string() }),
})

const fetchReport = fetchReportDef.server(async ({ reportId }, ctx) => {
  // Forward to fetch, a DB query, or an MCP callTool call.
  const response = await fetch(`https://slow.api/reports/${reportId}`, {
    signal: ctx?.abortSignal,
  })
  return response.json()
})
```

MCP tools wire this automatically — `makeMcpExecute` passes `ctx?.abortSignal`
as the `signal` option to `client.callTool(...)`, so MCP server calls cancel
with the chat run without any extra code.

### stdio transport (Node-only)

```typescript
import { createMCPClient } from '@tanstack/ai-mcp'
import { stdioTransport } from '@tanstack/ai-mcp/stdio'

const mcp = await createMCPClient({
  transport: stdioTransport({ command: 'npx', args: ['-y', 'my-mcp-server'] }),
})
```

Import `stdioTransport` from the `/stdio` subpath only — it contains Node.js
`child_process` imports and must not be bundled for edge runtimes.

### `chat({ mcp })` — discovery + lifecycle in one prop

Instead of manually calling `client.tools()` and managing `close()`, pass an
`mcp` object and let `chat()` handle discovery and lifecycle.

```typescript
// Prop shape (ChatMCPOptions):
// mcp: {
//   clients: Array<MCPClient | MCPClients>,
//   connection?: 'close' | 'keep-alive',  // default: 'close'
//   lazyTools?: boolean,
//   onDiscoveryError?: (error: unknown, source) => void,
// }
```

- At run start, `chat()` calls `.tools()` on every entry in `clients` and merges
  the results — identical to spreading `await client.tools()` into `tools: [...]`.
- `lazyTools: true` is forwarded to `tools({ lazy: true })`.
- `onDiscoveryError`: throw to fail-fast; return to skip that source.
- `connection: 'close'` (default) closes each client when the run ends (after
  the agent loop completes and the stream is drained). With `'keep-alive'`,
  `chat()` never closes the clients — the caller owns their lifecycle (keep
  connections warm across requests).

**When to use `mcp` vs. the tools spread:**

| Approach                                                | Use when                                                                          |
| ------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `chat({ mcp: { clients: [...] } })`                     | Convenience: discovery + lifecycle in one place; untyped tool args are acceptable |
| `tools: [...await client.tools([toolDefinition(...)])]` | Fully-typed tool args/results via Zod schemas                                     |

**Example:**

```typescript
// api/chat/route.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 } = await request.json()

  const mcpClient = await createMCPClient({
    transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
  })

  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    mcp: {
      clients: [mcpClient],
      connection: 'keep-alive',
      onDiscoveryError: (err) => {
        console.warn('MCP discovery failed, skipping source:', err)
        // returning (not throwing) skips this source and continues
      },
    },
  })

  return toServerSentEventsResponse(stream)
}
```

### Host your own MCP server

Import `createMCPServer` from `@tanstack/ai-mcp/server`.
Pass tools from `toolDefinition().server()`.
Call `server.fetch(request)` in your HTTP route.

```typescript
import { toolDefinition } from '@tanstack/ai'
import { createMCPServer } from '@tanstack/ai-mcp/server'
import { z } from 'zod'

const getWeather = toolDefinition({
  name: 'get_weather',
  description: 'Current weather for a city',
  inputSchema: z.object({ city: z.string() }),
}).server(async ({ city }) => {
  return { city, temperature: 18, conditions: 'clear' }
})

const server = createMCPServer({
  name: 'weather',
  version: '1.0.0',
  tools: [getWeather],
})

export function POST(request: Request) {
  return server.fetch(request)
}
```

`stdioTransport` from `@tanstack/ai-mcp/stdio` connects your client to a command.
`serveMCPStdio` from `@tanstack/ai-mcp/server/stdio` serves your server on stdin and stdout.
Write logs with `console.error`.
stdout carries only protocol messages.

```typescript
import { toolDefinition } from '@tanstack/ai'
import { createMCPServer } from '@tanstack/ai-mcp/server'
import { serveMCPStdio } from '@tanstack/ai-mcp/server/stdio'
import { z } from 'zod'

const getWeather = toolDefinition({
  name: 'get_weather',
  description: 'Current weather for a city',
  inputSchema: z.object({ city: z.string() }),
}).server(async ({ city }) => {
  return { city, temperature: 18, conditions: 'clear' }
})

const server = createMCPServer({
  name: 'weather',
  version: '1.0.0',
  tools: [getWeather],
})

serveMCPStdio(server)
```

The `@tanstack/ai-mcp` skill shows `ctx.context.requestInput` and `ctx.context.sample`.

### Read an MCP input interrupt

When `chat()` receives an MCP input request, the run outcome is an interrupt.
The stream ends with `RUN_FINISHED`.
The outcome type is `interrupt`.
Read each interrupt whose `reason` is `mcp_input`.
The payload key is `tanstack:interruptPayload`.

`form` means the server asks the user for input.
`sampling` means the server asks for a model result.

```typescript
import { chat, INTERRUPT_PAYLOAD_METADATA_KEY } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'

const client = await createMCPClient({
  transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
})

const stream = chat({
  adapter: openaiText('gpt-5.5'),
  messages: [{ role: 'user', content: 'Weather in Paris?' }],
  tools: await client.tools(),
})

for await (const chunk of stream) {
  if (chunk.type !== 'RUN_FINISHED') continue
  if (chunk.outcome?.type !== 'interrupt') continue

  for (const item of chunk.outcome.interrupts) {
    if (item.reason !== 'mcp_input') continue
    const payload = item.metadata?.[INTERRUPT_PAYLOAD_METADATA_KEY]
    if (typeof payload !== 'object' || payload === null) continue
    if (!('kind' in payload)) continue
    // payload.kind is 'form' or 'sampling'
  }
}
```

## Provider Skills

> **Not to be confused with `@tanstack/ai-code-mode-snippets`**, whose snippets are TypeScript functions your application generates and runs in its own Code Mode sandbox (a local JS isolate). Provider Skills are hosted, provider-managed bundles that the model loads on demand and runs inside the provider's server-side sandbox.

Provider Skills are inert without an execution tool. The execution tool is what activates the sandbox; skills are additional capability bundles that run inside it:

- **Anthropic**: skills require the `code_execution` tool (`@tanstack/ai-anthropic/tools`).
- **OpenAI**: skills live inside the `shell` tool (`@tanstack/ai-openai/tools`) and are Responses API only.

### Anthropic: `codeExecutionTool` with skills

Import from `@tanstack/ai-anthropic/tools`:

```typescript
import { codeExecutionTool } from '@tanstack/ai-anthropic/tools'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'

export async function POST(request: Request) {
  const { messages } = await request.json()
  const stream = chat({
    adapter: anthropicText('claude-sonnet-4-6'),
    messages,
    tools: [
      codeExecutionTool(
        { type: 'code_execution_20250825', name: 'code_execution' },
        {
          skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }],
        },
      ),
    ],
  })
  return toServerSentEventsResponse(stream)
}
```

`AnthropicContainerSkill` shape: `{ type: 'anthropic' | 'custom'; skill_id: string; version?: string }`. Constraints: max 8 skills per request; `skill_id` must be 1–64 characters.

The adapter automatically:

- Lifts the skills into the request's top-level `container.skills` param (the shape Anthropic's API requires).
- Attaches the required beta headers (`code-execution-2025-08-25` plus `skills-2025-10-02` when skills are present). You do not set these manually.

**Deprecation:** Setting skills via `modelOptions.container.skills` is deprecated. Use `codeExecutionTool(config, { skills })` instead — the legacy path bypasses the beta-header wiring.

### OpenAI: `shellTool` with skills (Responses API only)

Import from `@tanstack/ai-openai/tools`:

```typescript
import { shellTool } from '@tanstack/ai-openai/tools'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'

export async function POST(request: Request) {
  const { messages } = await request.json()
  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [
      shellTool({
        environment: {
          type: 'container_auto',
          skills: [
            { type: 'skill_reference', skill_id: 'skill_abc', version: '2' },
          ],
        },
      }),
    ],
  })
  return toServerSentEventsResponse(stream)
}
```

`SkillReference` shape: `{ type: 'skill_reference'; skill_id: string; version?: string }`. `version` is a string — use a positive integer as a string (e.g. `'2'`) or `'latest'`. This is Responses API only; Chat Completions does not support the shell tool.

### Scope

Only hosted/managed-by-id skills (`type: 'anthropic'` / `type: 'custom'` for Anthropic; `type: 'skill_reference'` for OpenAI) are wired. Inline bundles, local-path, and upload-API skill creation are not handled by these factories.

## Common Mistakes

### a. HIGH: Not passing tool definitions to both server and client

Server tools need `chat({ tools })`. Client tools need their definition in
`chat({ tools })` AND their `.client()` in `useChat({ tools: clientTools(...) })`.

Wrong -- tool only on server, client cannot execute:

```tsx group=tool-wiring
import { chat, toolDefinition } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import { clientTools } from '@tanstack/ai-client'
import { z } from 'zod'

const myToolDef = toolDefinition({
  name: 'my_tool',
  description: 'Example client-executed tool',
  inputSchema: z.object({ id: z.string() }),
  outputSchema: z.object({ success: z.boolean() }),
})
const adapter = openaiText('gpt-5.5')
const messages = [{ role: 'user' as const, content: 'Run my tool' }]

// server
chat({ adapter, messages, tools: [myToolDef] })
// client
function ChatServerOnly() {
  useChat({ connection: fetchServerSentEvents('/api/chat') }) // no tools
  return null
}
```

Wrong -- tool only on client, LLM does not know about it:

```tsx group=tool-wiring
// server
chat({ adapter, messages }) // no tools
// client
function ChatClientOnly() {
  useChat({
    connection: fetchServerSentEvents('/api/chat'),
    tools: clientTools(myToolDef.client(() => ({ success: true }))),
  })
  return null
}
```

Correct:

```tsx group=tool-wiring
// server
chat({ adapter, messages, tools: [myToolDef] })
// client
function ChatWired() {
  useChat({
    connection: fetchServerSentEvents('/api/chat'),
    tools: clientTools(
      myToolDef.client((input) => ({ success: input.id !== '' })),
    ),
  })
  return null
}
```

Source: docs/tools/tools.md

## Cross-References

- See also: ai-core/chat-experience/SKILL.md -- Tools are used within chat
- See also: `@tanstack/ai-code-mode` package skills -- Code Mode is an alternative to tools for complex multi-step operations

Attribution

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

Comments

Loading comments…

Newsletter

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