Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/mcp-task-execution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/ai-mcp': minor
---

Support MCP tools that require task-based execution. Task-required tools are now discovered and execute through the MCP SDK's experimental task stream, while optional task tools continue to use ordinary tool calls.

Task execution is gated on the server declaring the tasks capability for `tools/call` — a server that lists a task-required tool without it is skipped by auto-discovery (binding one explicitly throws `MCPTaskRequiredToolError`). Aborting a run now sends a best-effort `tasks/cancel` for an in-flight task, and `MCPClient.callTool` accepts an optional `{ signal }`. Tool discovery follows `tools/list` pagination, is refreshed on `tools/list_changed`, and a direct `callTool` no longer hard-depends on `tools/list` (a failing listing falls back to a plain call) nor changes output-schema validation behavior.
6 changes: 4 additions & 2 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@
{
"label": "MCP Server Tools",
"to": "tools/mcp",
"addedAt": "2026-06-05"
"addedAt": "2026-06-05",
"updatedAt": "2026-07-17"
},
{
"label": "Managed MCP with chat()",
Expand All @@ -134,7 +135,8 @@
{
"label": "Manual MCP",
"to": "tools/mcp-manual",
"addedAt": "2026-06-05"
"addedAt": "2026-06-05",
"updatedAt": "2026-07-17"
},
{
"label": "MCP Type Generation",
Expand Down
8 changes: 5 additions & 3 deletions docs/tools/mcp-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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."
description: "Spread fully-typed MCP tools into chat(), inject MCP resources and prompts as content and messages, and stop in-flight MCP tool calls when a run aborts."
keywords:
- tanstack ai
- mcp
Expand Down Expand Up @@ -185,7 +185,9 @@ export const Route = createFileRoute('/api/chat')({

## 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.
When the chat run is cancelled (e.g. the user navigates away or an `AbortController` fires), TanStack AI stops waiting for in-flight MCP tool calls. The abort signal from the chat run is threaded through `ToolExecutionContext.abortSignal` into each tool's execute function.

For an ordinary `callTool` request, this aborts the request. For a task-required tool, it stops the local task stream and sends a best-effort `tasks/cancel` for a remote task the MCP server has already created.

```ts
import { chat } from '@tanstack/ai'
Expand All @@ -206,7 +208,7 @@ const stream = chat({
abortController: controller,
})

// Cancel the run and all in-flight MCP tool calls:
// Cancel the run and stop waiting for in-flight MCP tool calls:
controller.abort()
```

Expand Down
21 changes: 13 additions & 8 deletions docs/tools/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,16 +235,21 @@ 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.
> **Task-based tools are supported.** Tools that declare
> `execution.taskSupport: 'required'` automatically run through the MCP SDK's
> experimental `tasks/callToolStream` flow. TanStack AI waits through task
> status updates and returns the terminal result to the model. Tools declaring
> `taskSupport: 'optional'` continue to use ordinary `callTool` execution.
> Task execution needs the server to declare the tasks capability for
> `tools/call`; a server that lists a task-required tool without it is
> skipped by auto-discovery (the tool could never be invoked).
>
> If the chat run aborts, TanStack AI stops waiting for the task and sends a
> best-effort `tasks/cancel` for a remote task the server has already created.

### 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).
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. Task-required tools use the same automatic task execution described in Mode 1.

```ts
import { toolDefinition } from '@tanstack/ai'
Expand Down Expand Up @@ -484,6 +489,6 @@ The Quick Start above hands tools to `chat()` manually via `tools: await mcp.too
| `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 |
| `MCPTaskRequiredToolError` | A task-required tool was bound via `tools([...defs])` or called via `callTool()` but the server does not declare the tasks capability for `tools/call`, so the call could never execute |

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).
33 changes: 33 additions & 0 deletions examples/ts-react-chat/src/lib/mcp-provider-adapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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'

/**
* SERVER-ONLY adapter resolution for the MCP demo routes. Kept separate from
* `mcp-providers.ts` (the client-safe provider metadata) so pages that render
* the provider picker never pull the provider SDKs into the browser bundle —
* `@anthropic-ai/sdk` is in vite's `optimizeDeps.exclude`, and serving its
* CJS dep `standardwebhooks` un-prebundled crashes the page in dev.
*/

/**
* 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-3.5-flash')
case 'groq':
return groqText('llama-3.3-70b-versatile')
case 'openai':
default:
return openaiText('gpt-5.5')
}
}
32 changes: 5 additions & 27 deletions examples/ts-react-chat/src/lib/mcp-providers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
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
Expand All @@ -13,6 +7,10 @@ import { openRouterText } from '@tanstack/ai-openrouter'
*
* Each provider needs its own API key in the environment; the LLM key is
* separate from the (keyless) MCP servers.
*
* CLIENT-SAFE metadata only — the pages that render the provider picker import
* this module, so it must not pull in any provider SDK. Adapter resolution
* (server-only) lives in `mcp-provider-adapters.ts`.
*/
export const MCP_PROVIDERS = [
{
Expand All @@ -36,7 +34,7 @@ export const MCP_PROVIDERS = [
{
value: 'gemini',
label: 'Gemini',
model: 'gemini-2.5-flash',
model: 'gemini-3.5-flash',
envKey: 'GOOGLE_API_KEY',
},
{
Expand All @@ -48,23 +46,3 @@ export const MCP_PROVIDERS = [
] 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')
}
}
42 changes: 42 additions & 0 deletions examples/ts-react-chat/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ 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 ApiSandboxTriageRouteImport } from './routes/api.sandbox-triage'
import { Route as ApiMcpTasksServerRouteImport } from './routes/api.mcp-tasks-server'
import { Route as ApiMcpTasksChatRouteImport } from './routes/api.mcp-tasks-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'
Expand Down Expand Up @@ -198,6 +200,16 @@ const ApiSandboxTriageRoute = ApiSandboxTriageRouteImport.update({
path: '/api/sandbox-triage',
getParentRoute: () => rootRouteImport,
} as any)
const ApiMcpTasksServerRoute = ApiMcpTasksServerRouteImport.update({
id: '/api/mcp-tasks-server',
path: '/api/mcp-tasks-server',
getParentRoute: () => rootRouteImport,
} as any)
const ApiMcpTasksChatRoute = ApiMcpTasksChatRouteImport.update({
id: '/api/mcp-tasks-chat',
path: '/api/mcp-tasks-chat',
getParentRoute: () => rootRouteImport,
} as any)
const ApiMcpStatusRoute = ApiMcpStatusRouteImport.update({
id: '/api/mcp-status',
path: '/api/mcp-status',
Expand Down Expand Up @@ -309,6 +321,8 @@ export interface FileRoutesByFullPath {
'/api/mcp-manual': typeof ApiMcpManualRoute
'/api/mcp-pool': typeof ApiMcpPoolRoute
'/api/mcp-status': typeof ApiMcpStatusRoute
'/api/mcp-tasks-chat': typeof ApiMcpTasksChatRoute
'/api/mcp-tasks-server': typeof ApiMcpTasksServerRoute
'/api/sandbox-triage': typeof ApiSandboxTriageRoute
'/api/structured-chat': typeof ApiStructuredChatRoute
'/api/structured-output': typeof ApiStructuredOutputRoute
Expand Down Expand Up @@ -356,6 +370,8 @@ export interface FileRoutesByTo {
'/api/mcp-manual': typeof ApiMcpManualRoute
'/api/mcp-pool': typeof ApiMcpPoolRoute
'/api/mcp-status': typeof ApiMcpStatusRoute
'/api/mcp-tasks-chat': typeof ApiMcpTasksChatRoute
'/api/mcp-tasks-server': typeof ApiMcpTasksServerRoute
'/api/sandbox-triage': typeof ApiSandboxTriageRoute
'/api/structured-chat': typeof ApiStructuredChatRoute
'/api/structured-output': typeof ApiStructuredOutputRoute
Expand Down Expand Up @@ -404,6 +420,8 @@ export interface FileRoutesById {
'/api/mcp-manual': typeof ApiMcpManualRoute
'/api/mcp-pool': typeof ApiMcpPoolRoute
'/api/mcp-status': typeof ApiMcpStatusRoute
'/api/mcp-tasks-chat': typeof ApiMcpTasksChatRoute
'/api/mcp-tasks-server': typeof ApiMcpTasksServerRoute
'/api/sandbox-triage': typeof ApiSandboxTriageRoute
'/api/structured-chat': typeof ApiStructuredChatRoute
'/api/structured-output': typeof ApiStructuredOutputRoute
Expand Down Expand Up @@ -453,6 +471,8 @@ export interface FileRouteTypes {
| '/api/mcp-manual'
| '/api/mcp-pool'
| '/api/mcp-status'
| '/api/mcp-tasks-chat'
| '/api/mcp-tasks-server'
| '/api/sandbox-triage'
| '/api/structured-chat'
| '/api/structured-output'
Expand Down Expand Up @@ -500,6 +520,8 @@ export interface FileRouteTypes {
| '/api/mcp-manual'
| '/api/mcp-pool'
| '/api/mcp-status'
| '/api/mcp-tasks-chat'
| '/api/mcp-tasks-server'
| '/api/sandbox-triage'
| '/api/structured-chat'
| '/api/structured-output'
Expand Down Expand Up @@ -547,6 +569,8 @@ export interface FileRouteTypes {
| '/api/mcp-manual'
| '/api/mcp-pool'
| '/api/mcp-status'
| '/api/mcp-tasks-chat'
| '/api/mcp-tasks-server'
| '/api/sandbox-triage'
| '/api/structured-chat'
| '/api/structured-output'
Expand Down Expand Up @@ -595,6 +619,8 @@ export interface RootRouteChildren {
ApiMcpManualRoute: typeof ApiMcpManualRoute
ApiMcpPoolRoute: typeof ApiMcpPoolRoute
ApiMcpStatusRoute: typeof ApiMcpStatusRoute
ApiMcpTasksChatRoute: typeof ApiMcpTasksChatRoute
ApiMcpTasksServerRoute: typeof ApiMcpTasksServerRoute
ApiSandboxTriageRoute: typeof ApiSandboxTriageRoute
ApiStructuredChatRoute: typeof ApiStructuredChatRoute
ApiStructuredOutputRoute: typeof ApiStructuredOutputRoute
Expand Down Expand Up @@ -816,6 +842,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSandboxTriageRouteImport
parentRoute: typeof rootRouteImport
}
'/api/mcp-tasks-server': {
id: '/api/mcp-tasks-server'
path: '/api/mcp-tasks-server'
fullPath: '/api/mcp-tasks-server'
preLoaderRoute: typeof ApiMcpTasksServerRouteImport
parentRoute: typeof rootRouteImport
}
'/api/mcp-tasks-chat': {
id: '/api/mcp-tasks-chat'
path: '/api/mcp-tasks-chat'
fullPath: '/api/mcp-tasks-chat'
preLoaderRoute: typeof ApiMcpTasksChatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/mcp-status': {
id: '/api/mcp-status'
path: '/api/mcp-status'
Expand Down Expand Up @@ -963,6 +1003,8 @@ const rootRouteChildren: RootRouteChildren = {
ApiMcpManualRoute: ApiMcpManualRoute,
ApiMcpPoolRoute: ApiMcpPoolRoute,
ApiMcpStatusRoute: ApiMcpStatusRoute,
ApiMcpTasksChatRoute: ApiMcpTasksChatRoute,
ApiMcpTasksServerRoute: ApiMcpTasksServerRoute,
ApiSandboxTriageRoute: ApiSandboxTriageRoute,
ApiStructuredChatRoute: ApiStructuredChatRoute,
ApiStructuredOutputRoute: ApiStructuredOutputRoute,
Expand Down
2 changes: 1 addition & 1 deletion examples/ts-react-chat/src/routes/api.mcp-apps-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from '@tanstack/ai'
import { createMCPClient } from '@tanstack/ai-mcp'
import type { MCPClient } from '@tanstack/ai-mcp'
import { resolveTextAdapter } from '@/lib/mcp-providers'
import { resolveTextAdapter } from '@/lib/mcp-provider-adapters'
import {
SHOP_PREFIX,
SHOP_SERVER_PATH,
Expand Down
8 changes: 6 additions & 2 deletions examples/ts-react-chat/src/routes/api.mcp-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
toServerSentEventsResponse,
} from '@tanstack/ai'
import { createMCPClient } from '@tanstack/ai-mcp'
import { resolveTextAdapter } from '@/lib/mcp-providers'
import { resolveTextAdapter } from '@/lib/mcp-provider-adapters'
import { everythingTransport, memoryTransport } from '@/lib/mcp-servers'

export const Route = createFileRoute('/api/mcp-chat')({
Expand All @@ -43,6 +43,7 @@ export const Route = createFileRoute('/api/mcp-chat')({
)
}

let clients
try {
// Connect two keyless MCP servers in parallel.
// Prefixes disambiguate tools if both servers expose same-named tools.
Expand Down Expand Up @@ -73,7 +74,7 @@ export const Route = createFileRoute('/api/mcp-chat')({
)
throw rejected.reason
}
const clients = settled.flatMap((r) =>
clients = settled.flatMap((r) =>
r.status === 'fulfilled' ? [r.value] : [],
)

Expand All @@ -95,6 +96,9 @@ export const Route = createFileRoute('/api/mcp-chat')({

return toServerSentEventsResponse(stream, { abortController })
} catch (error: any) {
// chat() only owns the clients once the stream is consumed — if
// setup throws before the response is returned, close them here.
if (clients) await Promise.allSettled(clients.map((c) => c.close()))
console.error('[api.mcp-chat] Error:', {
message: error?.message,
name: error?.name,
Expand Down
2 changes: 1 addition & 1 deletion examples/ts-react-chat/src/routes/api.mcp-manual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} 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 { resolveTextAdapter } from '@/lib/mcp-provider-adapters'
import { everythingTransport } from '@/lib/mcp-servers'

/**
Expand Down
8 changes: 6 additions & 2 deletions examples/ts-react-chat/src/routes/api.mcp-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
maxIterations,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { resolveTextAdapter } from '@/lib/mcp-providers'
import { resolveTextAdapter } from '@/lib/mcp-provider-adapters'
import { createMCPClients } from '@tanstack/ai-mcp'
import {
everythingTransport,
Expand Down Expand Up @@ -49,13 +49,14 @@ export const Route = createFileRoute('/api/mcp-pool')({
)
}

let pool
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({
pool = await createMCPClients({
everything: { transport: everythingTransport() },
memory: { transport: memoryTransport() },
thinking: { transport: sequentialThinkingTransport() },
Expand All @@ -78,6 +79,9 @@ export const Route = createFileRoute('/api/mcp-pool')({

return toServerSentEventsResponse(stream, { abortController })
} catch (error: any) {
// chat() only owns the pool once the stream is consumed — if
// setup throws before the response is returned, close it here.
if (pool) await pool.close().catch(() => {})
console.error('[api.mcp-pool] Error:', {
message: error?.message,
name: error?.name,
Expand Down
Loading