diff --git a/.changeset/mcp-task-execution.md b/.changeset/mcp-task-execution.md new file mode 100644 index 000000000..e850e8a12 --- /dev/null +++ b/.changeset/mcp-task-execution.md @@ -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. diff --git a/docs/config.json b/docs/config.json index 7debc1ba4..26f92d87f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -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()", @@ -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", diff --git a/docs/tools/mcp-manual.md b/docs/tools/mcp-manual.md index 29d7d7e96..29f9c0ece 100644 --- a/docs/tools/mcp-manual.md +++ b/docs/tools/mcp-manual.md @@ -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 @@ -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' @@ -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() ``` diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index ad7cf1ffb..c6c2a80e4 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -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' @@ -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). diff --git a/examples/ts-react-chat/src/lib/mcp-provider-adapters.ts b/examples/ts-react-chat/src/lib/mcp-provider-adapters.ts new file mode 100644 index 000000000..4174ee6b4 --- /dev/null +++ b/examples/ts-react-chat/src/lib/mcp-provider-adapters.ts @@ -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') + } +} diff --git a/examples/ts-react-chat/src/lib/mcp-providers.ts b/examples/ts-react-chat/src/lib/mcp-providers.ts index 82086c6e6..34aa9391c 100644 --- a/examples/ts-react-chat/src/lib/mcp-providers.ts +++ b/examples/ts-react-chat/src/lib/mcp-providers.ts @@ -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 @@ -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 = [ { @@ -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', }, { @@ -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') - } -} diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index b223bce09..6c0c09634 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -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' @@ -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', @@ -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 @@ -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 @@ -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 @@ -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' @@ -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' @@ -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' @@ -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 @@ -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' @@ -963,6 +1003,8 @@ const rootRouteChildren: RootRouteChildren = { ApiMcpManualRoute: ApiMcpManualRoute, ApiMcpPoolRoute: ApiMcpPoolRoute, ApiMcpStatusRoute: ApiMcpStatusRoute, + ApiMcpTasksChatRoute: ApiMcpTasksChatRoute, + ApiMcpTasksServerRoute: ApiMcpTasksServerRoute, ApiSandboxTriageRoute: ApiSandboxTriageRoute, ApiStructuredChatRoute: ApiStructuredChatRoute, ApiStructuredOutputRoute: ApiStructuredOutputRoute, diff --git a/examples/ts-react-chat/src/routes/api.mcp-apps-chat.ts b/examples/ts-react-chat/src/routes/api.mcp-apps-chat.ts index e543fe355..83d27aa7f 100644 --- a/examples/ts-react-chat/src/routes/api.mcp-apps-chat.ts +++ b/examples/ts-react-chat/src/routes/api.mcp-apps-chat.ts @@ -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, diff --git a/examples/ts-react-chat/src/routes/api.mcp-chat.ts b/examples/ts-react-chat/src/routes/api.mcp-chat.ts index 952378bcd..0bfb88c86 100644 --- a/examples/ts-react-chat/src/routes/api.mcp-chat.ts +++ b/examples/ts-react-chat/src/routes/api.mcp-chat.ts @@ -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')({ @@ -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. @@ -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] : [], ) @@ -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, diff --git a/examples/ts-react-chat/src/routes/api.mcp-manual.ts b/examples/ts-react-chat/src/routes/api.mcp-manual.ts index 179671d04..ff187c93f 100644 --- a/examples/ts-react-chat/src/routes/api.mcp-manual.ts +++ b/examples/ts-react-chat/src/routes/api.mcp-manual.ts @@ -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' /** diff --git a/examples/ts-react-chat/src/routes/api.mcp-pool.ts b/examples/ts-react-chat/src/routes/api.mcp-pool.ts index 4b847b89f..65a80c4f1 100644 --- a/examples/ts-react-chat/src/routes/api.mcp-pool.ts +++ b/examples/ts-react-chat/src/routes/api.mcp-pool.ts @@ -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, @@ -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() }, @@ -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, diff --git a/examples/ts-react-chat/src/routes/api.mcp-tasks-chat.ts b/examples/ts-react-chat/src/routes/api.mcp-tasks-chat.ts new file mode 100644 index 000000000..8c13da921 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.mcp-tasks-chat.ts @@ -0,0 +1,94 @@ +/** + * /api/mcp-tasks-chat — task-required MCP tool execution via chat({ mcp }). + * + * Connects to the in-process Streamable HTTP MCP server at + * `/api/mcp-tasks-server` (same origin), whose only tool declares + * `execution.taskSupport: 'required'`. `@tanstack/ai-mcp` detects this during + * discovery and routes the call through the SDK's experimental task flow + * (create task → poll status → fetch result) instead of ordinary `tools/call`. + * + * From chat()'s perspective nothing changes — the tool call resolves like any + * other, just ~4 seconds later while the UI shows the pending tool call. + */ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { createMCPClient } from '@tanstack/ai-mcp' +import { resolveTextAdapter } from '@/lib/mcp-provider-adapters' + +export const Route = createFileRoute('/api/mcp-tasks-chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const requestSignal = request.signal + + if (requestSignal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + let client + try { + // The task server is hosted by this same dev server — derive its + // URL from the incoming request so the demo works on any port. + const origin = new URL(request.url).origin + client = await createMCPClient({ + transport: { type: 'http', url: `${origin}/api/mcp-tasks-server` }, + }) + + // chat() discovers the task-required tool and closes the client + // when the stream drains — connection: 'close' (the default; shown + // explicitly). The model is encoded in the adapter. + const stream = chat({ + adapter: resolveTextAdapter(params.forwardedProps.provider), + messages: params.messages, + mcp: { + clients: [client], + connection: 'close', + }, + agentLoopStrategy: maxIterations(20), + threadId: params.threadId, + runId: params.runId, + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error: any) { + // chat() only owns the client once the stream is consumed — if + // setup throws before the response is returned, close it here. + if (client) await client.close().catch(() => {}) + console.error('[api.mcp-tasks-chat] Error:', { + message: error?.message, + name: error?.name, + stack: error?.stack, + }) + if (error.name === 'AbortError' || abortController.signal.aborted) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ error: error.message || 'An error occurred' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.mcp-tasks-server.ts b/examples/ts-react-chat/src/routes/api.mcp-tasks-server.ts new file mode 100644 index 000000000..5e8ca9bc5 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.mcp-tasks-server.ts @@ -0,0 +1,152 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + InMemoryTaskMessageQueue, + InMemoryTaskStore, +} from '@modelcontextprotocol/sdk/experimental/tasks' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' + +/** + * In-process MCP server for the **Tasks** mode of the MCP demo — a real MCP + * server (via `@modelcontextprotocol/sdk`'s `McpServer`) speaking Streamable + * HTTP, exposing a single tool with `execution.taskSupport: 'required'`. + * + * Task-required tools cannot run through ordinary `tools/call`; the client + * must create a task, poll its status, and fetch the result. `@tanstack/ai-mcp` + * does this automatically via the SDK's experimental `callToolStream` flow — + * the companion `/api/mcp-tasks-chat` route connects here and runs the tool + * inside a real `chat()` agent loop. + * + * The appraisal deliberately takes ~4 seconds so a human can watch the task + * lifecycle: the chat UI shows a pending tool call while the client polls + * (every 500ms), then the distinctive appraisal total arrives. Lifecycle + * events are also console.logged (created → completed → result fetched). + * + * Stateless mode (no `sessionIdGenerator`) creates a fresh `McpServer` and + * transport per request; the globalThis-anchored task store persists task + * state across the follow-up polling requests. + */ +// Anchored on globalThis, NOT module scope: each `tasks/get` poll from the +// client is its own HTTP request, and if Vite invalidates this module between +// polls (HMR after an edit, a route-tree regeneration) a module-scoped store +// would be re-created empty and in-flight polls would fail with "Task not +// found". globalThis persists for the life of the dev-server process. +interface TasksDemoState { + taskStore: InMemoryTaskStore + taskMessageQueue: InMemoryTaskMessageQueue +} +const globalState = globalThis as { __mcpTasksDemo?: TasksDemoState } +const { taskStore, taskMessageQueue } = (globalState.__mcpTasksDemo ??= { + taskStore: new InMemoryTaskStore(), + taskMessageQueue: new InMemoryTaskMessageQueue(), +}) + +const APPRAISAL_DURATION_MS = 4_000 +const TASK_POLL_INTERVAL_MS = 500 +const PRICE_PER_GUITAR = 1_400 + +function createTasksMcpServer(): McpServer { + const server = new McpServer( + { + name: 'guitar-appraisal-mcp', + version: '0.0.1', + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } }, + taskStore, + taskMessageQueue, + defaultTaskPollInterval: TASK_POLL_INTERVAL_MS, + }, + ) + + server.experimental.tasks.registerToolTask( + 'appraise_guitar_collection', + { + description: + 'Appraise a collection of guitars by their names/ids. Long-running: requires task-based execution (~4 seconds).', + inputSchema: { ids: z.array(z.string()) }, + execution: { taskSupport: 'required' }, + }, + { + async createTask({ ids }, { taskStore: store, taskRequestedTtl }) { + const task = await store.createTask({ + // ai-mcp doesn't request a TTL (undefined → never expires), and the + // store outlives requests — expire finished demo tasks after a minute + // so repeated manual runs don't accumulate for the dev-server lifetime. + ttl: taskRequestedTtl ?? 60_000, + pollInterval: TASK_POLL_INTERVAL_MS, + }) + console.log( + `[mcp-tasks] created task ${task.taskId} — appraising ${ids.length} guitar(s), completes in ${APPRAISAL_DURATION_MS}ms`, + ) + // Complete the task in the background; the client keeps polling + // getTask until the status turns terminal. If the user aborts the + // chat mid-task, this timer still fires and the stored result is + // simply never fetched (documented ai-mcp abort semantics). + setTimeout(() => { + const total = ids.length * PRICE_PER_GUITAR + store + .storeTaskResult(task.taskId, 'completed', { + content: [ + { + type: 'text', + text: `Appraisal complete: ${ids.join(', ')} — $${PRICE_PER_GUITAR} each, $${total} total.`, + }, + ], + }) + .then(() => + console.log(`[mcp-tasks] completed task ${task.taskId}`), + ) + .catch((error) => + console.error( + `[mcp-tasks] failed to complete task ${task.taskId}:`, + error, + ), + ) + }, APPRAISAL_DURATION_MS) + return { task } + }, + async getTask(_args, { taskId, taskStore: store }) { + return store.getTask(taskId) + }, + async getTaskResult(_args, { taskId, taskStore: store }) { + const result = CallToolResultSchema.parse( + await store.getTaskResult(taskId), + ) + console.log(`[mcp-tasks] fetched result for task ${taskId}`) + return result + }, + }, + ) + + return server +} + +async function handleMcpRequest(request: Request): Promise { + const server = createTasksMcpServer() + const transport = new WebStandardStreamableHTTPServerTransport({ + // Stateless mode — no session id is generated or validated. A fresh + // server+transport pair handles this single request and is then GC'd. + sessionIdGenerator: undefined, + }) + + // The McpServer assumes ownership of the transport and tears down its own + // per-request streams when the response stream completes; in stateless mode + // we deliberately do NOT close the transport here, since doing so before the + // SSE body drains would abort the in-flight response. + await server.connect(transport) + + return transport.handleRequest(request) +} + +export const Route = createFileRoute('/api/mcp-tasks-server')({ + server: { + handlers: { + POST: ({ request }) => handleMcpRequest(request), + GET: ({ request }) => handleMcpRequest(request), + DELETE: ({ request }) => handleMcpRequest(request), + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/mcp-demo.tsx b/examples/ts-react-chat/src/routes/mcp-demo.tsx index 2bc99b5c6..885e95fb1 100644 --- a/examples/ts-react-chat/src/routes/mcp-demo.tsx +++ b/examples/ts-react-chat/src/routes/mcp-demo.tsx @@ -11,7 +11,7 @@ import { ThinkingPart } from '@tanstack/ai-react-ui' import type { UIMessage } from '@tanstack/ai-react' import { MCP_PROVIDERS, type McpProvider } from '@/lib/mcp-providers' -type McpMode = 'manual' | 'chat' | 'pool' +type McpMode = 'manual' | 'chat' | 'pool' | 'tasks' const MODES: Array<{ value: McpMode @@ -40,6 +40,13 @@ const MODES: Array<{ description: 'createMCPClients() spins up a 3-server pool with auto-prefixed tool names.', }, + { + value: 'tasks', + label: 'Tasks', + endpoint: '/api/mcp-tasks-chat', + description: + 'Task-required tool (execution.taskSupport: "required") runs via the MCP task flow — the appraisal stays pending ~4s while the client polls, then the total arrives. Try: "Appraise my guitar collection: strat, tele, jazzmaster".', + }, ] type ToolCallPart = Extract @@ -332,11 +339,13 @@ function McpDemoPage() { {/* Header / mode + provider selectors */}

- These chat against keyless MCP reference servers (server-everything / - -memory / -sequential-thinking) spawned via stdio. The MCP servers - need no keys — but the selected LLM provider does (set{' '} + Manual / chat({'{mcp}'}) / Pool chat against keyless MCP reference + servers (server-everything / -memory / -sequential-thinking) spawned + via stdio; Tasks talks to an in-process HTTP MCP server hosting a + task-required tool. None of the MCP servers need keys — but the + selected LLM provider does (set{' '} {selectedProvider.envKey} in - your environment). First run downloads the servers via npx. + your environment). First run downloads the stdio servers via npx.

diff --git a/packages/ai-mcp/README.md b/packages/ai-mcp/README.md index 946b1867b..e14028349 100644 --- a/packages/ai-mcp/README.md +++ b/packages/ai-mcp/README.md @@ -9,6 +9,7 @@ Discover and run MCP server tools, resources, and prompts inside any TanStack AI - `createMCPClient({ transport })` — connect to a single MCP server (Streamable HTTP, SSE, or stdio) - `createMCPClients({ ... })` — connect to many servers at once with auto-prefix collision avoidance - Auto-discovery (`client.tools()`) or explicit typed binding (`client.tools([toolDefinition(...)])`) +- Automatic execution of MCP tools that require the experimental tasks flow - `@tanstack/ai-mcp/stdio` subpath — Node-only stdio transport, isolated so edge bundles stay clean - Bundled `tanstack-ai-mcp generate` CLI — introspects live servers and emits TypeScript types for `createMCPClient()` - `[Symbol.asyncDispose]` support — use `await using` for automatic cleanup diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index 3b02edb9a..697ea4af8 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -393,9 +393,11 @@ await using pool = await createMCPClients({ ## Abort signal — cancelling in-flight MCP calls -MCP tool calls are automatically cancelled when the chat run's `AbortController` -fires (e.g. client disconnect, server abort). The `abortSignal` is threaded -through `ToolExecutionContext` into every `callTool` call with no extra code. +TanStack AI stops waiting for MCP tool calls when the chat run's +`AbortController` fires (e.g. client disconnect, server abort). The +`abortSignal` is threaded through `ToolExecutionContext` into every tool call +with no extra code. For a task-required tool, aborting stops the local task +stream but does not cancel a remote task the MCP server has already created. You can also read it in a hand-written server tool that wraps an MCP call: @@ -685,11 +687,9 @@ and do NOT appear in the library's runtime dependency graph. methods after `close()`. - `MCPToolNotFoundError` — thrown from `client.tools([defs])` when a definition's `name` is not exposed by the server. -- `MCPTaskRequiredToolError` — thrown from `client.tools([defs])` when the named - tool declares `execution.taskSupport: 'required'` (experimental MCP tasks). - Such tools only run via the SDK's `tasks/callToolStream` flow, which - `@tanstack/ai-mcp` does not support yet; they are silently excluded from - `tools()` auto-discovery for the same reason. +- `MCPTaskRequiredToolError` — deprecated compatibility export. Task-required + tools now run automatically through the SDK's experimental + `tasks/callToolStream` flow, so `client.tools()` no longer throws this error. - `DuplicateToolNameError` — thrown by a single pool's own `tools()` when two tools within that pool share the same name (same server or pool clients with no prefix). Exported from `@tanstack/ai-mcp`. diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index bc8345ccb..95fdf900c 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -1,11 +1,21 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { + ListToolsResultSchema, + ToolListChangedNotificationSchema, +} from '@modelcontextprotocol/sdk/types.js' import { DuplicateToolNameError, MCPConnectionError, MCPTaskRequiredToolError, MCPToolNotFoundError, } from './errors' -import { makeMcpExecute, requiresTaskExecution, toServerTools } from './tools' +import { + callMcpTool, + makeMcpExecute, + requiresTaskExecution, + serverSupportsTaskCalls, + toServerTools, +} from './tools' import { isTransportInstance, resolveTransport } from './transport' import type { TransportConfig } from './transport' import type { @@ -20,6 +30,7 @@ import type { import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import type { GetPromptResult, + Tool as McpToolDef, Prompt, ReadResourceResult, Resource, @@ -57,9 +68,17 @@ export interface MCPClient< name: string, args?: Record, ) => Promise + /** + * Call a tool directly and return its raw MCP result. Tools declaring + * `execution.taskSupport: 'required'` automatically use task execution when + * the server declares the tasks capability for tools/call. Pass + * `options.signal` to abort — an in-flight task is best-effort cancelled on + * the server. + */ callTool: ( name: string, args?: Record, + options?: { signal?: AbortSignal }, ) => Promise>> /** * The ORIGINAL connection descriptor this client was created from — the @@ -87,6 +106,7 @@ class MCPClientImpl< capabilities: TServer['capabilities'] = {} readonly #client: Client #closed = false + #toolDefinitions?: Map private readonly prefix?: string // The ORIGINAL serializable transport config (undefined for clients built // from a ready-made Transport instance, which is single-use / not reconnectable). @@ -112,6 +132,14 @@ class MCPClientImpl< async connect(transport: Transport): Promise { try { + // A tools/list_changed notification invalidates the cached definitions + // so the next callTool re-discovers each tool's execution mode. + this.#client.setNotificationHandler( + ToolListChangedNotificationSchema, + () => { + this.#toolDefinitions = undefined + }, + ) await this.#client.connect(transport) this.capabilities = this.#client.getServerCapabilities() ?? {} } catch (err) { @@ -119,6 +147,31 @@ class MCPClientImpl< } } + /** + * Fetch every page of tools/list and refresh the definition cache. + * + * `raw: true` bypasses the SDK's `listTools()` wrapper so the SDK's own + * metadata caches (output-schema validators, task flags) are not armed — + * `callTool`'s lazy lookup must not switch a previously validation-free + * direct call over to strict structured-content validation. + */ + async #listTools(options?: { raw?: boolean }): Promise> { + const defs: Array = [] + let cursor: string | undefined + do { + const page = options?.raw + ? await this.#client.request( + { method: 'tools/list', ...(cursor ? { params: { cursor } } : {}) }, + ListToolsResultSchema, + ) + : await this.#client.listTools(cursor ? { cursor } : undefined) + defs.push(...page.tools) + cursor = page.nextCursor + } while (cursor) + this.#toolDefinitions = new Map(defs.map((def) => [def.name, def])) + return defs + } + async tools( defsOrOptions?: ReadonlyArray | ToolsOptions, maybeOptions: ToolsOptions = {}, @@ -135,17 +188,26 @@ class MCPClientImpl< if (isDefs) { // Explicit path: bind each TanStack toolDefinition to the server by name. const available = new Map( - (await this.#client.listTools()).tools.map((t) => [t.name, t]), + (await this.#listTools()).map((tool) => [tool.name, tool]), ) tools = (defsOrOptions as ReadonlyArray).map((def) => { const serverTool = available.get(def.name) if (!serverTool) throw new MCPToolNotFoundError(def.name) - // Explicitly binding a task-required tool is an error (it would fail - // on every callTool with -32600) — unlike discovery, which skips them. - if (requiresTaskExecution(serverTool)) + // A task-required tool on a server without the tasks capability for + // tools/call cannot be invoked (every call fails) — refuse the binding. + if ( + requiresTaskExecution(serverTool) && + !serverSupportsTaskCalls(this.#client) + ) { throw new MCPTaskRequiredToolError(def.name) + } const tool = def.server( - makeMcpExecute(this.#client, def.name, Boolean(def.outputSchema)), + makeMcpExecute( + this.#client, + def.name, + Boolean(def.outputSchema), + requiresTaskExecution(serverTool), + ), ) as ServerTool if (this.prefix) tool.name = `${this.prefix}_${def.name}` if (options.lazy) tool.lazy = true @@ -165,7 +227,7 @@ class MCPClientImpl< }) } else { // Auto-discovery path. - const defs = (await this.#client.listTools()).tools + const defs = await this.#listTools() tools = toServerTools(this.#client, defs, { prefix: this.prefix, lazy: options.lazy, @@ -212,9 +274,36 @@ class MCPClientImpl< async callTool( name: string, args?: Record, + options?: { signal?: AbortSignal }, ): Promise>> { if (this.#closed) throw new MCPConnectionError('MCP client is closed') - return this.#client.callTool({ name, arguments: args ?? {} }) + if (!this.#toolDefinitions) { + // Lazy discovery so task-required tools work without a prior tools() + // call. Best-effort: a server whose tools/list fails (or omits the + // tool) still gets the plain tools/call it would have received before + // task support existed. Raw fetch — see #listTools. + try { + await this.#listTools({ raw: true }) + } catch { + // fall through to a plain tools/call + } + } + const definition = this.#toolDefinitions?.get(name) + const taskRequired = + definition !== undefined && requiresTaskExecution(definition) + // A known task-required tool on a server without the tasks capability can + // never execute — fail with the clear local error rather than the server's + // opaque -32600 (mirrors the tools([...defs]) binding guard). + if (taskRequired && !serverSupportsTaskCalls(this.#client)) { + throw new MCPTaskRequiredToolError(name) + } + return callMcpTool( + this.#client, + name, + args ?? {}, + taskRequired, + options?.signal, + ) } async close(): Promise { diff --git a/packages/ai-mcp/src/errors.ts b/packages/ai-mcp/src/errors.ts index 467fc4edc..83bd0b18b 100644 --- a/packages/ai-mcp/src/errors.ts +++ b/packages/ai-mcp/src/errors.ts @@ -18,14 +18,17 @@ export class DuplicateToolNameError extends Error { } } +/** + * Thrown when a task-required tool is explicitly bound via `mcp.tools([...])` + * or called via `callTool()` but the server does not declare the tasks + * capability for tools/call, so the call could never execute. + * (Auto-discovery silently skips such tools.) + */ export class MCPTaskRequiredToolError extends Error { constructor(public readonly toolName: string) { super( - `MCP tool "${toolName}" declares \`execution.taskSupport: 'required'\` — it can ` + - `only be invoked via the MCP SDK's experimental task-based execution ` + - `(client.experimental.tasks.callToolStream()), which @tanstack/ai-mcp does not ` + - `support yet. Task-required tools are excluded from tools() auto-discovery; ` + - `binding one explicitly via tools([toolDefinition(...)]) is an error.`, + `MCP tool "${toolName}" requires task-based execution, but the server ` + + `does not declare the tasks capability for tools/call`, ) this.name = 'MCPTaskRequiredToolError' } diff --git a/packages/ai-mcp/src/tools.ts b/packages/ai-mcp/src/tools.ts index c311f80af..f7fbf2b96 100644 --- a/packages/ai-mcp/src/tools.ts +++ b/packages/ai-mcp/src/tools.ts @@ -1,3 +1,4 @@ +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { Tool as McpToolDef } from '@modelcontextprotocol/sdk/types.js' import type { ContentPart, ServerTool } from '@tanstack/ai' @@ -50,8 +51,57 @@ export function mcpContentToTanstack( } /** - * Build the execute body that proxies a TanStack tool call to an MCP server's - * `callTool`. Shared by auto-discovery and the definition path. + * Call an MCP tool through the execution mode declared by its definition. + * Task-required tools use the SDK's experimental stream and are drained to the + * terminal result. Aborting stops this client from waiting and best-effort + * cancels (`tasks/cancel`) a remote task the server has already created. + */ +export async function callMcpTool( + client: Client, + mcpName: string, + args: Record, + taskRequired: boolean, + signal?: AbortSignal, +): Promise>> { + signal?.throwIfAborted() + if (!taskRequired) { + return client.callTool( + { name: mcpName, arguments: args }, + CallToolResultSchema, + { signal }, + ) + } + + // `task` is passed explicitly: the SDK's auto-configuration only engages + // when its own metadata cache saw this tool in a prior listTools() on this + // Client instance, which callers of this function cannot rely on. + const stream = client.experimental.tasks.callToolStream( + { name: mcpName, arguments: args }, + CallToolResultSchema, + { signal, task: {} }, + ) + let taskId: string | undefined + for await (const message of stream) { + if (message.type === 'taskCreated') taskId = message.task.taskId + if (message.type === 'result') return message.result + if (message.type === 'error') { + // On abort the SDK merely stops polling; the server-side task keeps + // running until its TTL. Propagate the abort as a best-effort cancel + // (never masking the original error with a cancel failure). + if (signal?.aborted && taskId !== undefined) { + await client.experimental.tasks.cancelTask(taskId).catch(() => {}) + } + throw message.error + } + } + throw new Error( + `MCP task-required tool "${mcpName}" ended without a result or error`, + ) +} + +/** + * Build the execute body that proxies a TanStack tool call to an MCP server. + * Shared by auto-discovery and the definition path. * * @param preferStructured when true (i.e. the tool declares an outputSchema), * return `result.structuredContent` if present so the existing output @@ -62,13 +112,15 @@ export function makeMcpExecute( client: Client, mcpName: string, preferStructured: boolean, + taskRequired = false, ) { return async (args: unknown, ctx?: { abortSignal?: AbortSignal }) => { - ctx?.abortSignal?.throwIfAborted() - const result = await client.callTool( - { name: mcpName, arguments: (args ?? {}) as Record }, - undefined, - { signal: ctx?.abortSignal }, + const result = await callMcpTool( + client, + mcpName, + (args ?? {}) as Record, + taskRequired, + ctx?.abortSignal, ) if (result.isError) { const text = Array.isArray(result.content) @@ -95,28 +147,30 @@ export function makeMcpExecute( } } -/** - * A tool with `execution.taskSupport: 'required'` can only run through the - * SDK's experimental task-based execution (`tasks/callToolStream`) — plain - * `callTool` is rejected by the server with -32600. Until task execution is - * supported, such tools must not be offered to the model. - */ +/** A tool that must use the SDK's experimental task-based execution. */ export function requiresTaskExecution(def: McpToolDef): boolean { return def.execution?.taskSupport === 'required' } +/** The server declares task-based execution support for tools/call. */ +export function serverSupportsTaskCalls(client: Client): boolean { + return Boolean(client.getServerCapabilities()?.tasks?.requests?.tools?.call) +} + /** - * Auto-discovery path: turn raw MCP tool defs into ServerTools (args typed - * `unknown`). Task-required tools are excluded — they cannot be invoked via - * plain `callTool` (see {@link requiresTaskExecution}). + * Auto-discovery path: turn raw MCP tool defs into ServerTools. Task-required + * tools are excluded when the server does not declare the tasks capability + * for tools/call — every invocation would fail, so they must not be offered + * to the model. */ export function toServerTools( client: Client, defs: Array, options: ConvertOptions, ): Array { + const supportsTasks = serverSupportsTaskCalls(client) return defs - .filter((def) => !requiresTaskExecution(def)) + .filter((def) => !requiresTaskExecution(def) || supportsTasks) .map((def) => { const name = options.prefix ? `${options.prefix}_${def.name}` : def.name const tool: ServerTool = { @@ -136,7 +190,12 @@ export function toServerTools( uiResourceUri: extractUiResourceUri(def), }, }, - execute: makeMcpExecute(client, def.name, Boolean(def.outputSchema)), + execute: makeMcpExecute( + client, + def.name, + Boolean(def.outputSchema), + requiresTaskExecution(def), + ), } return tool }) diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts index 3ca15b554..75df5b3f9 100644 --- a/packages/ai-mcp/tests/client.test.ts +++ b/packages/ai-mcp/tests/client.test.ts @@ -1,5 +1,5 @@ // packages/ai-mcp/tests/client.test.ts -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createMCPClient, createMCPClientFromTransport } from '../src/client' import { DuplicateToolNameError, @@ -7,7 +7,13 @@ import { MCPTaskRequiredToolError, } from '../src/errors' import { + makeServerWithBrokenToolList, + makeServerWithChangingTools, + makeServerWithLaxOutputSchemaTool, + makeServerWithPaginatedTools, + makeServerWithPendingTaskTool, makeServerWithTaskRequiredTool, + makeServerWithUnsupportedTaskTool, makeServerWithWeatherTool, } from './helpers/in-memory-server' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' @@ -113,15 +119,21 @@ describe('createMCPClient', () => { }) }) - it('excludes task-required tools from auto-discovery', async () => { + it('discovers and executes task-required tools', async () => { const { clientTransport } = await makeServerWithTaskRequiredTool() await using client = await createMCPClientFromTransport(clientTransport) - const names = (await client.tools()).map((t) => t.name) - expect(names).toContain('get_weather') - expect(names).not.toContain('research_task') + const tools = await client.tools() + expect(tools.map((t) => t.name)).toEqual(['get_weather', 'research_task']) + const research = tools.find((tool) => tool.name === 'research_task')! + await expect( + research.execute!( + { query: 'MCP tasks' }, + { toolCallId: 't', emitCustomEvent: () => {} }, + ), + ).resolves.toBe('Research complete: MCP tasks') }) - it('throws MCPTaskRequiredToolError when binding a task-required tool', async () => { + it('binds and executes a task-required tool definition', async () => { const { clientTransport } = await makeServerWithTaskRequiredTool() await using client = await createMCPClientFromTransport(clientTransport) const { toolDefinition } = await import('@tanstack/ai') @@ -131,9 +143,13 @@ describe('createMCPClient', () => { description: 'A long-running tool that requires task-based execution', inputSchema: z.object({ query: z.string() }), }) - await expect(client.tools([researchTask])).rejects.toThrow( - MCPTaskRequiredToolError, - ) + const [tool] = await client.tools([researchTask]) + await expect( + tool!.execute!( + { query: 'typed tasks' }, + { toolCallId: 't', emitCustomEvent: () => {} }, + ), + ).resolves.toBe('Research complete: typed tasks') }) it('wraps connection failures in MCPConnectionError preserving the cause', async () => { @@ -165,6 +181,150 @@ describe('createMCPClient', () => { ).toBe(true) }) + it('callTool executes task-required tools and returns the raw result', async () => { + const { clientTransport } = await makeServerWithTaskRequiredTool() + await using client = await createMCPClientFromTransport(clientTransport) + const result = await client.callTool('research_task', { + query: 'direct tasks', + }) + expect(result.content).toEqual([ + { type: 'text', text: 'Research complete: direct tasks' }, + ]) + }) + + it('tools() follows tools/list pagination across pages', async () => { + const { clientTransport } = await makeServerWithPaginatedTools() + await using client = await createMCPClientFromTransport(clientTransport) + const tools = await client.tools() + expect(tools.map((t) => t.name).sort()).toEqual([ + 'first_page_tool', + 'second_page_tool', + ]) + }) + + it('callTool does not re-list for a name absent from the cached list', async () => { + const { clientTransport, getListRequests } = + await makeServerWithPaginatedTools() + await using client = await createMCPClientFromTransport(clientTransport) + await client.tools() + const listed = getListRequests() + const result = await client.callTool('not_listed') + expect(result.content).toEqual([ + { type: 'text', text: 'called not_listed' }, + ]) + await client.callTool('not_listed') + expect(getListRequests()).toBe(listed) + }) + + it('callTool falls back to a plain call when tools/list fails', async () => { + const { clientTransport } = await makeServerWithBrokenToolList() + await using client = await createMCPClientFromTransport(clientTransport) + const result = await client.callTool('anything') + expect(result.content).toEqual([{ type: 'text', text: 'called anything' }]) + }) + + it('direct callTool without prior discovery stays validation-free', async () => { + const { clientTransport } = await makeServerWithLaxOutputSchemaTool() + await using client = await createMCPClientFromTransport(clientTransport) + // The tool declares an outputSchema but returns only text content — the + // raw result must come back, not the SDK's strict structured-content error. + const result = await client.callTool('lax_tool') + expect(result.content).toEqual([{ type: 'text', text: 'called lax_tool' }]) + }) + + it('excludes task-required tools when the server lacks the tasks capability', async () => { + const { clientTransport } = await makeServerWithUnsupportedTaskTool() + await using client = await createMCPClientFromTransport(clientTransport) + const tools = await client.tools() + expect(tools.map((t) => t.name)).toEqual(['plain_tool']) + }) + + it('callTool throws MCPTaskRequiredToolError for a task-required tool the server cannot execute', async () => { + const { clientTransport } = await makeServerWithUnsupportedTaskTool() + await using client = await createMCPClientFromTransport(clientTransport) + await expect(client.callTool('needs_tasks')).rejects.toBeInstanceOf( + MCPTaskRequiredToolError, + ) + // Ordinary tools on the same server keep working. + const result = await client.callTool('plain_tool') + expect(result.content).toEqual([ + { type: 'text', text: 'called plain_tool' }, + ]) + }) + + it('throws MCPTaskRequiredToolError when binding a task-required tool the server cannot execute', async () => { + const { clientTransport } = await makeServerWithUnsupportedTaskTool() + await using client = await createMCPClientFromTransport(clientTransport) + const { toolDefinition } = await import('@tanstack/ai') + const { z } = await import('zod') + const needsTasks = toolDefinition({ + name: 'needs_tasks', + description: 'Requires tasks the server cannot execute', + inputSchema: z.object({}), + }) + await expect(client.tools([needsTasks])).rejects.toBeInstanceOf( + MCPTaskRequiredToolError, + ) + }) + + it('callTool rejects immediately when the signal is already aborted', async () => { + const { clientTransport } = await makeServerWithWeatherTool() + await using client = await createMCPClientFromTransport(clientTransport) + const controller = new AbortController() + controller.abort() + await expect( + client.callTool( + 'get_weather', + { city: 'Berlin' }, + { signal: controller.signal }, + ), + ).rejects.toThrow() + }) + + it('cancels the remote task when callTool is aborted mid-poll', async () => { + const { clientTransport, taskStore } = await makeServerWithPendingTaskTool() + const sent: Array = [] + const origSend = clientTransport.send.bind(clientTransport) + clientTransport.send = (message, sendOptions) => { + if ('method' in message && typeof message.method === 'string') { + sent.push(message.method) + } + return origSend(message, sendOptions) + } + await using client = await createMCPClientFromTransport(clientTransport) + const controller = new AbortController() + const pending = client.callTool( + 'slow_task', + { query: 'q' }, + { signal: controller.signal }, + ) + // Wait until the task exists server-side, then abort the poll loop. + await vi.waitFor(async () => { + const { tasks } = await taskStore.listTasks() + expect(tasks).toHaveLength(1) + }) + controller.abort() + await expect(pending).rejects.toThrow() + await vi.waitFor(() => { + expect(sent).toContain('tasks/cancel') + }) + }) + + it('re-lists tools after a tools/list_changed notification', async () => { + const { server, clientTransport, getListRequests } = + await makeServerWithChangingTools() + await using client = await createMCPClientFromTransport(clientTransport) + await client.callTool('tool_a') + const listed = getListRequests() + await client.callTool('tool_a') + expect(getListRequests()).toBe(listed) // cached — no re-list + await server.sendToolListChanged() + await vi.waitFor(async () => { + await client.callTool('tool_a') + expect(getListRequests()).toBeGreaterThan(listed) + }) + }) + it('callTool throws MCPConnectionError when client is closed', async () => { const { clientTransport } = await makeServerWithWeatherTool() const client = await createMCPClientFromTransport(clientTransport) diff --git a/packages/ai-mcp/tests/helpers/in-memory-server.ts b/packages/ai-mcp/tests/helpers/in-memory-server.ts index cbace5212..fca62bc3d 100644 --- a/packages/ai-mcp/tests/helpers/in-memory-server.ts +++ b/packages/ai-mcp/tests/helpers/in-memory-server.ts @@ -1,7 +1,18 @@ // packages/ai-mcp/tests/helpers/in-memory-server.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { + InMemoryTaskMessageQueue, + InMemoryTaskStore, +} from '@modelcontextprotocol/sdk/experimental/tasks' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { + CallToolRequestSchema, + CallToolResultSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' +import type { Tool as McpToolDef } from '@modelcontextprotocol/sdk/types.js' /** Build a connected (server, clientTransport) pair over in-memory transports. */ export async function makeServerWithWeatherTool() { @@ -42,9 +53,18 @@ export async function makeServerWithFailingTool() { return { server, clientTransport } } -/** Build a connected (server, clientTransport) pair with one normal tool and one task-required tool. */ +/** Build a connected pair with one normal tool and one real task-required tool. */ export async function makeServerWithTaskRequiredTool() { - const server = new McpServer({ name: 'tasky', version: '1.0.0' }) + const taskStore = new InMemoryTaskStore() + const server = new McpServer( + { name: 'tasky', version: '1.0.0' }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } }, + taskStore, + taskMessageQueue: new InMemoryTaskMessageQueue(), + defaultTaskPollInterval: 1, + }, + ) server.registerTool( 'get_weather', { @@ -55,25 +75,234 @@ export async function makeServerWithTaskRequiredTool() { content: [{ type: 'text' as const, text: `Sunny in ${city}` }], }), ) - const registered = server.registerTool( + server.experimental.tasks.registerToolTask( 'research_task', { description: 'A long-running tool that requires task-based execution', inputSchema: { query: z.string() }, + execution: { taskSupport: 'required' }, + }, + { + async createTask({ query }, { taskStore: store, taskRequestedTtl }) { + const task = await store.createTask({ + ttl: taskRequestedTtl, + pollInterval: 1, + }) + await store.storeTaskResult(task.taskId, 'completed', { + content: [ + { type: 'text' as const, text: `Research complete: ${query}` }, + ], + }) + return { task } + }, + async getTask(_args, { taskId, taskStore: store }) { + return store.getTask(taskId) + }, + async getTaskResult(_args, { taskId, taskStore: store }) { + return CallToolResultSchema.parse(await store.getTaskResult(taskId)) + }, + }, + ) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport } +} + +/** + * Like {@link makeServerWithTaskRequiredTool}, but the created task never + * reaches a terminal state — the client polls until aborted. Exposes the + * taskStore so tests can observe the task server-side. + */ +export async function makeServerWithPendingTaskTool() { + const taskStore = new InMemoryTaskStore() + const server = new McpServer( + { name: 'pending-tasky', version: '1.0.0' }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } }, + taskStore, + taskMessageQueue: new InMemoryTaskMessageQueue(), + defaultTaskPollInterval: 1, + }, + ) + server.experimental.tasks.registerToolTask( + 'slow_task', + { + description: 'A task that never completes on its own', + inputSchema: { query: z.string() }, + execution: { taskSupport: 'required' }, + }, + { + async createTask(_args, { taskStore: store, taskRequestedTtl }) { + const task = await store.createTask({ + ttl: taskRequestedTtl, + pollInterval: 1, + }) + return { task } + }, + async getTask(_args, { taskId, taskStore: store }) { + return store.getTask(taskId) + }, + async getTaskResult(_args, { taskId, taskStore: store }) { + return CallToolResultSchema.parse(await store.getTaskResult(taskId)) + }, }, - async () => ({ - content: [{ type: 'text' as const, text: 'unreachable via callTool' }], - }), ) - // registerTool's config doesn't accept `execution` directly in SDK 1.29; - // RegisteredTool exposes it as a mutable property consumed at list time. - registered.execution = { taskSupport: 'required' } + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport, taskStore } +} + +/** Low-level server exposing `tools` across handlers, with a tools/list request counter. */ +function makeLowLevelToolServer(options: { + name: string + pages: Array> + listChanged?: boolean + listError?: string +}) { + const server = new Server( + { name: options.name, version: '1.0.0' }, + { + capabilities: { + tools: options.listChanged ? { listChanged: true } : {}, + }, + }, + ) + let listRequests = 0 + server.setRequestHandler(ListToolsRequestSchema, (req) => { + listRequests++ + if (options.listError) throw new Error(options.listError) + const cursor = req.params?.cursor + const index = cursor ? Number(cursor) : 0 + const nextCursor = + index + 1 < options.pages.length ? String(index + 1) : undefined + return { + tools: options.pages[index] ?? [], + ...(nextCursor ? { nextCursor } : {}), + } + }) + server.setRequestHandler(CallToolRequestSchema, (req) => ({ + content: [{ type: 'text' as const, text: `called ${req.params.name}` }], + })) + return { server, getListRequests: () => listRequests } +} + +/** + * Build a connected pair whose tools/list is split across two pages, with a + * request counter. Every tools/call answers `called ` — including names + * absent from the list. + */ +export async function makeServerWithPaginatedTools() { + const { server, getListRequests } = makeLowLevelToolServer({ + name: 'paged', + pages: [ + [ + { + name: 'first_page_tool', + description: 'On page one', + inputSchema: { type: 'object' }, + }, + ], + [ + { + name: 'second_page_tool', + description: 'On page two', + inputSchema: { type: 'object' }, + }, + ], + ], + }) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport, getListRequests } +} + +/** Build a connected pair whose tools/list always errors but tools/call works. */ +export async function makeServerWithBrokenToolList() { + const { server } = makeLowLevelToolServer({ + name: 'broken-list', + pages: [[]], + listError: 'tools/list unavailable', + }) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport } +} + +/** + * Build a connected pair with one tool that declares an outputSchema but + * answers with text-only content (no structuredContent) — a lax server. + */ +export async function makeServerWithLaxOutputSchemaTool() { + const { server } = makeLowLevelToolServer({ + name: 'lax', + pages: [ + [ + { + name: 'lax_tool', + description: 'Declares an output schema it never honors', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + }, + }, + ], + ], + }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await server.connect(serverTransport) return { server, clientTransport } } +/** + * Build a connected pair that LISTS a task-required tool but does NOT declare + * the tasks capability for tools/call (e.g. a proxy stripping capabilities). + */ +export async function makeServerWithUnsupportedTaskTool() { + const { server } = makeLowLevelToolServer({ + name: 'no-task-capability', + pages: [ + [ + { + name: 'plain_tool', + description: 'plain', + inputSchema: { type: 'object' }, + }, + { + name: 'needs_tasks', + description: 'Requires tasks the server cannot execute', + inputSchema: { type: 'object' }, + execution: { taskSupport: 'required' }, + }, + ], + ], + }) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport } +} + +/** Build a connected pair with a single tool and tools/list_changed support. */ +export async function makeServerWithChangingTools() { + const { server, getListRequests } = makeLowLevelToolServer({ + name: 'changing', + pages: [ + [{ name: 'tool_a', description: 'A', inputSchema: { type: 'object' } }], + ], + listChanged: true, + }) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport, getListRequests } +} + /** Build a connected (server, clientTransport) pair that exposes a static text resource. */ export async function makeServerWithResource() { const server = new McpServer({ name: 'resource-server', version: '1.0.0' }) diff --git a/packages/ai-mcp/tests/tools.test.ts b/packages/ai-mcp/tests/tools.test.ts index b91c28de3..a24e19878 100644 --- a/packages/ai-mcp/tests/tools.test.ts +++ b/packages/ai-mcp/tests/tools.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' import { + callMcpTool, makeMcpExecute, mcpContentToTanstack, toServerTools, @@ -25,6 +27,7 @@ function mcpToolDef(def: { name: string description?: string inputSchema?: { type: 'object'; properties?: Record } + execution?: { taskSupport?: 'optional' | 'required' | 'forbidden' } _meta?: { ui?: { resourceUri?: string } } }): McpToolDef { return { @@ -42,7 +45,10 @@ function mcpToolDef(def: { function fakeMcpClient( callTool: (...args: Array) => Promise, ): Client { - return { callTool } as unknown as Client + return { + callTool, + getServerCapabilities: () => undefined, + } as unknown as Client } describe('mcpContentToTanstack', () => { @@ -123,6 +129,84 @@ describe('mcpContentToTanstack', () => { }) }) +describe('callMcpTool', () => { + it('drains task status updates and returns the terminal result', async () => { + const controller = new AbortController() + const callToolStream = vi.fn(() => + (async function* () { + yield { + type: 'taskStatus' as const, + task: { + taskId: 'task-1', + status: 'working' as const, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + ttl: 60_000, + }, + } + yield { + type: 'result' as const, + result: { content: [{ type: 'text' as const, text: 'done' }] }, + } + })(), + ) + const client = { + experimental: { tasks: { callToolStream } }, + } as unknown as Client + + await expect( + callMcpTool(client, 'research', { query: 'x' }, true, controller.signal), + ).resolves.toEqual({ content: [{ type: 'text', text: 'done' }] }) + expect(callToolStream).toHaveBeenCalledWith( + { name: 'research', arguments: { query: 'x' } }, + CallToolResultSchema, + { signal: controller.signal, task: {} }, + ) + }) + + it('throws a terminal task-stream error', async () => { + const error = new Error('task failed') + const client = { + experimental: { + tasks: { + callToolStream: () => + (async function* () { + yield { type: 'error' as const, error } + })(), + }, + }, + } as unknown as Client + + await expect(callMcpTool(client, 'research', {}, true)).rejects.toBe(error) + }) + + it('throws if a task stream ends without a terminal message', async () => { + const client = { + experimental: { + tasks: { + callToolStream: () => + (async function* () { + yield { + type: 'taskStatus' as const, + task: { + taskId: 'task-1', + status: 'working' as const, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + ttl: 60_000, + }, + } + })(), + }, + }, + } as unknown as Client + + await expect(callMcpTool(client, 'research', {}, true)).rejects.toThrow( + /ended without a result or error/, + ) + }) +}) + describe('makeMcpExecute', () => { it('throws an error naming the tool when the MCP tool returns isError', async () => { const { clientTransport } = await makeServerWithFailingTool() @@ -163,7 +247,7 @@ describe('makeMcpExecute', () => { ) expect(callTool).toHaveBeenCalledWith( { name: 'x', arguments: {} }, - undefined, + CallToolResultSchema, { signal: controller.signal }, ) }) @@ -277,6 +361,25 @@ describe('toServerTools', () => { await client.close() }) + it('keeps task-optional tools on ordinary callTool execution', async () => { + const callTool = vi + .fn() + .mockResolvedValue({ content: [{ type: 'text', text: 'plain' }] }) + const tools = toServerTools( + fakeMcpClient(callTool), + [ + mcpToolDef({ + name: 'optional_task', + execution: { taskSupport: 'optional' }, + }), + ], + {}, + ) + + await expect(tools[0]!.execute!({})).resolves.toBe('plain') + expect(callTool).toHaveBeenCalledOnce() + }) + it('applies a prefix', async () => { const { clientTransport } = await makeServerWithWeatherTool() const client = new Client({ name: 'test', version: '1.0.0' }) diff --git a/testing/e2e/src/routes/api.mcp-apps-server.ts b/testing/e2e/src/routes/api.mcp-apps-server.ts index 8f69a8747..69e687105 100644 --- a/testing/e2e/src/routes/api.mcp-apps-server.ts +++ b/testing/e2e/src/routes/api.mcp-apps-server.ts @@ -1,6 +1,11 @@ import { createFileRoute } from '@tanstack/react-router' +import { + InMemoryTaskMessageQueue, + InMemoryTaskStore, +} from '@modelcontextprotocol/sdk/experimental/tasks' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' /** @@ -28,12 +33,22 @@ import { z } from 'zod' const WIDGET_URI = 'ui://show_widget' const WIDGET_HTML = '
MCP_APPS_WIDGET_OK
' +const taskStore = new InMemoryTaskStore() +const taskMessageQueue = new InMemoryTaskMessageQueue() function createMockAppsMcpServer(): McpServer { - const server = new McpServer({ - name: 'mcp-apps-mock', - version: '0.0.1', - }) + const server = new McpServer( + { + name: 'mcp-apps-mock', + version: '0.0.1', + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } }, + taskStore, + taskMessageQueue, + defaultTaskPollInterval: 1, + }, + ) // A tool that links a ui:// resource via the MCP Apps `_meta.ui.resourceUri` // convention. `@tanstack/ai-mcp` discovery stamps this onto the ServerTool's @@ -57,6 +72,40 @@ function createMockAppsMcpServer(): McpServer { // RegisteredTool exposes `_meta` as a mutable property surfaced at list time. widgetTool._meta = { ui: { resourceUri: WIDGET_URI } } + // A task-required action exercises the Apps call handler's direct + // MCPClient.callTool path, which must select task execution after discovery. + server.experimental.tasks.registerToolTask( + 'run_widget_task', + { + description: 'Run a task-required action from an MCP App', + inputSchema: { action: z.string() }, + execution: { taskSupport: 'required' }, + }, + { + async createTask({ action }, { taskStore: store, taskRequestedTtl }) { + const task = await store.createTask({ + ttl: taskRequestedTtl, + pollInterval: 1, + }) + await store.storeTaskResult(task.taskId, 'completed', { + content: [ + { + type: 'text', + text: `MCP_APPS_TASK_CALL_OK:${action}`, + }, + ], + }) + return { task } + }, + async getTask(_args, { taskId, taskStore: store }) { + return store.getTask(taskId) + }, + async getTaskResult(_args, { taskId, taskStore: store }) { + return CallToolResultSchema.parse(await store.getTaskResult(taskId)) + }, + }, + ) + // The linked ui:// resource — its HTML is what the client widget would // render. We only assert OUR plumbing carries this HTML, not that any // third-party renderer mounts it. diff --git a/testing/e2e/src/routes/api.mcp-server.ts b/testing/e2e/src/routes/api.mcp-server.ts index 75ffcf1a8..5294f5666 100644 --- a/testing/e2e/src/routes/api.mcp-server.ts +++ b/testing/e2e/src/routes/api.mcp-server.ts @@ -1,6 +1,11 @@ import { createFileRoute } from '@tanstack/react-router' +import { + InMemoryTaskMessageQueue, + InMemoryTaskStore, +} from '@modelcontextprotocol/sdk/experimental/tasks' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' /** @@ -13,21 +18,31 @@ import { z } from 'zod' * 'http', url } })`, discovers its tools, and runs them inside a real `chat()` * agent loop (with the LLM mocked by aimock). * - * Stateless mode (no `sessionIdGenerator`): a fresh `McpServer` + transport is - * created per request. This avoids any cross-request session bookkeeping — - * appropriate for a serverless-style route and for deterministic tests. The - * transport is closed once the response has been produced. + * Stateless mode (no `sessionIdGenerator`) creates a fresh `McpServer` and + * transport per request. The module-scoped task store persists task state + * across the follow-up polling requests made by `callToolStream`; no session + * bookkeeping is needed. * - * The single tool `get_guitar_price` is fully deterministic: given `{ id }` it - * returns both a structured payload and a text block carrying `{ id, price: - * 1999 }`, so the spec can assert the price `1999` reaches the streamed - * transcript after the tool executes. + * `get_guitar_price` provides an ordinary deterministic tool. The + * task-required `appraise_guitar_collection` tool returns a distinctive total + * through the real task create/status/result flow. */ +const taskStore = new InMemoryTaskStore() +const taskMessageQueue = new InMemoryTaskMessageQueue() + function createMockMcpServer(): McpServer { - const server = new McpServer({ - name: 'guitar-store-mcp-mock', - version: '0.0.1', - }) + const server = new McpServer( + { + name: 'guitar-store-mcp-mock', + version: '0.0.1', + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } }, + taskStore, + taskMessageQueue, + defaultTaskPollInterval: 1, + }, + ) server.registerTool( 'get_guitar_price', @@ -45,22 +60,37 @@ function createMockMcpServer(): McpServer { }, ) - // A task-required tool (experimental MCP tasks). Plain `callTool` would be - // rejected with -32600, so @tanstack/ai-mcp must EXCLUDE it from tools() - // discovery — the spec asserts it never reaches the tool list. - const taskTool = server.registerTool( + server.experimental.tasks.registerToolTask( 'appraise_guitar_collection', { description: 'Long-running appraisal that requires task-based execution', inputSchema: { ids: z.array(z.string()) }, + execution: { taskSupport: 'required' }, + }, + { + async createTask({ ids }, { taskStore: store, taskRequestedTtl }) { + const task = await store.createTask({ + ttl: taskRequestedTtl, + pollInterval: 1, + }) + await store.storeTaskResult(task.taskId, 'completed', { + content: [ + { + type: 'text', + text: `Appraised ${ids.join(', ')} at 4200 total`, + }, + ], + }) + return { task } + }, + async getTask(_args, { taskId, taskStore: store }) { + return store.getTask(taskId) + }, + async getTaskResult(_args, { taskId, taskStore: store }) { + return CallToolResultSchema.parse(await store.getTaskResult(taskId)) + }, }, - () => ({ - content: [{ type: 'text' as const, text: 'unreachable via callTool' }], - }), ) - // registerTool's config doesn't accept `execution` directly in SDK 1.29; - // RegisteredTool exposes it as a mutable property consumed at list time. - taskTool.execution = { taskSupport: 'required' } // A static resource + prompt so the resource/prompt read+convert path can be // exercised end-to-end (see api.mcp-status-test). The catalog text carries a diff --git a/testing/e2e/src/routes/api.mcp-status-test.ts b/testing/e2e/src/routes/api.mcp-status-test.ts index 40d1d2a95..69ea11af6 100644 --- a/testing/e2e/src/routes/api.mcp-status-test.ts +++ b/testing/e2e/src/routes/api.mcp-status-test.ts @@ -27,7 +27,18 @@ export const Route = createFileRoute('/api/mcp-status-test')({ transport: { type: 'http', url: mcpUrl }, }) try { - const tools = (await client.tools()).map((t) => t.name) + const toolList = await client.tools() + const tools = toolList.map((tool) => tool.name) + const taskTool = toolList.find( + (tool) => tool.name === 'appraise_guitar_collection', + ) + if (!taskTool?.execute) { + throw new Error('Task-required appraisal tool was not discovered') + } + const taskResult = await taskTool.execute( + { ids: ['strat', 'tele'] }, + { toolCallId: 'task-e2e', emitCustomEvent: () => {} }, + ) const resourceList = await client.resources().catch(() => []) const resourceContent: Array = [] @@ -47,6 +58,7 @@ export const Route = createFileRoute('/api/mcp-status-test')({ return Response.json({ tools, + taskResult, resources: resourceList.map((r) => r.uri), prompts: promptList.map((p) => p.name), resourceContent, diff --git a/testing/e2e/tests/mcp-apps.spec.ts b/testing/e2e/tests/mcp-apps.spec.ts index 8f656bced..e4abaf546 100644 --- a/testing/e2e/tests/mcp-apps.spec.ts +++ b/testing/e2e/tests/mcp-apps.spec.ts @@ -9,9 +9,9 @@ import { test, expect } from './fixtures' * stream. The event carries the resource HTML (token MCP_APPS_WIDGET_OK), * which a client reconciles into a `UIResourcePart`. We assert the event * reaches the client; we do NOT assert the iframe widget mounts. - * - INTERACTIVE plane (`api.mcp-apps-call`): a POST to the route mounting - * `createMcpAppCallHandler` returns the MCP tool's result (token - * MCP_APPS_CALL_OK). + * - INTERACTIVE plane (`api.mcp-apps-call`): POSTs to the route mounting + * `createMcpAppCallHandler` return both ordinary and task-required MCP + * tool results (tokens MCP_APPS_CALL_OK and MCP_APPS_TASK_CALL_OK). * - ALLOWLIST: a call for a tool the server does not expose returns * `{ ok: false }`. */ @@ -172,6 +172,32 @@ test.describe('mcp-apps — data + interactive planes', () => { expect(JSON.stringify(json.result)).toContain('MCP_APPS_CALL_OK') }) + test('INTERACTIVE TASK: the call handler executes a task-required tool', async ({ + request, + testId, + }) => { + const res = await request.post('/api/mcp-apps-call', { + headers: { 'Content-Type': 'application/json' }, + data: { + threadId: `mcp-apps-task-thread-${testId}`, + serverId: 'widgets', + toolName: 'run_widget_task', + args: { action: 'refresh' }, + }, + }) + + const body = await res.text() + expect(res.ok(), `mcp-apps-call failed (${res.status()}): ${body}`).toBe( + true, + ) + + const json = parseCallResponse(body) + expect(json.ok, `expected ok:true, got: ${body}`).toBe(true) + expect(JSON.stringify(json.result)).toContain( + 'MCP_APPS_TASK_CALL_OK:refresh', + ) + }) + test('ALLOWLIST: a call for a tool the server does not expose returns ok:false', async ({ request, testId, diff --git a/testing/e2e/tests/mcp-status.spec.ts b/testing/e2e/tests/mcp-status.spec.ts index 942be3e5f..e3b098c18 100644 --- a/testing/e2e/tests/mcp-status.spec.ts +++ b/testing/e2e/tests/mcp-status.spec.ts @@ -24,6 +24,7 @@ test.describe('mcp — resource/prompt discovery + conversion', () => { const json = JSON.parse(body) as { tools: Array + taskResult: unknown resources: Array prompts: Array resourceContent: Array<{ type: string; content: string }> @@ -33,9 +34,10 @@ test.describe('mcp — resource/prompt discovery + conversion', () => { // Tool discovered. expect(json.tools).toContain('get_guitar_price') - // Task-required tool (execution.taskSupport: 'required') is excluded from - // discovery — plain callTool can never execute it (-32600). - expect(json.tools).not.toContain('appraise_guitar_collection') + // Task-required tools are discovered and execute through MCP's task stream. + expect(json.tools).toContain('appraise_guitar_collection') + expect(JSON.stringify(json.taskResult)).toContain('4200') + expect(JSON.stringify(json.taskResult)).toContain('strat') // Resource listed + read + converted to a text ContentPart carrying the // server's distinctive token.