From f4ac15a2f0bf8f126925d9be6978c31fc30e2ea4 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 13:56:47 +0200 Subject: [PATCH 01/71] chore(ai-mcp): scaffold @tanstack/ai-mcp package --- packages/ai-mcp/README.md | 47 ++++++++ packages/ai-mcp/package.json | 40 +++++++ packages/ai-mcp/src/index.ts | 1 + packages/ai-mcp/tsconfig.json | 6 + packages/ai-mcp/tsup.bin.config.ts | 14 +++ packages/ai-mcp/vite.config.ts | 28 +++++ pnpm-lock.yaml | 182 ++++++++++++++++++++++++++++- 7 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 packages/ai-mcp/README.md create mode 100644 packages/ai-mcp/package.json create mode 100644 packages/ai-mcp/src/index.ts create mode 100644 packages/ai-mcp/tsconfig.json create mode 100644 packages/ai-mcp/tsup.bin.config.ts create mode 100644 packages/ai-mcp/vite.config.ts diff --git a/packages/ai-mcp/README.md b/packages/ai-mcp/README.md new file mode 100644 index 000000000..1c3b5a184 --- /dev/null +++ b/packages/ai-mcp/README.md @@ -0,0 +1,47 @@ +# @tanstack/ai-mcp + +Host-side Model Context Protocol (MCP) client for TanStack AI. + +Discover and run MCP server tools, resources, and prompts inside any TanStack AI `chat()` / agent loop — across any provider adapter — with optional generated end-to-end TypeScript types. + +## Features + +- `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(...)])`) +- `@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 + +## Installation + +```bash +pnpm add @tanstack/ai-mcp @modelcontextprotocol/sdk +``` + +## Quick Start + +```ts +import { createMCPClient } from '@tanstack/ai-mcp' +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' + +const mcp = await createMCPClient({ + transport: { type: 'http', url: 'https://your-mcp-server.com/mcp' }, +}) + +const tools = await mcp.tools() + +const result = await chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: [{ role: 'user', content: 'What is the weather in Brooklyn?' }], + tools, +}) + +await mcp.close() +``` + +## License + +MIT diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json new file mode 100644 index 000000000..09b4cf87e --- /dev/null +++ b/packages/ai-mcp/package.json @@ -0,0 +1,40 @@ +{ + "name": "@tanstack/ai-mcp", + "version": "0.0.1", + "description": "Host-side Model Context Protocol client for TanStack AI: discover and run MCP server tools, resources, and prompts in any adapter's chat() loop, with generated end-to-end types.", + "license": "MIT", + "repository": { "type": "git", "url": "git+https://github.com/TanStack/ai.git", "directory": "packages/ai-mcp" }, + "keywords": ["ai", "mcp", "model-context-protocol", "tanstack", "tools", "typescript"], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "bin": { "tanstack-ai-mcp": "./dist/bin/bin.js" }, + "exports": { + ".": { "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js" }, + "./stdio": { "types": "./dist/esm/stdio.d.ts", "import": "./dist/esm/stdio.js" } + }, + "files": ["dist", "src"], + "scripts": { + "build": "vite build && tsup --config tsup.bin.config.ts", + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:build": "publint --strict", + "test:coverage": "vitest run --coverage", + "test:eslint": "eslint ./src", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/ai": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "devDependencies": { + "json-schema-to-typescript": "^15.0.4", + "jiti": "^2.4.2", + "tsup": "^8.3.5", + "vite": "^7.3.3", + "zod": "^4.2.0", + "@vitest/coverage-v8": "4.0.14" + } +} diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts new file mode 100644 index 000000000..0a188be06 --- /dev/null +++ b/packages/ai-mcp/src/index.ts @@ -0,0 +1 @@ +export {} // replaced in Task 5 diff --git a/packages/ai-mcp/tsconfig.json b/packages/ai-mcp/tsconfig.json new file mode 100644 index 000000000..6e7ab980b --- /dev/null +++ b/packages/ai-mcp/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-mcp/tsup.bin.config.ts b/packages/ai-mcp/tsup.bin.config.ts new file mode 100644 index 000000000..e5635420d --- /dev/null +++ b/packages/ai-mcp/tsup.bin.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { bin: 'src/cli/bin.ts' }, + outDir: 'dist/bin', + format: ['esm'], + platform: 'node', + target: 'node18', + // Inline codegen-only deps into the bin so they aren't runtime deps of the lib. + noExternal: ['json-schema-to-typescript', 'jiti'], + // Keep the heavy SDK + workspace pkg external (installed alongside). + external: ['@modelcontextprotocol/sdk', '@tanstack/ai'], + banner: { js: '#!/usr/bin/env node' }, +}) diff --git a/packages/ai-mcp/vite.config.ts b/packages/ai-mcp/vite.config.ts new file mode 100644 index 000000000..f428e9a09 --- /dev/null +++ b/packages/ai-mcp/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/cli/**', '**/*.test.ts', 'src/types.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/stdio.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44e31ad0d..315e16035 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1242,7 +1242,7 @@ importers: dependencies: '@google/genai': specifier: ^1.43.0 - version: 1.43.0 + version: 1.43.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) '@tanstack/ai-utils': specifier: workspace:* version: link:../ai-utils @@ -1355,6 +1355,34 @@ importers: specifier: 4.0.14 version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.15))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + packages/ai-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + devDependencies: + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.15))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + jiti: + specifier: ^2.4.2 + version: 2.6.1 + json-schema-to-typescript: + specifier: ^15.0.4 + version: 15.0.4 + tsup: + specifier: ^8.3.5 + version: 8.5.1(@microsoft/api-extractor@7.47.7(@types/node@24.10.3))(jiti@2.6.1)(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + vite: + specifier: ^7.3.3 + version: 7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + zod: + specifier: ^4.2.0 + version: 4.3.6 + packages/ai-ollama: dependencies: '@tanstack/ai-utils': @@ -2086,6 +2114,10 @@ packages: zod: optional: true + '@apidevtools/json-schema-ref-parser@11.9.3': + resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} + engines: {node: '>= 16'} + '@ark/schema@0.56.0': resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} @@ -4125,6 +4157,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} @@ -4155,6 +4190,16 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpack/msgpack@3.1.3': resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} engines: {node: '>= 18'} @@ -7169,6 +7214,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -7708,6 +7756,9 @@ packages: ajv@8.13.0: resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + alien-signals@1.0.13: resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} @@ -8412,6 +8463,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -9085,6 +9140,14 @@ packages: resolution: {integrity: sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==} engines: {node: '>=14.18'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -9174,6 +9237,12 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -9215,6 +9284,9 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -9812,6 +9884,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -10159,12 +10235,20 @@ packages: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} + json-schema-to-typescript@15.0.4: + resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} + engines: {node: '>=16.0.0'} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -11381,6 +11465,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-dir@3.0.0: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} @@ -13772,6 +13860,11 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -13799,6 +13892,12 @@ snapshots: optionalDependencies: zod: 4.2.1 + '@apidevtools/json-schema-ref-parser@11.9.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + '@ark/schema@0.56.0': dependencies: '@ark/util': 0.56.0 @@ -15636,12 +15735,14 @@ snapshots: '@shikijs/types': 3.20.0 '@shikijs/vscode-textmate': 10.0.2 - '@google/genai@1.43.0': + '@google/genai@1.43.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': dependencies: google-auth-library: 10.5.0 p-retry: 4.6.2 protobufjs: 7.5.4 ws: 8.18.3 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) transitivePeerDependencies: - bufferutil - supports-color @@ -15870,6 +15971,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ono@7.1.3': {} + '@livekit/mutex@1.1.1': {} '@livekit/protocol@1.44.0': @@ -15940,6 +16043,28 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.23) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.23 + jose: 6.2.0 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@msgpack/msgpack@3.1.3': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': @@ -19404,6 +19529,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/lodash@4.17.24': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -20068,6 +20195,10 @@ snapshots: optionalDependencies: ajv: 8.13.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -20096,6 +20227,13 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + alien-signals@1.0.13: {} anser@1.4.10: {} @@ -20889,6 +21027,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -21669,6 +21812,12 @@ snapshots: eventsource-parser@1.1.2: {} + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -21785,6 +21934,11 @@ snapshots: exponential-backoff@3.1.3: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -21854,6 +22008,8 @@ snapshots: fast-sha256@1.3.0: {} + fast-uri@3.1.2: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -22559,6 +22715,8 @@ snapshots: ip-address@10.1.0: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} iron-webcrypto@1.2.1: {} @@ -22908,10 +23066,24 @@ snapshots: '@babel/runtime': 7.29.2 ts-algebra: 2.0.0 + json-schema-to-typescript@15.0.4: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.9.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.24 + is-glob: 4.0.3 + js-yaml: 4.1.1 + lodash: 4.17.21 + minimist: 1.2.8 + prettier: 3.7.4 + tinyglobby: 0.2.16 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -24724,6 +24896,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-dir@3.0.0: dependencies: find-up: 3.0.0 @@ -27338,6 +27512,10 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@3.25.76: {} zod@4.2.1: {} From 8fa56bf99325acf8098b8c170571e4d974c30318 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 13:58:35 +0200 Subject: [PATCH 02/71] feat(ai): expose abortSignal on ToolExecutionContext --- packages/ai/src/types.ts | 6 +++++ .../ai/tests/tool-execution-context.test.ts | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 packages/ai/tests/tool-execution-context.test.ts diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 4cebe5837..d902f8c3f 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -490,6 +490,12 @@ export type ToolExecutionContext = RuntimeContextField & { /** The ID of the tool call being executed */ toolCallId?: string + /** + * Abort signal for the current chat run. Aborts when the run's + * `abortController` fires (or middleware aborts). Long-running tools — + * e.g. MCP `callTool` — should forward this to cancel in-flight work. + */ + abortSignal?: AbortSignal /** * Emit a custom event during tool execution. * Events are streamed to the client in real-time as AG-UI CUSTOM events. diff --git a/packages/ai/tests/tool-execution-context.test.ts b/packages/ai/tests/tool-execution-context.test.ts new file mode 100644 index 000000000..b52ed8466 --- /dev/null +++ b/packages/ai/tests/tool-execution-context.test.ts @@ -0,0 +1,26 @@ +// packages/ai/tests/tool-execution-context.test.ts +import { describe, expect, it } from 'vitest' +import { toolDefinition } from '../src' +import { z } from 'zod' + +describe('ToolExecutionContext.abortSignal', () => { + it('passes the abort signal into a server tool execute', async () => { + const controller = new AbortController() + let seen: AbortSignal | undefined + const tool = toolDefinition({ + name: 'echo', + description: 'Echo a value', + inputSchema: z.object({ v: z.string() }), + }).server((args, ctx) => { + seen = ctx?.abortSignal + return args.v + }) + // Invoke execute directly with a context to assert the field is typed + forwarded. + await tool.execute!({ v: 'hi' }, { + toolCallId: 't1', + emitCustomEvent: () => {}, + abortSignal: controller.signal, + }) + expect(seen).toBe(controller.signal) + }) +}) From feca14669233b43162e7ee7b5030e991ec790a9a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 14:12:35 +0200 Subject: [PATCH 03/71] feat(ai-mcp): transport config types and resolver with isolated stdio --- packages/ai-mcp/src/stdio.ts | 15 ++++++ packages/ai-mcp/src/transport.ts | 67 +++++++++++++++++++++++++ packages/ai-mcp/tests/transport.test.ts | 26 ++++++++++ 3 files changed, 108 insertions(+) create mode 100644 packages/ai-mcp/src/stdio.ts create mode 100644 packages/ai-mcp/src/transport.ts create mode 100644 packages/ai-mcp/tests/transport.test.ts diff --git a/packages/ai-mcp/src/stdio.ts b/packages/ai-mcp/src/stdio.ts new file mode 100644 index 000000000..e2dbda0c1 --- /dev/null +++ b/packages/ai-mcp/src/stdio.ts @@ -0,0 +1,15 @@ +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { StdioTransportConfig } from './transport' + +/** Build a stdio transport instance to pass as `createMCPClient({ transport })`. Node-only. */ +export function stdioTransport( + config: Omit, +): Transport { + return new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env, + cwd: config.cwd, + }) +} diff --git a/packages/ai-mcp/src/transport.ts b/packages/ai-mcp/src/transport.ts new file mode 100644 index 000000000..d6c467e84 --- /dev/null +++ b/packages/ai-mcp/src/transport.ts @@ -0,0 +1,67 @@ +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' + +export interface HttpTransportConfig { + type: 'http' + url: string + headers?: Record + fetch?: typeof fetch + authProvider?: OAuthClientProvider +} + +export interface SseTransportConfig { + type: 'sse' + url: string + headers?: Record + fetch?: typeof fetch + authProvider?: OAuthClientProvider +} + +/** stdio is declared here for typing but constructed only via `@tanstack/ai-mcp/stdio`. */ +export interface StdioTransportConfig { + type: 'stdio' + command: string + args?: Array + env?: Record + cwd?: string +} + +export type TransportConfig = + | HttpTransportConfig + | SseTransportConfig + | StdioTransportConfig + +/** Either a built-in config or a ready-made SDK Transport instance (escape hatch). */ +export type TransportInput = TransportConfig | Transport + +function isTransportInstance(input: TransportInput): input is Transport { + return typeof (input as Transport).start === 'function' +} + +export async function resolveTransport(input: TransportInput): Promise { + if (isTransportInstance(input)) return input + + switch (input.type) { + case 'http': + return new StreamableHTTPClientTransport(new URL(input.url), { + requestInit: { headers: input.headers }, + fetch: input.fetch, + authProvider: input.authProvider, + }) + case 'sse': + return new SSEClientTransport(new URL(input.url), { + requestInit: { headers: input.headers }, + fetch: input.fetch, + authProvider: input.authProvider, + }) + case 'stdio': + throw new Error( + "stdio transport must be created via '@tanstack/ai-mcp/stdio': " + + "import { stdioTransport } from '@tanstack/ai-mcp/stdio' and pass the result as `transport`.", + ) + default: + throw new Error(`Unknown MCP transport config: ${JSON.stringify(input)}`) + } +} diff --git a/packages/ai-mcp/tests/transport.test.ts b/packages/ai-mcp/tests/transport.test.ts new file mode 100644 index 000000000..f172859f9 --- /dev/null +++ b/packages/ai-mcp/tests/transport.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { resolveTransport } from '../src/transport' + +describe('resolveTransport', () => { + it('builds a Streamable HTTP transport from config', async () => { + const t = await resolveTransport({ + type: 'http', + url: 'https://example.com/mcp', + headers: { Authorization: 'Bearer x' }, + }) + expect(t).toBeDefined() + expect(t.constructor.name).toMatch(/StreamableHTTP/) + }) + + it('passes through a user-supplied transport instance', async () => { + const fake = { start: async () => {}, send: async () => {}, close: async () => {} } + const t = await resolveTransport(fake as any) + expect(t).toBe(fake) + }) + + it('throws a clear error for stdio without the /stdio import', async () => { + await expect( + resolveTransport({ type: 'stdio', command: 'node', args: [] }), + ).rejects.toThrow(/@tanstack\/ai-mcp\/stdio/) + }) +}) From 25d07ec2d501ed2fd12ee3eed2f0d9fc03c35124 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 14:13:21 +0200 Subject: [PATCH 04/71] feat(ai): thread chat-run abort signal into tool execution context --- packages/ai/src/activities/chat/index.ts | 2 ++ .../src/activities/chat/tools/tool-calls.ts | 2 ++ .../ai/tests/tool-abort-threading.test.ts | 36 +++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 packages/ai/tests/tool-abort-threading.test.ts diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index c283b97c4..cdf34941f 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -1237,6 +1237,7 @@ class TextEngine< }, }, this.middlewareCtx.context, + this.effectiveSignal, ) // Consume the async generator, yielding custom events and collecting the return value @@ -1398,6 +1399,7 @@ class TextEngine< }, }, this.middlewareCtx.context, + this.effectiveSignal, ) // Consume the async generator, yielding custom events and collecting the return value diff --git a/packages/ai/src/activities/chat/tools/tool-calls.ts b/packages/ai/src/activities/chat/tools/tool-calls.ts index 9ab977cf9..4d5989d29 100644 --- a/packages/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/ai/src/activities/chat/tools/tool-calls.ts @@ -599,6 +599,7 @@ export async function* executeToolCalls( ) => CustomEvent, middlewareHooks?: ToolExecutionMiddlewareHooks, userContext?: TContext, + abortSignal?: AbortSignal, ): AsyncGenerator { const results: Array = [] const needsApproval: Array = [] @@ -679,6 +680,7 @@ export async function* executeToolCalls( const context = { toolCallId: toolCall.id, context: userContext, + abortSignal, emitCustomEvent: (eventName: string, value: Record) => { if (createCustomEventChunk) { pendingEvents.push( diff --git a/packages/ai/tests/tool-abort-threading.test.ts b/packages/ai/tests/tool-abort-threading.test.ts new file mode 100644 index 000000000..572a9e48e --- /dev/null +++ b/packages/ai/tests/tool-abort-threading.test.ts @@ -0,0 +1,36 @@ +// packages/ai/tests/tool-abort-threading.test.ts +import { describe, expect, it } from 'vitest' +import { executeToolCalls } from '../src/activities/chat/tools/tool-calls' +import { toolDefinition } from '../src' +import { z } from 'zod' + +describe('executeToolCalls abort threading', () => { + it('forwards an AbortSignal to server tool execute via context', async () => { + const controller = new AbortController() + let seen: AbortSignal | undefined + const tool = toolDefinition({ + name: 'probe', + description: 'probe tool', + inputSchema: z.object({}), + }).server((_args, ctx) => { + seen = ctx?.abortSignal + return 'ok' + }) + const calls = [{ id: 'c1', type: 'function', function: { name: 'probe', arguments: '{}' } }] + const gen = executeToolCalls( + calls as any, + [tool], + new Map(), + new Map(), + undefined, + undefined, + undefined, + controller.signal, // new trailing param + ) + // drain + while (!(await gen.next()).done) { + /* consume events */ + } + expect(seen).toBe(controller.signal) + }) +}) From 4e742f3bbcf9cc47c193d34735029e20417532ab Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 14:21:18 +0200 Subject: [PATCH 05/71] feat(ai-mcp): core types and error classes --- packages/ai-mcp/src/errors.ts | 26 ++++++++++++++++++ packages/ai-mcp/src/types.ts | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 packages/ai-mcp/src/errors.ts create mode 100644 packages/ai-mcp/src/types.ts diff --git a/packages/ai-mcp/src/errors.ts b/packages/ai-mcp/src/errors.ts new file mode 100644 index 000000000..66647bd51 --- /dev/null +++ b/packages/ai-mcp/src/errors.ts @@ -0,0 +1,26 @@ +export class MCPConnectionError extends Error { + constructor(message: string, public override readonly cause?: unknown) { + super(message) + this.name = 'MCPConnectionError' + } +} + +export class DuplicateToolNameError extends Error { + constructor(public readonly toolName: string) { + super( + `Duplicate MCP tool name "${toolName}". Set a unique \`prefix\` on one of the ` + + `MCP clients (createMCPClient({ transport, prefix: '...' })) to disambiguate.`, + ) + this.name = 'DuplicateToolNameError' + } +} + +export class MCPToolNotFoundError extends Error { + constructor(public readonly toolName: string) { + super( + `toolDefinition name "${toolName}" was passed to mcp.tools([...]) but the MCP ` + + `server exposes no tool with that name. Check the name or run mcp.tools() to list.`, + ) + this.name = 'MCPToolNotFoundError' + } +} diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts new file mode 100644 index 000000000..1570e1f68 --- /dev/null +++ b/packages/ai-mcp/src/types.ts @@ -0,0 +1,52 @@ +import type { ServerTool, ToolDefinition } from '@tanstack/ai' +import type { TransportInput } from './transport' + +/** A bare tool definition (from `toolDefinition({...})`, no `.server()`/`.client()` called). */ +export type AnyToolDefinition = ToolDefinition + +/** Compile-time-only descriptor of an MCP server, emitted by the codegen CLI. */ +export interface ServerDescriptor { + tools: Record + resources: Record + prompts: Record + capabilities: Record +} + +/** The "no generated types" default — discovery yields unknown-typed tools. */ +export interface AutomaticDescriptor extends ServerDescriptor { + tools: Record + resources: Record + prompts: Record + capabilities: Record +} + +export interface MCPClientOptions { + transport: TransportInput + /** Tool-name prefix (e.g. 'github' → 'github_search'). Default: none. */ + prefix?: string + /** Client identity sent to the server. */ + name?: string + version?: string +} + +export interface ToolsOptions { + /** Mark tools `lazy: true` to defer schema-sending via LazyToolManager. */ + lazy?: boolean +} + +/** + * Per-element ServerTool type from a tool definition. `def.server(execute)` + * already returns a fully-typed `ServerTool`, so a + * mapped tuple over the passed definitions preserves per-tool types. + */ +export type ServerToolFromDef = D extends ToolDefinition< + infer TInput, + infer TOutput, + infer TName +> + ? ServerTool + : never + +export type MappedServerTools> = { + -readonly [K in keyof TDefs]: ServerToolFromDef +} From 65caeb509f1a301e9f100bcc014106c865c99bbd Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 14:43:26 +0200 Subject: [PATCH 06/71] test(ai-mcp): in-memory MCP server helper --- .../ai-mcp/tests/helpers/in-memory-server.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 packages/ai-mcp/tests/helpers/in-memory-server.ts diff --git a/packages/ai-mcp/tests/helpers/in-memory-server.ts b/packages/ai-mcp/tests/helpers/in-memory-server.ts new file mode 100644 index 000000000..321fbad93 --- /dev/null +++ b/packages/ai-mcp/tests/helpers/in-memory-server.ts @@ -0,0 +1,22 @@ +// packages/ai-mcp/tests/helpers/in-memory-server.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { z } from 'zod' + +/** Build a connected (server, clientTransport) pair over in-memory transports. */ +export async function makeServerWithWeatherTool() { + const server = new McpServer({ name: 'weather', version: '1.0.0' }) + server.registerTool( + 'get_weather', + { + description: 'Get weather for a city', + inputSchema: { city: z.string() }, + }, + async ({ city }) => ({ + content: [{ type: 'text' as const, text: `Sunny in ${city}` }], + }), + ) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport } +} From fc64673db03e786ab388b64b596b283e24d97aac Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 14:49:53 +0200 Subject: [PATCH 07/71] feat(ai-mcp): convert MCP tool definitions to TanStack ServerTools --- packages/ai-mcp/src/tools.ts | 85 +++++++++++++++++++++++++++++ packages/ai-mcp/tests/tools.test.ts | 34 ++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 packages/ai-mcp/src/tools.ts create mode 100644 packages/ai-mcp/tests/tools.test.ts diff --git a/packages/ai-mcp/src/tools.ts b/packages/ai-mcp/src/tools.ts new file mode 100644 index 000000000..4d5878731 --- /dev/null +++ b/packages/ai-mcp/src/tools.ts @@ -0,0 +1,85 @@ +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' + +interface ConvertOptions { + prefix?: string + lazy?: boolean +} + +export function mcpContentToTanstack( + content: Array, +): string | Array { + // Single text block → plain string (most common, best for the model). + if (content.length === 1 && content[0]?.type === 'text') return content[0].text + return content.map((c): ContentPart => { + switch (c.type) { + case 'text': + return { type: 'text', content: c.text } + case 'image': + return { + type: 'image', + source: { type: 'data', value: c.data, mimeType: c.mimeType }, + } + case 'resource': + return { type: 'text', content: JSON.stringify(c.resource) } + default: + return { type: 'text', content: JSON.stringify(c) } + } + }) +} + +/** + * Build the execute body that proxies a TanStack tool call to an MCP server's + * `callTool`. 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 + * validation in `executeServerTool` validates MCP's typed payload rather than + * a JSON-in-text blob. Otherwise normalize `content[]` → string | ContentPart[]. + */ +export function makeMcpExecute( + client: Client, + mcpName: string, + preferStructured: boolean, +) { + return async (args: unknown, ctx?: { abortSignal?: AbortSignal }) => { + const result = await client.callTool( + { name: mcpName, arguments: (args as Record) ?? {} }, + undefined, + { signal: ctx?.abortSignal }, + ) + if (result.isError) { + const text = Array.isArray(result.content) + ? mcpContentToTanstack(result.content as Array) + : 'MCP tool returned an error' + throw new Error(typeof text === 'string' ? text : JSON.stringify(text)) + } + if (preferStructured && (result as any).structuredContent !== undefined) { + return (result as any).structuredContent + } + return mcpContentToTanstack((result.content as Array) ?? []) + } +} + +/** Auto-discovery path: turn raw MCP tool defs into ServerTools (args typed `unknown`). */ +export function toServerTools( + client: Client, + defs: Array, + options: ConvertOptions, +): Array { + return defs.map((def) => { + const name = options.prefix ? `${options.prefix}_${def.name}` : def.name + const tool: ServerTool = { + __toolSide: 'server', + name, + description: def.description ?? '', + inputSchema: (def.inputSchema as any) ?? { type: 'object', properties: {} }, + ...(def.outputSchema ? { outputSchema: def.outputSchema as any } : {}), + ...(options.lazy ? { lazy: true } : {}), + metadata: { mcp: { serverToolName: def.name } }, + execute: makeMcpExecute(client, def.name, Boolean(def.outputSchema)), + } + return tool + }) +} diff --git a/packages/ai-mcp/tests/tools.test.ts b/packages/ai-mcp/tests/tools.test.ts new file mode 100644 index 000000000..a3ede939c --- /dev/null +++ b/packages/ai-mcp/tests/tools.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { makeServerWithWeatherTool } from './helpers/in-memory-server' +import { toServerTools } from '../src/tools' + +describe('toServerTools', () => { + it('discovers tools and proxies execute to callTool', async () => { + const { clientTransport } = await makeServerWithWeatherTool() + const client = new Client({ name: 'test', version: '1.0.0' }) + await client.connect(clientTransport) + + const defs = (await client.listTools()).tools + const tools = toServerTools(client, defs, { prefix: undefined, lazy: false }) + + expect(tools.map((t) => t.name)).toContain('get_weather') + const tool = tools.find((t) => t.name === 'get_weather')! + const result = await tool.execute!({ city: 'Brooklyn' }, { + toolCallId: 't', + emitCustomEvent: () => {}, + }) + expect(JSON.stringify(result)).toContain('Sunny in Brooklyn') + await client.close() + }) + + it('applies a prefix', async () => { + const { clientTransport } = await makeServerWithWeatherTool() + const client = new Client({ name: 'test', version: '1.0.0' }) + await client.connect(clientTransport) + const defs = (await client.listTools()).tools + const tools = toServerTools(client, defs, { prefix: 'wx', lazy: false }) + expect(tools.map((t) => t.name)).toContain('wx_get_weather') + await client.close() + }) +}) From 48b75b55d6dc876b785789e23a5e8ebb378b3cf3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 15:29:52 +0200 Subject: [PATCH 08/71] feat(ai-mcp): MCPClient connect, tools discovery, and lifecycle --- packages/ai-mcp/src/client.ts | 125 +++++++++++++++++++++++++++ packages/ai-mcp/src/index.ts | 26 +++++- packages/ai-mcp/tests/client.test.ts | 26 ++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 packages/ai-mcp/src/client.ts create mode 100644 packages/ai-mcp/tests/client.test.ts diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts new file mode 100644 index 000000000..8c769f5f6 --- /dev/null +++ b/packages/ai-mcp/src/client.ts @@ -0,0 +1,125 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { ServerTool } from '@tanstack/ai' +import { resolveTransport } from './transport' +import { toServerTools } from './tools' +import { MCPConnectionError, DuplicateToolNameError } from './errors' +import type { + AnyToolDefinition, + AutomaticDescriptor, + MappedServerTools, + MCPClientOptions, + ServerDescriptor, + ToolsOptions, +} from './types' + +export interface MCPClient< + TServer extends ServerDescriptor = AutomaticDescriptor, +> { + readonly capabilities: TServer['capabilities'] + /** Auto-discovery: every server tool as a ServerTool (args typed `unknown`). */ + tools(options?: ToolsOptions): Promise> + /** Explicit: bind these TanStack toolDefinitions to the server (typed + validated, allowlist). */ + tools>( + defs: TDefs, + options?: ToolsOptions, + ): Promise> + // resources()/readResource()/prompts()/getPrompt() added in Phase 4. + close(): Promise + [Symbol.asyncDispose](): Promise +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +class MCPClientImpl<_TServer extends ServerDescriptor> + implements MCPClient<_TServer> +{ + capabilities: Record = {} + #client: Client + #closed = false + private prefix?: string + + constructor( + prefix?: string, + name = 'tanstack-ai-mcp', + version = '0.0.1', + ) { + this.prefix = prefix + this.#client = new Client({ name, version }) + } + + async connect(transport: Transport): Promise { + try { + await this.#client.connect(transport) + this.capabilities = (this.#client.getServerCapabilities() ?? + {}) as Record + } catch (err) { + throw new MCPConnectionError('Failed to connect to MCP server', err) + } + } + + async tools( + defsOrOptions?: ReadonlyArray | ToolsOptions, + maybeOptions: ToolsOptions = {}, + ): Promise> { + if (this.#closed) throw new MCPConnectionError('MCP client is closed') + + const isDefs = Array.isArray(defsOrOptions) + const options: ToolsOptions = isDefs + ? maybeOptions + : ((defsOrOptions as ToolsOptions) ?? {}) + + if (isDefs) { + // TODO(4.1b): implement definition-binding branch + throw new Error( + 'definition-binding mode (tools(defs)) is implemented in the next step', + ) + } + + // Auto-discovery path. + const defs = (await this.#client.listTools()).tools + const tools = toServerTools(this.#client, defs, { + prefix: this.prefix, + lazy: options.lazy, + }) + + // Local duplicate guard (within one client's own list). + const seen = new Set() + for (const t of tools) { + if (seen.has(t.name)) throw new DuplicateToolNameError(t.name) + seen.add(t.name) + } + return tools + } + + async close(): Promise { + if (this.#closed) return + this.#closed = true + await this.#client.close() + } + + async [Symbol.asyncDispose](): Promise { + await this.close() + } +} + +export async function createMCPClient< + TServer extends ServerDescriptor = AutomaticDescriptor, +>(options: MCPClientOptions): Promise> { + const transport = await resolveTransport(options.transport) + const impl = new MCPClientImpl( + options.prefix, + options.name, + options.version, + ) + await impl.connect(transport) + return impl +} + +/** Test-only: connect directly from a transport instance (skips resolveTransport). */ +export async function createMCPClientFromTransport< + TServer extends ServerDescriptor = AutomaticDescriptor, +>(transport: Transport, prefix?: string): Promise> { + const impl = new MCPClientImpl(prefix) + await impl.connect(transport) + return impl +} diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 0a188be06..538c12d6e 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -1 +1,25 @@ -export {} // replaced in Task 5 +export { createMCPClient, createMCPClientFromTransport } from './client' +export type { MCPClient } from './client' +export type { + AnyToolDefinition, + MappedServerTools, + MCPClientOptions, + ServerDescriptor, + ToolsOptions, +} from './types' +export type { + TransportConfig, + TransportInput, + HttpTransportConfig, + SseTransportConfig, + StdioTransportConfig, +} from './transport' +export { + MCPConnectionError, + DuplicateToolNameError, + MCPToolNotFoundError, +} from './errors' +// Converters added in Phase 4: +// export { mcpResourceToContentPart } from './resources' +// export { mcpPromptToMessages } from './prompts' + diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts new file mode 100644 index 000000000..dcc4e945d --- /dev/null +++ b/packages/ai-mcp/tests/client.test.ts @@ -0,0 +1,26 @@ +// packages/ai-mcp/tests/client.test.ts +import { describe, expect, it } from 'vitest' +import { makeServerWithWeatherTool } from './helpers/in-memory-server' +import { createMCPClientFromTransport } from '../src/client' + +describe('createMCPClient', () => { + it('connects and returns discovered tools', async () => { + const { clientTransport } = await makeServerWithWeatherTool() + await using client = await createMCPClientFromTransport(clientTransport) + const tools = await client.tools() + expect(tools.map((t) => t.name)).toContain('get_weather') + expect(client.capabilities).toBeDefined() + }) + + it('closes on asyncDispose', async () => { + const { clientTransport } = await makeServerWithWeatherTool() + let client: Awaited> + { + await using c = await createMCPClientFromTransport(clientTransport) + client = c + expect(await c.tools()).toBeDefined() + } + // after scope exit the client is closed; calling tools() rejects + await expect(client.tools()).rejects.toThrow() + }) +}) From 7eb89dcd3da39a10e30d8c5d406a045f3daad38b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 15:38:58 +0200 Subject: [PATCH 09/71] feat(ai-mcp): definition-binding tools() with MCPToolNotFoundError + duplicate detection --- packages/ai-mcp/src/client.ts | 40 +++++++++++++++-------- packages/ai-mcp/tests/client.test.ts | 48 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 8c769f5f6..bb2da56ab 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -2,8 +2,12 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import type { ServerTool } from '@tanstack/ai' import { resolveTransport } from './transport' -import { toServerTools } from './tools' -import { MCPConnectionError, DuplicateToolNameError } from './errors' +import { makeMcpExecute, toServerTools } from './tools' +import { + MCPConnectionError, + DuplicateToolNameError, + MCPToolNotFoundError, +} from './errors' import type { AnyToolDefinition, AutomaticDescriptor, @@ -68,21 +72,31 @@ class MCPClientImpl<_TServer extends ServerDescriptor> ? maybeOptions : ((defsOrOptions as ToolsOptions) ?? {}) + let tools: Array if (isDefs) { - // TODO(4.1b): implement definition-binding branch - throw new Error( - 'definition-binding mode (tools(defs)) is implemented in the next step', + // Explicit path: bind each TanStack toolDefinition to the server by name. + const available = new Set( + (await this.#client.listTools()).tools.map((t) => t.name), ) + tools = (defsOrOptions as ReadonlyArray).map((def) => { + if (!available.has(def.name)) throw new MCPToolNotFoundError(def.name) + const tool = def.server( + makeMcpExecute(this.#client, def.name, Boolean(def.outputSchema)), + ) as ServerTool + if (this.prefix) tool.name = `${this.prefix}_${def.name}` + if (options.lazy) (tool as ServerTool & { lazy?: boolean }).lazy = true + return tool + }) + } else { + // Auto-discovery path. + const defs = (await this.#client.listTools()).tools + tools = toServerTools(this.#client, defs, { + prefix: this.prefix, + lazy: options.lazy, + }) } - // Auto-discovery path. - const defs = (await this.#client.listTools()).tools - const tools = toServerTools(this.#client, defs, { - prefix: this.prefix, - lazy: options.lazy, - }) - - // Local duplicate guard (within one client's own list). + // Local duplicate guard (within one client's own list — applies to both branches). const seen = new Set() for (const t of tools) { if (seen.has(t.name)) throw new DuplicateToolNameError(t.name) diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts index dcc4e945d..e5c739693 100644 --- a/packages/ai-mcp/tests/client.test.ts +++ b/packages/ai-mcp/tests/client.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { makeServerWithWeatherTool } from './helpers/in-memory-server' import { createMCPClientFromTransport } from '../src/client' +import { DuplicateToolNameError } from '../src/errors' describe('createMCPClient', () => { it('connects and returns discovered tools', async () => { @@ -12,6 +13,53 @@ describe('createMCPClient', () => { expect(client.capabilities).toBeDefined() }) + it('binds passed toolDefinitions to the server, typed + validated', async () => { + const { clientTransport } = await makeServerWithWeatherTool() + await using client = await createMCPClientFromTransport(clientTransport) + const { toolDefinition } = await import('@tanstack/ai') + const { z } = await import('zod') + const getWeather = toolDefinition({ + name: 'get_weather', + description: 'Get weather for a city', + inputSchema: z.object({ city: z.string() }), + }) + const tools = await client.tools([getWeather]) + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe('get_weather') + const result = await tools[0].execute!({ city: 'Brooklyn' }, { + toolCallId: 't', + emitCustomEvent: () => {}, + }) + expect(JSON.stringify(result)).toContain('Sunny in Brooklyn') + }) + + it('throws MCPToolNotFoundError for a definition the server lacks', async () => { + const { clientTransport } = await makeServerWithWeatherTool() + await using client = await createMCPClientFromTransport(clientTransport) + const { toolDefinition } = await import('@tanstack/ai') + const { z } = await import('zod') + const ghost = toolDefinition({ + name: 'does_not_exist', + description: 'A tool that does not exist on the server', + inputSchema: z.object({}), + }) + await expect(client.tools([ghost])).rejects.toThrow(/does_not_exist/) + }) + + it('throws DuplicateToolNameError when discovered tools collide', async () => { + const { clientTransport } = await makeServerWithWeatherTool() + await using client = await createMCPClientFromTransport(clientTransport) + const a = await client.tools() + const b = await client.tools() + expect(() => { + const seen = new Set() + for (const t of [...a, ...b]) { + if (seen.has(t.name)) throw new DuplicateToolNameError(t.name) + seen.add(t.name) + } + }).toThrow(DuplicateToolNameError) + }) + it('closes on asyncDispose', async () => { const { clientTransport } = await makeServerWithWeatherTool() let client: Awaited> From 714a6165d49b9a5dc53d28a3e4ed788fd8c37c25 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 16:11:26 +0200 Subject: [PATCH 10/71] fix(ai-mcp): type capabilities from the server descriptor generic; drop redundant lazy cast --- packages/ai-mcp/src/client.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index bb2da56ab..a6220bae7 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -33,11 +33,10 @@ export interface MCPClient< [Symbol.asyncDispose](): Promise } -// eslint-disable-next-line @typescript-eslint/no-unused-vars class MCPClientImpl<_TServer extends ServerDescriptor> implements MCPClient<_TServer> { - capabilities: Record = {} + capabilities: _TServer['capabilities'] = {} as _TServer['capabilities'] #client: Client #closed = false private prefix?: string @@ -55,7 +54,7 @@ class MCPClientImpl<_TServer extends ServerDescriptor> try { await this.#client.connect(transport) this.capabilities = (this.#client.getServerCapabilities() ?? - {}) as Record + {}) as _TServer['capabilities'] } catch (err) { throw new MCPConnectionError('Failed to connect to MCP server', err) } @@ -84,7 +83,7 @@ class MCPClientImpl<_TServer extends ServerDescriptor> makeMcpExecute(this.#client, def.name, Boolean(def.outputSchema)), ) as ServerTool if (this.prefix) tool.name = `${this.prefix}_${def.name}` - if (options.lazy) (tool as ServerTool & { lazy?: boolean }).lazy = true + if (options.lazy) tool.lazy = true return tool }) } else { From d8cd75b6266420afb2dbbb341475e06e2c5f06e9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 16:22:38 +0200 Subject: [PATCH 11/71] fix(ai-mcp): resolve capabilities cast lint in client --- packages/ai-mcp/src/client.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index a6220bae7..f114a2dd9 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -36,7 +36,7 @@ export interface MCPClient< class MCPClientImpl<_TServer extends ServerDescriptor> implements MCPClient<_TServer> { - capabilities: _TServer['capabilities'] = {} as _TServer['capabilities'] + capabilities: _TServer['capabilities'] = {} #client: Client #closed = false private prefix?: string @@ -53,8 +53,7 @@ class MCPClientImpl<_TServer extends ServerDescriptor> async connect(transport: Transport): Promise { try { await this.#client.connect(transport) - this.capabilities = (this.#client.getServerCapabilities() ?? - {}) as _TServer['capabilities'] + this.capabilities = this.#client.getServerCapabilities() ?? {} } catch (err) { throw new MCPConnectionError('Failed to connect to MCP server', err) } From 79a34c4d255d636ca62cc147170fc4c93dd7bce6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 17:00:17 +0200 Subject: [PATCH 12/71] style(ai-mcp): resolve eslint errors across the package --- packages/ai-mcp/src/client.ts | 43 +++++++++++++++++++---------------- packages/ai-mcp/src/tools.ts | 10 ++++---- packages/ai-mcp/src/types.ts | 2 +- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index f114a2dd9..5bea88a01 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -1,45 +1,47 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' -import type { ServerTool } from '@tanstack/ai' -import { resolveTransport } from './transport' -import { makeMcpExecute, toServerTools } from './tools' import { - MCPConnectionError, DuplicateToolNameError, + MCPConnectionError, MCPToolNotFoundError, } from './errors' +import { makeMcpExecute, toServerTools } from './tools' +import { resolveTransport } from './transport' import type { AnyToolDefinition, AutomaticDescriptor, - MappedServerTools, MCPClientOptions, + MappedServerTools, ServerDescriptor, ToolsOptions, } from './types' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { ServerTool } from '@tanstack/ai' export interface MCPClient< TServer extends ServerDescriptor = AutomaticDescriptor, > { readonly capabilities: TServer['capabilities'] /** Auto-discovery: every server tool as a ServerTool (args typed `unknown`). */ - tools(options?: ToolsOptions): Promise> - /** Explicit: bind these TanStack toolDefinitions to the server (typed + validated, allowlist). */ - tools>( - defs: TDefs, - options?: ToolsOptions, - ): Promise> + tools: { + (options?: ToolsOptions): Promise> + /** Explicit: bind these TanStack toolDefinitions to the server (typed + validated, allowlist). */ + >( + defs: TDefs, + options?: ToolsOptions, + ): Promise> + } // resources()/readResource()/prompts()/getPrompt() added in Phase 4. - close(): Promise - [Symbol.asyncDispose](): Promise + close: () => Promise + [Symbol.asyncDispose]: () => Promise } -class MCPClientImpl<_TServer extends ServerDescriptor> - implements MCPClient<_TServer> +class MCPClientImpl + implements MCPClient { - capabilities: _TServer['capabilities'] = {} - #client: Client + capabilities: TServer['capabilities'] = {} + readonly #client: Client #closed = false - private prefix?: string + private readonly prefix?: string constructor( prefix?: string, @@ -68,7 +70,8 @@ class MCPClientImpl<_TServer extends ServerDescriptor> const isDefs = Array.isArray(defsOrOptions) const options: ToolsOptions = isDefs ? maybeOptions - : ((defsOrOptions as ToolsOptions) ?? {}) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + : ((defsOrOptions as ToolsOptions) ?? {}) // SDK interop: defsOrOptions may be undefined at runtime even though TS types it as ToolsOptions here let tools: Array if (isDefs) { diff --git a/packages/ai-mcp/src/tools.ts b/packages/ai-mcp/src/tools.ts index 4d5878731..6136c98da 100644 --- a/packages/ai-mcp/src/tools.ts +++ b/packages/ai-mcp/src/tools.ts @@ -45,20 +45,20 @@ export function makeMcpExecute( ) { return async (args: unknown, ctx?: { abortSignal?: AbortSignal }) => { const result = await client.callTool( - { name: mcpName, arguments: (args as Record) ?? {} }, + { name: mcpName, arguments: (args ?? {}) as Record }, undefined, { signal: ctx?.abortSignal }, ) if (result.isError) { const text = Array.isArray(result.content) - ? mcpContentToTanstack(result.content as Array) + ? mcpContentToTanstack(result.content) : 'MCP tool returned an error' throw new Error(typeof text === 'string' ? text : JSON.stringify(text)) } - if (preferStructured && (result as any).structuredContent !== undefined) { - return (result as any).structuredContent + if (preferStructured && result.structuredContent !== undefined) { + return result.structuredContent } - return mcpContentToTanstack((result.content as Array) ?? []) + return mcpContentToTanstack(result.content as Array) } } diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index 1570e1f68..ce4abbaae 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -39,7 +39,7 @@ export interface ToolsOptions { * already returns a fully-typed `ServerTool`, so a * mapped tuple over the passed definitions preserves per-tool types. */ -export type ServerToolFromDef = D extends ToolDefinition< +export type ServerToolFromDef = TDef extends ToolDefinition< infer TInput, infer TOutput, infer TName From 6229e2b2d977030543733c9aeda08f01c5490519 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 17:56:46 +0200 Subject: [PATCH 13/71] feat(ai-mcp): createMCPClients multi-server pool with auto-prefix --- packages/ai-mcp/src/index.ts | 2 + packages/ai-mcp/src/pool.ts | 103 +++++++++++++++++++++++++++++ packages/ai-mcp/tests/pool.test.ts | 53 +++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 packages/ai-mcp/src/pool.ts create mode 100644 packages/ai-mcp/tests/pool.test.ts diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 538c12d6e..2c9d911fa 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -22,4 +22,6 @@ export { // Converters added in Phase 4: // export { mcpResourceToContentPart } from './resources' // export { mcpPromptToMessages } from './prompts' +export { createMCPClients } from './pool' +export type { MCPClients, MCPClientsConfig } from './pool' diff --git a/packages/ai-mcp/src/pool.ts b/packages/ai-mcp/src/pool.ts new file mode 100644 index 000000000..748e8e7c1 --- /dev/null +++ b/packages/ai-mcp/src/pool.ts @@ -0,0 +1,103 @@ +import { createMCPClient } from './client' +import { DuplicateToolNameError, MCPConnectionError } from './errors' +import type { MCPClient } from './client' +import type { MCPClientOptions, ServerDescriptor, ToolsOptions } from './types' +import type { ServerTool } from '@tanstack/ai' + +export type MCPClientsConfig = Record + +export interface MCPClients< + TServers extends Record = Record< + string, + ServerDescriptor + >, +> { + /** Typed per-server access (typed defs, resources, prompts on one server). */ + readonly clients: { [K in keyof TServers]: MCPClient } + /** + * All servers' tools, flattened and auto-prefixed by config key. + * `options` (including `lazy`) is forwarded to every client's `tools()`. + */ + tools: (options?: ToolsOptions) => Promise> + /** Close every client. */ + close: () => Promise + [Symbol.asyncDispose]: () => Promise +} + +export async function createMCPClients< + TServers extends Record = Record< + string, + ServerDescriptor + >, +>( + // When TServers is a generated `MCPServers` map, the config keys are + // constrained to the declared servers (missing/typo'd key → compile error). + config: { [K in keyof TServers]: MCPClientOptions } & MCPClientsConfig, +): Promise> { + const names = Object.keys(config) + + // Connect all in parallel; on any failure, close the successes and throw once. + const settled = await Promise.allSettled( + names.map(async (name) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const opts = config[name]! + // default prefix = config key; `prefix: ''` disables; explicit string wins + const prefix = opts.prefix === undefined ? name : opts.prefix || undefined + const client = await createMCPClient({ ...opts, prefix }) + return [name, client] as const + }), + ) + + const ok = settled.filter( + ( + r, + ): r is PromiseFulfilledResult]> => + r.status === 'fulfilled', + ) + const failed = settled + .map((r, i) => (r.status === 'rejected' ? names[i] : null)) + .filter((n): n is string => n !== null) + + if (failed.length > 0) { + // Cleanup already-connected clients — no leaks. + await Promise.allSettled(ok.map((r) => r.value[1].close())) + throw new MCPConnectionError( + `Failed to connect MCP server(s): ${failed.join(', ')}`, + ) + } + + const clients = Object.fromEntries(ok.map((r) => r.value)) as { + [K in keyof TServers]: MCPClient + } + + const pool: MCPClients = { + clients, + async tools(options?: ToolsOptions): Promise> { + const all = ( + await Promise.all( + Object.values(clients).map((c) => + (c as MCPClient).tools(options), + ), + ) + ).flat() + const seen = new Set() + for (const t of all) { + if (seen.has(t.name)) throw new DuplicateToolNameError(t.name) + seen.add(t.name) + } + return all + }, + async close(): Promise { + await Promise.all( + Object.values(clients).map((c) => + (c as MCPClient).close(), + ), + ) + }, + async [Symbol.asyncDispose](): Promise { + await pool.close() + }, + } + + return pool +} diff --git a/packages/ai-mcp/tests/pool.test.ts b/packages/ai-mcp/tests/pool.test.ts new file mode 100644 index 000000000..eaa24e007 --- /dev/null +++ b/packages/ai-mcp/tests/pool.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { makeServerWithWeatherTool } from './helpers/in-memory-server' +import { createMCPClients } from '../src/pool' + +describe('createMCPClients', () => { + it('connects to many servers and flattens auto-prefixed tools', async () => { + const a = await makeServerWithWeatherTool() + const b = await makeServerWithWeatherTool() + await using pool = await createMCPClients({ + alpha: { transport: a.clientTransport }, + beta: { transport: b.clientTransport }, + }) + const names = (await pool.tools()).map((t) => t.name) + expect(names).toContain('alpha_get_weather') + expect(names).toContain('beta_get_weather') // no collision despite same server tool name + }) + + it('exposes typed per-server access via .clients', async () => { + const a = await makeServerWithWeatherTool() + await using pool = await createMCPClients({ alpha: { transport: a.clientTransport } }) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(await pool.clients.alpha!.tools()).toBeDefined() + }) + + it('forwards ToolsOptions (lazy) to every server', async () => { + const a = await makeServerWithWeatherTool() + const b = await makeServerWithWeatherTool() + await using pool = await createMCPClients({ + alpha: { transport: a.clientTransport }, + beta: { transport: b.clientTransport }, + }) + const tools = await pool.tools({ lazy: true }) + expect(tools.length).toBeGreaterThan(0) + expect(tools.every((t) => t.lazy === true)).toBe(true) + }) + + it('closes already-connected clients and throws if one server fails', async () => { + const a = await makeServerWithWeatherTool() + const broken = { + start: async () => { + throw new Error('nope') + }, + send: async () => {}, + close: async () => {}, + } + await expect( + createMCPClients({ + alpha: { transport: a.clientTransport }, + beta: { transport: broken as any }, + }), + ).rejects.toThrow(/beta/) + }) +}) From 43a82a307dd6eadabe4f097ccbcdc93f56bca809 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 18:53:59 +0200 Subject: [PATCH 14/71] feat(ai-mcp): type tools() output from the generated ServerDescriptor generic --- packages/ai-mcp/src/client.ts | 3 ++- packages/ai-mcp/src/pool.ts | 9 ++++++- packages/ai-mcp/src/types.ts | 19 ++++++++++++++ packages/ai-mcp/tests/types.test-d.ts | 37 +++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/ai-mcp/tests/types.test-d.ts diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 5bea88a01..c8d958ff1 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -9,6 +9,7 @@ import { resolveTransport } from './transport' import type { AnyToolDefinition, AutomaticDescriptor, + DescriptorTools, MCPClientOptions, MappedServerTools, ServerDescriptor, @@ -23,7 +24,7 @@ export interface MCPClient< readonly capabilities: TServer['capabilities'] /** Auto-discovery: every server tool as a ServerTool (args typed `unknown`). */ tools: { - (options?: ToolsOptions): Promise> + (options?: ToolsOptions): Promise> /** Explicit: bind these TanStack toolDefinitions to the server (typed + validated, allowlist). */ >( defs: TDefs, diff --git a/packages/ai-mcp/src/pool.ts b/packages/ai-mcp/src/pool.ts index 748e8e7c1..0ba4d7042 100644 --- a/packages/ai-mcp/src/pool.ts +++ b/packages/ai-mcp/src/pool.ts @@ -66,7 +66,14 @@ export async function createMCPClients< ) } - const clients = Object.fromEntries(ok.map((r) => r.value)) as { + // Cast via `unknown`: the runtime map is descriptor-agnostic + // (`MCPClient` values), but per-key the public type is the + // narrowed `MCPClient`. Those no longer structurally overlap + // because `tools()` is now descriptor-typed (`DescriptorTools`), yet + // the generated descriptor is a compile-time overlay only — the runtime + // values are identical, so the through-`unknown` cast is sound here. + // eslint-disable-next-line no-restricted-syntax -- descriptor is a compile-time overlay; runtime MCPClient values are identical regardless of TServer + const clients = Object.fromEntries(ok.map((r) => r.value)) as unknown as { [K in keyof TServers]: MCPClient } diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index ce4abbaae..82251ecf8 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -50,3 +50,22 @@ export type ServerToolFromDef = TDef extends ToolDefinition< export type MappedServerTools> = { -readonly [K in keyof TDefs]: ServerToolFromDef } + +/** + * ServerTool typed from one descriptor tool entry, named by its key `TKey`. + * (Input/output stay `any` for now — the descriptor only carries the tool + * name into the discovery result; per-tool schema typing comes from the + * explicit `tools(defs)` overload via `MappedServerTools`.) + */ +type DescribedTool = ServerTool + +/** + * Discovery result typed from the generated descriptor. When TServer is the + * AutomaticDescriptor (no generated types), this collapses to `Array` + * (args typed `unknown`). + */ +export type DescriptorTools = Array< + { + [K in keyof TServer['tools'] & string]: DescribedTool + }[keyof TServer['tools'] & string] +> diff --git a/packages/ai-mcp/tests/types.test-d.ts b/packages/ai-mcp/tests/types.test-d.ts new file mode 100644 index 000000000..fafcb109d --- /dev/null +++ b/packages/ai-mcp/tests/types.test-d.ts @@ -0,0 +1,37 @@ +import { expectTypeOf } from 'vitest' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import type { MCPClient } from '../src/client' +import type { MappedServerTools, ServerDescriptor } from '../src/types' +import type { ServerTool } from '@tanstack/ai' + +interface WeatherServer extends ServerDescriptor { + tools: { get_weather: { input: { city: string }; output: string } } + resources: {} + prompts: {} + capabilities: { tools: {} } +} + +declare const client: MCPClient + +// Discovery: tools() (no args) resolves to typed ServerTools keyed by the +// descriptor — an array whose element matches ServerTool (not unknown-collapsed). +const discovered = await client.tools() +expectTypeOf(discovered).toBeArray() +expectTypeOf(discovered).items.toMatchTypeOf() + +// Default (no generic): discovery still yields an array of ServerTool +// (unchanged from before the descriptor overlay was added). +declare const defaultClient: MCPClient +const defaultDiscovered = await defaultClient.tools() +expectTypeOf(defaultDiscovered).toBeArray() +expectTypeOf(defaultDiscovered).items.toMatchTypeOf() + +// Defs overload still yields per-def types via MappedServerTools. +const getWeather = toolDefinition({ + name: 'get_weather', + description: 'Get weather for a city', + inputSchema: z.object({ city: z.string() }), +}) +const bound = await client.tools([getWeather]) +expectTypeOf(bound).toEqualTypeOf>() From 5686864007d0dcd80fae0c94a8c251b1a78934cd Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 19:08:54 +0200 Subject: [PATCH 15/71] feat(ai-mcp): mcpResourceToContentPart converter + resource test helper --- packages/ai-mcp/src/resources.ts | 23 ++++++++++++++ .../ai-mcp/tests/helpers/in-memory-server.ts | 16 ++++++++++ packages/ai-mcp/tests/resources.test.ts | 30 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 packages/ai-mcp/src/resources.ts create mode 100644 packages/ai-mcp/tests/resources.test.ts diff --git a/packages/ai-mcp/src/resources.ts b/packages/ai-mcp/src/resources.ts new file mode 100644 index 000000000..10835301e --- /dev/null +++ b/packages/ai-mcp/src/resources.ts @@ -0,0 +1,23 @@ +import type { ContentPart } from '@tanstack/ai' + +/** + * Converts a single MCP resource content block to a TanStack `ContentPart`. + * + * - `text` field present → `{ type: 'text', content: text }` + * - `blob` field present → `{ type: 'text', content: '[binary resource ]' }` + * - otherwise → `{ type: 'text', content: JSON.stringify(content) }` + */ +export function mcpResourceToContentPart(content: { + uri?: string + text?: string + blob?: string + [key: string]: unknown +}): ContentPart { + if (typeof content.text === 'string') { + return { type: 'text', content: content.text } + } + if (typeof content.blob === 'string') { + return { type: 'text', content: `[binary resource ${content.uri ?? ''}]` } + } + return { type: 'text', content: JSON.stringify(content) } +} diff --git a/packages/ai-mcp/tests/helpers/in-memory-server.ts b/packages/ai-mcp/tests/helpers/in-memory-server.ts index 321fbad93..3b926dfa6 100644 --- a/packages/ai-mcp/tests/helpers/in-memory-server.ts +++ b/packages/ai-mcp/tests/helpers/in-memory-server.ts @@ -20,3 +20,19 @@ export async function makeServerWithWeatherTool() { await server.connect(serverTransport) return { server, clientTransport } } + +/** 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' }) + server.registerResource( + 'hello', + 'file:///hello.txt', + { description: 'A simple text resource', mimeType: 'text/plain' }, + async (_uri) => ({ + contents: [{ uri: 'file:///hello.txt', text: 'hello from resource' }], + }), + ) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport } +} diff --git a/packages/ai-mcp/tests/resources.test.ts b/packages/ai-mcp/tests/resources.test.ts new file mode 100644 index 000000000..b48bffc7d --- /dev/null +++ b/packages/ai-mcp/tests/resources.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { mcpResourceToContentPart } from '../src/resources' + +describe('mcpResourceToContentPart', () => { + it('converts a text content block to a TextPart', () => { + const part = mcpResourceToContentPart({ uri: 'file:///x', text: 'hello' }) + expect(part.type).toBe('text') + expect((part as { type: 'text'; content: string }).content).toBe('hello') + }) + + it('converts a blob content block to a TextPart with binary placeholder', () => { + const part = mcpResourceToContentPart({ + uri: 'file:///img.png', + blob: 'abc123', + }) + expect(part.type).toBe('text') + expect((part as { type: 'text'; content: string }).content).toBe( + '[binary resource file:///img.png]', + ) + }) + + it('falls back to JSON.stringify for unknown content', () => { + const input = { uri: 'file:///unknown', mimeType: 'application/octet-stream' } + const part = mcpResourceToContentPart(input) + expect(part.type).toBe('text') + expect((part as { type: 'text'; content: string }).content).toBe( + JSON.stringify(input), + ) + }) +}) From a0c2f6098197a34111afa203d28324edec83c0cb Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 19:34:36 +0200 Subject: [PATCH 16/71] docs(skills): document MCP tools and tool-context abortSignal --- packages/ai-mcp/skills/ai-mcp/SKILL.md | 501 ++++++++++++++++++ .../ai/skills/ai-core/tool-calling/SKILL.md | 125 +++++ 2 files changed, 626 insertions(+) create mode 100644 packages/ai-mcp/skills/ai-mcp/SKILL.md diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md new file mode 100644 index 000000000..81f54c905 --- /dev/null +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -0,0 +1,501 @@ +--- +name: ai-mcp +description: > + Host-side Model Context Protocol (MCP) client for TanStack AI: connect to + external MCP servers, discover and run their tools inside any adapter's + chat() loop, read resources and prompts, generate end-to-end TypeScript types + with the bundled CLI, and manage lifecycle with close()/await using. +type: sub-skill +library: tanstack-ai +library_version: '0.10.0' +sources: + - 'TanStack/ai:docs/tools/mcp.md' + - 'TanStack/ai:packages/ai-mcp/src/client.ts' + - 'TanStack/ai:packages/ai-mcp/src/pool.ts' + - 'TanStack/ai:packages/ai-mcp/src/resources.ts' + - 'TanStack/ai:packages/ai-mcp/src/transport.ts' +--- + +# `@tanstack/ai-mcp` + +This skill covers the `@tanstack/ai-mcp` package. Read `ai-core/tool-calling/SKILL.md` +first — MCP tools flow into `chat()` the same way hand-written tools do. + +## When to use this package + +Use `@tanstack/ai-mcp` when: + +- A third-party MCP server exposes tools you want an agent or chat loop to call. +- You want to read MCP server resources (files, text, data) or prompts into a + `chat()` message list. +- You want end-to-end TypeScript types for an external MCP server's tool + signatures (via the bundled `generate` CLI). +- You are running tool execution on the server side and want to connect to MCP + servers with HTTP (Streamable HTTP or SSE) or stdio transports. + +Do NOT use this package for browser/client-side code — MCP connections are +server-side only. + +## Install + +```bash +pnpm add @tanstack/ai-mcp +``` + +The package has two subpath exports: + +- `.` — main client API (`createMCPClient`, `createMCPClients`, converters, types) +- `./stdio` — Node-only stdio transport factory (`stdioTransport`); import it + separately so edge bundles stay clean + +## `createMCPClient` — single server + +```typescript +import { createMCPClient } from '@tanstack/ai-mcp' + +const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + prefix: 'weather', // optional: prefixes all tool names (e.g. 'weather_get_forecast') + name: 'my-app', // optional: client identity sent to the server +}) +``` + +`createMCPClient` connects immediately and returns an `MCPClient`. Throws +`MCPConnectionError` if the connection fails. + +### Transports + +#### Streamable HTTP (default for internet-facing servers) + +```typescript +const client = await createMCPClient({ + transport: { + type: 'http', + url: 'https://mcp.example.com/mcp', + headers: { Authorization: 'Bearer sk-...' }, + }, +}) +``` + +#### SSE + +```typescript +const client = await createMCPClient({ + transport: { + type: 'sse', + url: 'https://mcp.example.com/sse', + headers: { Authorization: 'Bearer sk-...' }, + }, +}) +``` + +#### stdio (Node-only — import from `/stdio` subpath) + +```typescript +import { createMCPClient } from '@tanstack/ai-mcp' +import { stdioTransport } from '@tanstack/ai-mcp/stdio' + +const client = await createMCPClient({ + transport: stdioTransport({ + command: 'npx', + args: ['-y', 'my-mcp-server'], + env: { API_KEY: process.env.API_KEY ?? '' }, + }), +}) +``` + +#### Custom transport (escape hatch) + +Pass any SDK `Transport` instance directly: + +```typescript +import { createMCPClient } from '@tanstack/ai-mcp' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' + +const [clientTransport] = InMemoryTransport.createLinkedPair() +const client = await createMCPClient({ transport: clientTransport }) +``` + +## Three type-safety modes + +### Mode 1 — Auto-discovery (no types needed) + +`client.tools()` lists every tool the server exposes. Args are typed `unknown` +at compile time but the tool's JSON Schema is forwarded to the LLM. + +```typescript +const tools = await client.tools() +// tools: ServerTool[] (args unknown) + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + tools, +}) +``` + +Use `{ lazy: true }` to defer schema sending via the existing `LazyToolManager`: + +```typescript +const tools = await client.tools({ lazy: true }) +``` + +### Mode 2 — Typed via `toolDefinition` instances + +Pass bare `toolDefinition()` instances (no `.server()` call) to `client.tools([...])`. +The MCP client binds a `callTool` proxy as the execute function while +input/output validation and TypeScript types come from the definitions' Zod schemas. +Only the named tools are returned (allowlist = the definitions' `name`s). +Throws `MCPToolNotFoundError` if the server does not expose a tool with that name. + +```typescript +import { toolDefinition } from '@tanstack/ai' +import { createMCPClient } from '@tanstack/ai-mcp' +import { z } from 'zod' + +const getWeatherDef = toolDefinition({ + name: 'get_weather', + description: 'Current weather for a city', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temperature: z.number(), conditions: z.string() }), +}) + +const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, +}) + +// Returns MappedServerTools — fully typed per definition. +const tools = await client.tools([getWeatherDef]) +``` + +### Mode 3 — Generated end-to-end types (via `generate` CLI) + +Run `npx @tanstack/ai-mcp generate` to introspect live servers and emit a +`ServerDescriptor` interface per server. Pass the generated interface as the +generic to `createMCPClient(...)` to type the whole client. + +See the "Codegen CLI" section below for details. + +## Lifecycle + +**The caller owns the lifecycle.** `chat()` never closes the client. + +```typescript +// Option 1: explicit close +const client = await createMCPClient({ transport: { type: 'http', url: '...' } }) +try { + const tools = await client.tools() + const stream = chat({ adapter: openaiText('gpt-4o'), messages, tools }) + return toServerSentEventsResponse(stream) +} finally { + await client.close() +} + +// Option 2: await using (TypeScript 5.2+ with Symbol.asyncDispose) +await using client = await createMCPClient({ + transport: { type: 'http', url: '...' }, +}) +const tools = await client.tools() +// client.close() called automatically at scope exit +``` + +## `createMCPClients` — multiple servers + +Connect to many MCP servers in parallel. Each config key becomes the default +prefix for that server's tools, preventing name collisions across servers. + +```typescript +import { createMCPClients } from '@tanstack/ai-mcp' + +await using pool = await createMCPClients({ + github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } }, + linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } }, +}) + +// Tool names auto-prefixed: 'github_search_repos', 'linear_create_issue', etc. +const tools = await pool.tools() + +// Forward lazy flag to every server: +const lazyTools = await pool.tools({ lazy: true }) + +// Per-server typed access: +const githubTools = await pool.clients.github.tools() +``` + +`createMCPClients` connects in parallel, closes already-connected clients if +any connection fails (no leaks), and throws `MCPConnectionError` naming the +failed server(s). + +Override or disable prefixing: + +```typescript +await using pool = await createMCPClients({ + github: { transport: { ... }, prefix: 'gh' }, // 'gh_search_repos' + linear: { transport: { ... }, prefix: '' }, // 'create_issue' (no prefix) +}) +``` + +## 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. + +You can also read it in a hand-written server tool that wraps an MCP call: + +```typescript +const myTool = myDef.server(async (args, ctx) => { + // Forward to any async work that accepts an AbortSignal. + const result = await fetch('https://slow.api/data', { + signal: ctx?.abortSignal, + }) + return result.json() +}) +``` + +## Resources + +```typescript +// List all resources the server exposes. +const resources = await client.resources() + +// Read a specific resource by URI. +const resource = await client.readResource(resources[0].uri) + +// Convert one content block to a TanStack ContentPart. +import { mcpResourceToContentPart } from '@tanstack/ai-mcp' + +const part = mcpResourceToContentPart(resource.contents[0]) +// part: ContentPart (type: 'text' always for v1) +``` + +Inject resources into a chat turn: + +```typescript +import { chat } from '@tanstack/ai' +import { createMCPClient, mcpResourceToContentPart } from '@tanstack/ai-mcp' + +const client = await createMCPClient({ transport: { type: 'http', url: '...' } }) +const resource = await client.readResource('file:///project/README.md') +const parts = resource.contents.map(mcpResourceToContentPart) + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: [ + { + role: 'user', + content: [ + ...parts, + { type: 'text', content: 'Summarize this document.' }, + ], + }, + ], +}) +``` + +## Prompts + +```typescript +// List prompts the server exposes. +const prompts = await client.prompts() + +// Get a prompt (with optional arguments). +const prompt = await client.getPrompt('review_code', { language: 'TypeScript' }) + +// Convert to TanStack ModelMessage[] for use in chat(). +import { mcpPromptToMessages } from '@tanstack/ai-mcp' + +const messages = mcpPromptToMessages(prompt) +// messages: ModelMessage[] (role: 'user' | 'assistant') + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: [...messages, ...userMessages], +}) +``` + +## Codegen CLI + +Generate end-to-end TypeScript types by introspecting live MCP servers. + +**1. Create `mcp.config.ts` at your project root:** + +```typescript +import { defineConfig } from '@tanstack/ai-mcp' + +export default defineConfig({ + servers: { + github: { + transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, + // prefix must match the runtime createMCPClient({ prefix }) value + }, + }, + outFile: './src/mcp-types.generated.ts', +}) +``` + +**2. Run the generator:** + +```bash +npx @tanstack/ai-mcp generate +``` + +This connects to each server, lists its tools/resources/prompts, converts JSON +Schemas to TypeScript, and writes one `interface Server extends ServerDescriptor` +per server plus a combined `interface MCPServers` for pool typing. + +**3. Use the generated types:** + +```typescript +// Single server — narrows tools() return to descriptor-keyed tool names. +import type { GithubServer } from './src/mcp-types.generated' +import { createMCPClient } from '@tanstack/ai-mcp' + +const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, +}) +const tools = await client.tools() // typed to GithubServer's tool names + +// Multiple servers via the generated MCPServers map. +import type { MCPServers } from './src/mcp-types.generated' + +const pool = await createMCPClients({ + github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } }, +}) +// pool.clients.github is MCPClient +// missing/extra keys are a compile error +``` + +Codegen deps (`json-schema-to-typescript`, `jiti`) are bundled into the CLI bin +and do NOT appear in the library's runtime dependency graph. + +## Error classes + +- `MCPConnectionError` — thrown when a server connection fails or when calling + methods after `close()`. +- `MCPToolNotFoundError` — thrown from `client.tools([defs])` when a definition's + `name` is not exposed by the server. +- `DuplicateToolNameError` — thrown when two tools end up with the same name + (same server or across pool clients with no prefix). + +```typescript +import { + MCPConnectionError, + MCPToolNotFoundError, + DuplicateToolNameError, +} from '@tanstack/ai-mcp' +``` + +## Complete server-route example + +```typescript +// api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClients } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const { messages } = await request.json() + + await using pool = await createMCPClients({ + github: { + transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, + }, + linear: { + transport: { + type: 'http', + url: 'https://mcp.linear.app/mcp', + headers: { Authorization: `Bearer ${process.env.LINEAR_KEY ?? ''}` }, + }, + }, + }) + + const tools = await pool.tools() + + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + tools, + }) + + return toServerSentEventsResponse(stream) + // pool.close() called automatically by await using at scope exit +} +``` + +## Common Mistakes + +### a. HIGH: closing the client before the stream finishes + +`chat()` executes tools lazily as the model calls them during streaming. +If you close the MCP client before the response stream is fully consumed, +in-flight tool calls will fail. + +Wrong: + +```typescript +const tools = await client.tools() +const stream = chat({ adapter, messages, tools }) +await client.close() // closes before the stream runs tools +return toServerSentEventsResponse(stream) +``` + +Correct — use `await using` or close after stream consumption, or use `onFinish` +middleware: + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { createMCPClient } from '@tanstack/ai-mcp' + +const client = await createMCPClient({ transport: { type: 'http', url: '...' } }) + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + tools: await client.tools(), + middleware: [ + { + onFinish: async () => { + await client.close() + }, + }, + ], +}) +return toServerSentEventsResponse(stream) +``` + +### b. HIGH: importing `stdioTransport` from the main entry point + +`stdioTransport` is only available from `@tanstack/ai-mcp/stdio`. Importing it +from `@tanstack/ai-mcp` will fail with a module-not-found error and would +bundle Node.js child-process code into edge bundles. + +Wrong: + +```typescript +import { stdioTransport } from '@tanstack/ai-mcp' // does not exist here +``` + +Correct: + +```typescript +import { stdioTransport } from '@tanstack/ai-mcp/stdio' +``` + +### c. MEDIUM: using `client.tools([defs])` without matching names + +The name field on each `toolDefinition` must exactly match the tool name the MCP +server exposes. Mismatches throw `MCPToolNotFoundError` at call time, not at +type-check time (unless generated types are in use). + +### d. MEDIUM: not setting a prefix when multiple servers share tool names + +If two servers both expose a tool named `search`, the merged pool will throw +`DuplicateToolNameError`. Use `createMCPClients` (which auto-prefixes by config +key) or set an explicit `prefix` on each `createMCPClient` call. + +## Cross-References + +- See also: ai-core/tool-calling/SKILL.md — MCP tools are ServerTools; all tool + patterns (approval, lazy, client-side) apply. +- See also: ai-core/chat-experience/SKILL.md — wiring tools into `chat()`. diff --git a/packages/ai/skills/ai-core/tool-calling/SKILL.md b/packages/ai/skills/ai-core/tool-calling/SKILL.md index d15dfaa54..5e28f1273 100644 --- a/packages/ai/skills/ai-core/tool-calling/SKILL.md +++ b/packages/ai/skills/ai-core/tool-calling/SKILL.md @@ -375,6 +375,131 @@ gets the full schema, then calls `compareProducts` directly. Once discovered, a tool stays available for the conversation. When all lazy tools are discovered, the discovery tool is removed automatically. +## MCP Tools + +`@tanstack/ai-mcp` lets a server-side `chat()` call discover and invoke tools +hosted on any MCP server (Streamable HTTP, SSE, or stdio). + +### Basic usage — auto-discovery + +```typescript +// api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const { messages } = await request.json() + + // 1. Connect to the MCP server. + const mcp = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) + + try { + // 2. Discover all tools from the server (returns ServerTool[]). + const mcpTools = await mcp.tools() + + // 3. Spread them into chat() — they work exactly like hand-written tools. + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + tools: [...mcpTools], + }) + return toServerSentEventsResponse(stream) + } finally { + // 4. Caller owns the lifecycle — chat() never closes the client. + await mcp.close() + } +} +``` + +### Typed path — pass toolDefinition instances + +Pass bare `toolDefinition()` instances (no `.server()`) to `client.tools([...])`. +The MCP client supplies a `callTool` proxy as the execute function, while +input/output validation and types come from the definitions' Zod schemas. + +```typescript +import { toolDefinition } from '@tanstack/ai' +import { createMCPClient } from '@tanstack/ai-mcp' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'get_weather', + description: 'Current weather for a city', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temperature: z.number(), conditions: z.string() }), +}) + +const mcp = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, +}) + +// Returns ServerTool[] typed to the definitions' input/output schemas. +// Throws MCPToolNotFoundError if the server does not expose a tool with that name. +const tools = await mcp.tools([getWeather]) + +const stream = chat({ adapter: openaiText('gpt-4o'), messages, tools }) +``` + +### Multiple servers with `createMCPClients` + +```typescript +import { createMCPClients } from '@tanstack/ai-mcp' + +// Each key becomes the default prefix for that server's tools. +await using pool = await createMCPClients({ + github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } }, + linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } }, +}) + +// Tools auto-prefixed: 'github_search_repos', 'linear_create_issue', etc. +const tools = await pool.tools() + +const stream = chat({ adapter: openaiText('gpt-4o'), messages, tools }) +``` + +Use `pool.clients.` for typed per-server access (resources, prompts, typed +`tools([defs])` overload). + +### `ToolExecutionContext.abortSignal` — cancelling long-running tools + +Every server tool's execute function now receives `abortSignal` in its context. +When the chat run aborts (e.g. the client disconnects or calls the run's +`abortController`), the signal fires and any in-flight `callTool` call is +cancelled automatically. + +You can also forward it from your own server tools: + +```typescript +const longRunningTool = myToolDef.server(async (args, ctx) => { + // Forward to fetch, a DB query, or an MCP callTool call. + const response = await fetch('https://slow.api/data', { + signal: ctx?.abortSignal, + }) + return response.json() +}) +``` + +MCP tools wire this automatically — `makeMcpExecute` passes `ctx?.abortSignal` +as the `signal` option to `client.callTool(...)`, so MCP server calls cancel +with the chat run without any extra code. + +### stdio transport (Node-only) + +```typescript +import { createMCPClient } from '@tanstack/ai-mcp' +import { stdioTransport } from '@tanstack/ai-mcp/stdio' + +const mcp = await createMCPClient({ + transport: stdioTransport({ command: 'npx', args: ['-y', 'my-mcp-server'] }), +}) +``` + +Import `stdioTransport` from the `/stdio` subpath only — it contains Node.js +`child_process` imports and must not be bundled for edge runtimes. + ## Common Mistakes ### a. HIGH: Not passing tool definitions to both server and client From 5311cd58f2fd68e4366393458e457b776f577213 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 19:42:13 +0200 Subject: [PATCH 17/71] docs: add MCP server support guide --- docs/config.json | 4 + docs/tools/mcp.md | 563 ++++++++++++++++++++++++++++++++++++++++++++ docs/tools/tools.md | 1 + 3 files changed, 568 insertions(+) create mode 100644 docs/tools/mcp.md diff --git a/docs/config.json b/docs/config.json index b712f229c..59cc4c84d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -82,6 +82,10 @@ { "label": "Lazy Tool Discovery", "to": "tools/lazy-tool-discovery" + }, + { + "label": "MCP Server Tools", + "to": "tools/mcp" } ] }, diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md new file mode 100644 index 000000000..822bd6267 --- /dev/null +++ b/docs/tools/mcp.md @@ -0,0 +1,563 @@ +--- +title: MCP Server Tools +id: mcp +order: 8 +description: "Connect TanStack AI to any Model Context Protocol server to discover and execute its tools, resources, and prompts inside chat()." +keywords: + - tanstack ai + - mcp + - model context protocol + - mcp tools + - mcp client + - server tools + - createMCPClient + - createMCPClients + - codegen + - type safety +--- + +`@tanstack/ai-mcp` is a host-side [Model Context Protocol](https://modelcontextprotocol.io) client for TanStack AI. It connects your server route to any MCP-compliant server and makes that server's tools, resources, and prompts available inside `chat()`. + +> MCP tool execution is **server-side only**. The `createMCPClient` call lives in a server route (or serverless function) — never in browser code. + +## Installation + +```bash +pnpm add @tanstack/ai-mcp @modelcontextprotocol/sdk +``` + +## Quick Start + +```ts +// app/api/chat/route.ts (Next.js App Router example) +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const mcp = await createMCPClient({ + transport: { type: 'http', url: 'https://my-mcp-server.example.com/mcp' }, + }) + + try { + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages, + tools: await mcp.tools(), + }) + + return toServerSentEventsResponse(stream) + } finally { + await mcp.close() + } +} +``` + +On the client side, consume the stream with `useChat` exactly as you would any other TanStack AI endpoint: + +```tsx +// components/Chat.tsx +import { useChat } from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' + +export function Chat() { + const { messages, sendMessage, status } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + }) + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: {m.content} +
+ ))} + +
+ ) +} +``` + +## Transports + +### HTTP (Streamable HTTP) + +The preferred transport for remote servers. Uses the MCP Streamable HTTP protocol. + +```ts +const mcp = await createMCPClient({ + transport: { + type: 'http', + url: 'https://my-mcp-server.example.com/mcp', + headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, + }, +}) +``` + +### SSE (Server-Sent Events) + +For servers that implement the legacy SSE transport. + +```ts +const mcp = await createMCPClient({ + transport: { + type: 'sse', + url: 'https://my-mcp-server.example.com/sse', + headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, + }, +}) +``` + +### stdio (Node.js only) + +For spawning a local MCP process. Because stdio imports Node-native modules, it is isolated behind a subpath import so edge bundles stay clean. + +```ts +import { stdioTransport } from '@tanstack/ai-mcp/stdio' +import { createMCPClient } from '@tanstack/ai-mcp' + +const mcp = await createMCPClient({ + transport: stdioTransport({ + command: 'node', + args: ['./my-mcp-server.js'], + env: { API_KEY: process.env.API_KEY ?? '' }, + }), +}) +``` + +### Custom transport (escape hatch) + +Pass any `Transport` instance from `@modelcontextprotocol/sdk` directly: + +```ts +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +const transport = new StreamableHTTPClientTransport(new URL('https://example.com/mcp')) +const mcp = await createMCPClient({ transport }) +``` + +## Three Modes of Type Safety + +### Mode 1 — Auto-discovery (`client.tools()`) + +Call `tools()` with no arguments to discover every tool the server exposes. This requires no extra setup. Tool argument types are `unknown` at compile time; the MCP JSON Schema is used for runtime validation. + +```ts +const tools = await mcp.tools() +// tools: ServerTool[] — args typed unknown at compile time +``` + +### 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. + +```ts +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const searchDef = toolDefinition({ + name: 'search', + description: 'Search for items', + inputSchema: z.object({ query: z.string() }), + outputSchema: z.array(z.object({ id: z.string(), title: z.string() })), +}) + +const tools = await mcp.tools([searchDef]) +// tools[0].execute is typed: (args: { query: string }) => ... +``` + +### Mode 3 — Generated types (`createMCPClient`) + +Run the CLI against a live server to generate per-server `interface` types. Pass the generated type as a generic to get end-to-end type safety with zero runtime overhead. + +```ts +// mcp-types.generated.ts — produced by `npx @tanstack/ai-mcp generate` +import type { GithubServer } from './mcp-types.generated' +import { createMCPClient } from '@tanstack/ai-mcp' + +const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.GITHUB_MCP_URL! }, +}) + +const tools = await mcp.tools() +// Each tool's name is now a literal type from GithubServer['tools'] +``` + +See [Code Generation](#code-generation) below for CLI setup. + +## Multi-Server Pool + +`createMCPClients` connects to many servers in parallel and merges their tools into one flat array. Each server's tools are automatically prefixed with the config key to prevent name collisions. + +```ts +import { createMCPClients } from '@tanstack/ai-mcp' + +const pool = await createMCPClients({ + github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } }, + linear: { transport: { type: 'http', url: process.env.LINEAR_MCP_URL! } }, +}) + +// tools: [github_search_repos, github_create_issue, linear_create_issue, ...] +const tools = await pool.tools() +``` + +`pool.tools()` collects all servers' tools and throws `DuplicateToolNameError` if any two names collide after prefixing. + +### Per-server access + +```ts +const linearTools = await pool.clients.linear.tools() +const resources = await pool.clients.github.resources() +``` + +### Disable or override the prefix + +```ts +const pool = await createMCPClients({ + github: { + transport: { type: 'http', url: process.env.GITHUB_MCP_URL! }, + prefix: 'gh', // override: "gh_search_repos" + }, + internal: { + transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! }, + prefix: '', // disable prefix entirely + }, +}) +``` + +### Closing the pool + +```ts +await pool.close() +// or +await using pool = await createMCPClients({ ... }) +``` + +If any server fails to connect, already-connected clients are closed before the error is thrown — no leaks. + +## Lifecycle + +The MCP client is **caller-owned**. `chat()` never closes it. + +### Manual close + +```ts +const mcp = await createMCPClient({ transport: { type: 'http', url } }) +try { + const stream = chat({ ..., tools: await mcp.tools() }) + return toServerSentEventsResponse(stream) +} finally { + await mcp.close() +} +``` + +### `await using` (Explicit Resource Management) + +If your runtime supports `Symbol.asyncDispose` (Node 18.2+ with TypeScript `target: "es2022"` + `lib: ["esnext"]`): + +```ts +await using mcp = await createMCPClient({ transport: { type: 'http', url } }) +// mcp.close() is called automatically when the block exits +const stream = chat({ ..., tools: await mcp.tools() }) +return toServerSentEventsResponse(stream) +``` + +## Tool Name Collisions + +When mixing tools from multiple sources, duplicate names throw `DuplicateToolNameError`: + +```ts +import { DuplicateToolNameError } from '@tanstack/ai-mcp' + +try { + const tools = await pool.tools() +} catch (err) { + if (err instanceof DuplicateToolNameError) { + console.error('Conflicting tool name:', err.toolName) + // Fix: set a unique prefix on one of the clients + } +} +``` + +Use a unique `prefix` on each client to avoid collisions — `createMCPClients` does this automatically using the config key. + +## Lazy Tool Discovery + +Pass `{ lazy: true }` to defer sending tool schemas to the LLM until it explicitly asks for them. This reduces token usage when working with tool-heavy servers. + +```ts +const tools = await mcp.tools({ lazy: true }) +// All tools are marked lazy: true +``` + +Works with the pool too: + +```ts +const tools = await pool.tools({ lazy: true }) +``` + +See [Lazy Tool Discovery](./lazy-tool-discovery) for how the LLM discovers lazy tools at runtime. + +## Resources + +MCP resources are context documents (files, database records, web pages) the server exposes. Fetch them and inject them into `chat()` as content parts. + +```ts +import { mcpResourceToContentPart } from '@tanstack/ai-mcp' + +const resources = await mcp.resources() +// resources: Array<{ uri: string; name: string; ... }> + +const readResult = await mcp.readResource(resources[0].uri) +const parts = readResult.contents.map(mcpResourceToContentPart) + +// Inject as part of a user message +const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: [ + { + role: 'user', + content: [ + ...parts, + { type: 'text', content: 'Summarize the above document.' }, + ], + }, + ], +}) +``` + +`mcpResourceToContentPart` maps each MCP content block to a `ContentPart`: +- `text` field present → `{ type: 'text', content: text }` +- `blob` field present → `{ type: 'text', content: '[binary resource ]' }` +- otherwise → `{ type: 'text', content: JSON.stringify(content) }` + +### Resource templates + +```ts +const templates = await mcp.resourceTemplates() +// templates: Array +``` + +## 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. + +```ts +const controller = new AbortController() + +const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages, + tools: await mcp.tools(), + abortController: controller, +}) + +// Cancel the run and all in-flight MCP tool calls: +controller.abort() +``` + +## Code Generation + +The `generate` CLI introspects a live MCP server and emits TypeScript interface types for [Mode 3](#mode-3--generated-types-createmcpclientgeneratedserver) type safety. + +### 1. Create `mcp.config.ts` + +```ts +// mcp.config.ts +import { defineConfig } from '@tanstack/ai-mcp/cli' + +export default defineConfig({ + servers: { + github: { + transport: { type: 'http', url: 'https://github-mcp.example.com/mcp' }, + }, + linear: { + transport: { type: 'http', url: 'https://linear-mcp.example.com/mcp' }, + prefix: 'linear', // must match runtime createMCPClient({ prefix }) + }, + }, + outFile: './mcp-types.generated.ts', +}) +``` + +### 2. Run the generator + +```bash +npx @tanstack/ai-mcp generate +``` + +### 3. Inspect the output + +The generator emits one interface per server plus a combined pool map: + +```ts +// mcp-types.generated.ts — AUTO-GENERATED, do not edit + +import type { ServerDescriptor } from '@tanstack/ai-mcp' + +export interface GithubServer extends ServerDescriptor { + tools: { + 'search_repositories': { input: { query: string; limit?: number }; output: unknown } + 'create_issue': { input: { repo: string; title: string; body?: string }; output: unknown } + } + resources: {} + prompts: {} + capabilities: { tools: {} } & Record +} + +export interface LinearServer extends ServerDescriptor { + tools: { + 'linear_create_issue': { input: { title: string; teamId: string }; output: unknown } + } + resources: {} + prompts: {} + capabilities: { tools: {} } & Record +} + +export interface MCPServers extends Record { + 'github': GithubServer + 'linear': LinearServer +} +``` + +### 4. Use generated types at runtime + +**Single server:** + +```ts +import type { GithubServer } from './mcp-types.generated' +import { createMCPClient } from '@tanstack/ai-mcp' + +const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.GITHUB_MCP_URL! }, +}) + +const tools = await mcp.tools() +// Each tool name is narrowed from GithubServer['tools'] +``` + +**Multi-server pool:** + +```ts +import type { MCPServers } from './mcp-types.generated' +import { createMCPClients } from '@tanstack/ai-mcp' + +const pool = await createMCPClients({ + github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } }, + linear: { + transport: { type: 'http', url: process.env.LINEAR_MCP_URL! }, + prefix: 'linear', + }, +}) + +// Config keys are constrained to the declared servers — a typo is a compile error +const tools = await pool.tools() +``` + +## Full Server + Client Example + +Here is a complete Next.js App Router route that connects to two MCP servers and streams the response to the browser. + +**Server route (`app/api/chat/route.ts`):** + +```ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClients } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if (typeof body !== 'object' || body === null || !Array.isArray(body.messages)) { + return new Response('Bad request', { status: 400 }) + } + + const pool = await createMCPClients({ + github: { + transport: { + type: 'http', + url: process.env.GITHUB_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, + }, + }, + linear: { + transport: { + type: 'http', + url: process.env.LINEAR_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, + }, + }, + }) + + try { + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + tools: await pool.tools(), + }) + + return toServerSentEventsResponse(stream) + } finally { + await pool.close() + } +} +``` + +**Client component (`components/Chat.tsx`):** + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +export function Chat() { + const { messages, sendMessage, status } = useChat(chatOptions) + + return ( +
+
    + {messages.map((m) => ( +
  • + {m.role}: {m.content} +
  • + ))} +
+ +
+ ) +} +``` + +## Error Reference + +| Error class | When thrown | +|---|---| +| `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 | + +## Next Steps + +- [Tools Overview](./tools) — TanStack AI tool concepts +- [Server Tools](./server-tools) — Server-side tool execution patterns +- [Lazy Tool Discovery](./lazy-tool-discovery) — Reduce token usage with large tool sets +- [Tool Approval Flow](./tool-approval) — Require user confirmation before executing tools diff --git a/docs/tools/tools.md b/docs/tools/tools.md index 3d4ffdd56..28557c361 100644 --- a/docs/tools/tools.md +++ b/docs/tools/tools.md @@ -347,3 +347,4 @@ Tools go through different states during execution: - [Client Tools](./client-tools) - Learn about client-side tool execution - [Tool Approval Flow](./tool-approval) - Implement approval workflows - [How Tools Work](./tool-architecture) - Deep dive into the tool architecture +- [MCP Server Tools](./mcp) - Connect to external MCP servers for additional tools From 729cb707c4d05ed4a1cb41c87154a9ddb1cd78b9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 19:46:47 +0200 Subject: [PATCH 18/71] chore: changeset for @tanstack/ai-mcp --- .changeset/mcp-server-support.md | 6 ++++++ packages/ai-mcp/package.json | 12 ++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 .changeset/mcp-server-support.md diff --git a/.changeset/mcp-server-support.md b/.changeset/mcp-server-support.md new file mode 100644 index 000000000..6ddb1a954 --- /dev/null +++ b/.changeset/mcp-server-support.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai-mcp': minor +'@tanstack/ai': minor +--- + +Add `@tanstack/ai-mcp`: a host-side Model Context Protocol client. Discover and run MCP server tools (and read resources/prompts) inside any adapter's `chat()` loop, with three type-safety modes (auto-discovery, hand-written `toolDefinition()` binding, and generated end-to-end types via `npx @tanstack/ai-mcp generate`). Includes `createMCPClients` for connecting to multiple servers with auto-prefixed tool names. Also exposes `abortSignal` on `ToolExecutionContext` so long-running tools (e.g. MCP `callTool`) cancel with the chat run. diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json index 09b4cf87e..17a0df777 100644 --- a/packages/ai-mcp/package.json +++ b/packages/ai-mcp/package.json @@ -26,15 +26,15 @@ "test:types": "tsc" }, "dependencies": { - "@tanstack/ai": "workspace:*", - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/sdk": "^1.29.0", + "@tanstack/ai": "workspace:*" }, "devDependencies": { - "json-schema-to-typescript": "^15.0.4", + "@vitest/coverage-v8": "4.0.14", "jiti": "^2.4.2", - "tsup": "^8.3.5", + "json-schema-to-typescript": "^15.0.4", + "tsup": "^8.5.1", "vite": "^7.3.3", - "zod": "^4.2.0", - "@vitest/coverage-v8": "4.0.14" + "zod": "^4.2.0" } } From ef25059027230670dadf0890cea0898f871dd8ff Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 19:52:41 +0200 Subject: [PATCH 19/71] feat(ai-mcp): client resources() / readResource() / resourceTemplates() --- packages/ai-mcp/src/client.ts | 25 ++++++++++++++++++++++++- packages/ai-mcp/src/index.ts | 2 +- packages/ai-mcp/tests/resources.test.ts | 17 +++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index c8d958ff1..1d9b2a5c2 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -16,6 +16,11 @@ import type { ToolsOptions, } from './types' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { + ReadResourceResult, + Resource, + ResourceTemplate, +} from '@modelcontextprotocol/sdk/types.js' import type { ServerTool } from '@tanstack/ai' export interface MCPClient< @@ -31,7 +36,10 @@ export interface MCPClient< options?: ToolsOptions, ): Promise> } - // resources()/readResource()/prompts()/getPrompt() added in Phase 4. + resources: () => Promise> + readResource: (uri: string) => Promise + resourceTemplates: () => Promise> + // prompts()/getPrompt() added in Phase 4. close: () => Promise [Symbol.asyncDispose]: () => Promise } @@ -107,6 +115,21 @@ class MCPClientImpl return tools } + async resources(): Promise> { + if (this.#closed) throw new MCPConnectionError('MCP client is closed') + return (await this.#client.listResources()).resources + } + + async readResource(uri: string): Promise { + if (this.#closed) throw new MCPConnectionError('MCP client is closed') + return this.#client.readResource({ uri }) + } + + async resourceTemplates(): Promise> { + if (this.#closed) throw new MCPConnectionError('MCP client is closed') + return (await this.#client.listResourceTemplates()).resourceTemplates + } + async close(): Promise { if (this.#closed) return this.#closed = true diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 2c9d911fa..2b0eb6633 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -20,7 +20,7 @@ export { MCPToolNotFoundError, } from './errors' // Converters added in Phase 4: -// export { mcpResourceToContentPart } from './resources' +export { mcpResourceToContentPart } from './resources' // export { mcpPromptToMessages } from './prompts' export { createMCPClients } from './pool' export type { MCPClients, MCPClientsConfig } from './pool' diff --git a/packages/ai-mcp/tests/resources.test.ts b/packages/ai-mcp/tests/resources.test.ts index b48bffc7d..607bdca8e 100644 --- a/packages/ai-mcp/tests/resources.test.ts +++ b/packages/ai-mcp/tests/resources.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' +import { createMCPClientFromTransport } from '../src/client' import { mcpResourceToContentPart } from '../src/resources' +import { makeServerWithResource } from './helpers/in-memory-server' describe('mcpResourceToContentPart', () => { it('converts a text content block to a TextPart', () => { @@ -28,3 +30,18 @@ describe('mcpResourceToContentPart', () => { ) }) }) + +describe('MCPClient resource methods (connected)', () => { + it('resources() / readResource() round-trip via in-memory server', async () => { + await using client = await createMCPClientFromTransport( + (await makeServerWithResource()).clientTransport, + ) + + const list = await client.resources() + expect(list.length).toBeGreaterThan(0) + + const read = await client.readResource(list[0]!.uri) + const part = mcpResourceToContentPart(read.contents[0]!) + expect(part.type).toBe('text') + }) +}) From 440bbdd10fee71066c797071b7e64531d87d71f9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 20:25:36 +0200 Subject: [PATCH 20/71] feat(ai-mcp): mcpPromptToMessages converter + prompt test helper --- packages/ai-mcp/src/prompts.ts | 26 +++++++++++ .../ai-mcp/tests/helpers/in-memory-server.ts | 23 ++++++++++ packages/ai-mcp/tests/prompts.test.ts | 46 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 packages/ai-mcp/src/prompts.ts create mode 100644 packages/ai-mcp/tests/prompts.test.ts diff --git a/packages/ai-mcp/src/prompts.ts b/packages/ai-mcp/src/prompts.ts new file mode 100644 index 000000000..018fe1ea8 --- /dev/null +++ b/packages/ai-mcp/src/prompts.ts @@ -0,0 +1,26 @@ +import type { ModelMessage } from '@tanstack/ai' + +/** + * Convert an MCP GetPromptResult into an array of ModelMessages suitable for + * passing to `chat()` or any TanStack AI adapter. + * + * @param prompt - An object with a `messages` array as returned by the MCP + * `prompts/get` endpoint. + * @returns An array of {@link ModelMessage} values. + */ +export function mcpPromptToMessages(prompt: { + messages: Array<{ + role: string + content?: { type: string; text?: string } | null + }> +}): Array { + return prompt.messages.map((m) => { + const role: 'user' | 'assistant' = + m.role === 'assistant' ? 'assistant' : 'user' + const content = + m.content?.type === 'text' && m.content.text !== undefined + ? m.content.text + : JSON.stringify(m.content) + return { role, content } + }) +} diff --git a/packages/ai-mcp/tests/helpers/in-memory-server.ts b/packages/ai-mcp/tests/helpers/in-memory-server.ts index 3b926dfa6..c29b155e8 100644 --- a/packages/ai-mcp/tests/helpers/in-memory-server.ts +++ b/packages/ai-mcp/tests/helpers/in-memory-server.ts @@ -36,3 +36,26 @@ export async function makeServerWithResource() { await server.connect(serverTransport) return { server, clientTransport } } + +/** Build a connected (server, clientTransport) pair that exposes a prompt accepting a `code` argument. */ +export async function makeServerWithPrompt() { + const server = new McpServer({ name: 'prompt-server', version: '1.0.0' }) + server.registerPrompt( + 'review-code', + { + description: 'Review a code snippet', + argsSchema: { code: z.string() }, + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Please review: ${code}` }, + }, + ], + }), + ) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport } +} diff --git a/packages/ai-mcp/tests/prompts.test.ts b/packages/ai-mcp/tests/prompts.test.ts new file mode 100644 index 000000000..bee3a636c --- /dev/null +++ b/packages/ai-mcp/tests/prompts.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { mcpPromptToMessages } from '../src/prompts' + +describe('mcpPromptToMessages', () => { + it('converts a user text message correctly', () => { + const prompt = { + messages: [{ role: 'user', content: { type: 'text', text: 'review x' } }], + } + const messages = mcpPromptToMessages(prompt) + + expect(messages).toHaveLength(1) + expect(messages[0]!.role).toBe('user') + expect(messages[0]!.content).toBe('review x') + }) + + it('maps assistant role correctly', () => { + const prompt = { + messages: [ + { role: 'assistant', content: { type: 'text', text: 'looks good' } }, + ], + } + const messages = mcpPromptToMessages(prompt) + + expect(messages[0]!.role).toBe('assistant') + expect(messages[0]!.content).toBe('looks good') + }) + + it('falls back to JSON.stringify for non-text content', () => { + const content = { type: 'image', data: 'base64...' } + const prompt = { + messages: [{ role: 'user', content }], + } + const messages = mcpPromptToMessages(prompt) + + expect(messages[0]!.content).toBe(JSON.stringify(content)) + }) + + it('treats unknown roles as user', () => { + const prompt = { + messages: [{ role: 'system', content: { type: 'text', text: 'hi' } }], + } + const messages = mcpPromptToMessages(prompt) + + expect(messages[0]!.role).toBe('user') + }) +}) From 9fd124cf5800483e65aa25cb81aeb15bd48329d9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 20:54:13 +0200 Subject: [PATCH 21/71] feat(ai-mcp): client prompts() / getPrompt() --- packages/ai-mcp/src/client.ts | 15 ++++++++++++++- packages/ai-mcp/src/index.ts | 2 +- packages/ai-mcp/tests/prompts.test.ts | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 1d9b2a5c2..1ef3441f5 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -17,6 +17,8 @@ import type { } from './types' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import type { + GetPromptResult, + Prompt, ReadResourceResult, Resource, ResourceTemplate, @@ -39,7 +41,8 @@ export interface MCPClient< resources: () => Promise> readResource: (uri: string) => Promise resourceTemplates: () => Promise> - // prompts()/getPrompt() added in Phase 4. + prompts: () => Promise> + getPrompt: (name: string, args?: Record) => Promise close: () => Promise [Symbol.asyncDispose]: () => Promise } @@ -130,6 +133,16 @@ class MCPClientImpl return (await this.#client.listResourceTemplates()).resourceTemplates } + async prompts(): Promise> { + if (this.#closed) throw new MCPConnectionError('MCP client is closed') + return (await this.#client.listPrompts()).prompts + } + + async getPrompt(name: string, args?: Record): Promise { + if (this.#closed) throw new MCPConnectionError('MCP client is closed') + return this.#client.getPrompt({ name, arguments: args }) + } + async close(): Promise { if (this.#closed) return this.#closed = true diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 2b0eb6633..0a7090512 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -21,7 +21,7 @@ export { } from './errors' // Converters added in Phase 4: export { mcpResourceToContentPart } from './resources' -// export { mcpPromptToMessages } from './prompts' +export { mcpPromptToMessages } from './prompts' export { createMCPClients } from './pool' export type { MCPClients, MCPClientsConfig } from './pool' diff --git a/packages/ai-mcp/tests/prompts.test.ts b/packages/ai-mcp/tests/prompts.test.ts index bee3a636c..edf40ed3b 100644 --- a/packages/ai-mcp/tests/prompts.test.ts +++ b/packages/ai-mcp/tests/prompts.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' +import { createMCPClientFromTransport } from '../src/client' import { mcpPromptToMessages } from '../src/prompts' +import { makeServerWithPrompt } from './helpers/in-memory-server' describe('mcpPromptToMessages', () => { it('converts a user text message correctly', () => { @@ -44,3 +46,20 @@ describe('mcpPromptToMessages', () => { expect(messages[0]!.role).toBe('user') }) }) + +describe('MCPClient prompts integration', () => { + it('lists prompts and retrieves a prompt via the client', async () => { + await using client = await createMCPClientFromTransport( + (await makeServerWithPrompt()).clientTransport, + ) + + const list = await client.prompts() + expect(list.length).toBeGreaterThan(0) + + const prompt = await client.getPrompt(list[0]!.name, { code: 'x = 1' }) + const messages = mcpPromptToMessages(prompt) + + expect(messages[0]).toHaveProperty('role') + expect(messages[0]).toHaveProperty('content') + }) +}) From c98000a8cb234ec970714c0c27189ecf02eb8f79 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 21:16:05 +0200 Subject: [PATCH 22/71] feat(ai-mcp): codegen config schema and loader --- packages/ai-mcp/src/cli/config.ts | 36 ++++++++++++++++++++++++ packages/ai-mcp/tests/cli-config.test.ts | 12 ++++++++ 2 files changed, 48 insertions(+) create mode 100644 packages/ai-mcp/src/cli/config.ts create mode 100644 packages/ai-mcp/tests/cli-config.test.ts diff --git a/packages/ai-mcp/src/cli/config.ts b/packages/ai-mcp/src/cli/config.ts new file mode 100644 index 000000000..2f61f6899 --- /dev/null +++ b/packages/ai-mcp/src/cli/config.ts @@ -0,0 +1,36 @@ +import type { TransportConfig } from '../transport' + +export interface CodegenServerConfig { + transport: TransportConfig + /** Tool-name prefix; must match the runtime `createMCPClient({ prefix })`. */ + prefix?: string +} + +export interface MCPCodegenConfig { + servers: Record + /** Output file for the generated descriptor types. */ + outFile: string +} + +export function defineConfig(config: MCPCodegenConfig): MCPCodegenConfig { + return config +} + +/** Load mcp.config.ts (via jiti) or mcp.config.json from cwd. */ +export async function loadConfig(cwd: string): Promise { + const { existsSync } = await import('node:fs') + const { join } = await import('node:path') + const tsPath = join(cwd, 'mcp.config.ts') + const jsonPath = join(cwd, 'mcp.config.json') + if (existsSync(tsPath)) { + const { createJiti } = await import('jiti') + const jiti = createJiti(import.meta.url) + const mod = await jiti.import<{ default: MCPCodegenConfig }>(tsPath) + return mod.default + } + if (existsSync(jsonPath)) { + const { readFileSync } = await import('node:fs') + return JSON.parse(readFileSync(jsonPath, 'utf8')) as MCPCodegenConfig + } + throw new Error('No mcp.config.ts or mcp.config.json found in ' + cwd) +} diff --git a/packages/ai-mcp/tests/cli-config.test.ts b/packages/ai-mcp/tests/cli-config.test.ts new file mode 100644 index 000000000..9b9a5de44 --- /dev/null +++ b/packages/ai-mcp/tests/cli-config.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { defineConfig } from '../src/cli/config' + +describe('defineConfig', () => { + it('returns the config verbatim (identity helper for typing)', () => { + const cfg = defineConfig({ + servers: { weather: { transport: { type: 'http', url: 'https://x/mcp' } } }, + outFile: './mcp-types.generated.ts', + }) + expect(cfg.servers.weather?.transport.type).toBe('http') + }) +}) From a9fc65dbdf5ea374fc835429cc8ac56e37eb6b43 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 3 Jun 2026 21:49:32 +0200 Subject: [PATCH 23/71] feat(ai-mcp): codegen server introspection --- packages/ai-mcp/src/cli/introspect.ts | 52 +++++++++++++++++++ packages/ai-mcp/tests/cli-introspect.test.ts | 14 +++++ .../ai-mcp/tests/helpers/in-memory-server.ts | 45 ++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 packages/ai-mcp/src/cli/introspect.ts create mode 100644 packages/ai-mcp/tests/cli-introspect.test.ts diff --git a/packages/ai-mcp/src/cli/introspect.ts b/packages/ai-mcp/src/cli/introspect.ts new file mode 100644 index 000000000..e724e7104 --- /dev/null +++ b/packages/ai-mcp/src/cli/introspect.ts @@ -0,0 +1,52 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { resolveTransport } from '../transport' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { CodegenServerConfig } from './config' + +export interface ServerSurface { + tools: Array<{ + name: string + inputSchema: unknown + outputSchema?: unknown + description?: string + }> + resources: Array<{ uri: string; name?: string }> + prompts: Array<{ + name: string + arguments?: Array<{ name: string; required?: boolean }> + }> + capabilities: Record +} + +export async function introspectFromTransport( + transport: Transport, +): Promise { + const client = new Client({ name: 'tanstack-ai-mcp-codegen', version: '0.0.1' }) + await client.connect(transport) + try { + const caps = (client.getServerCapabilities() ?? {}) as Record + const tools = caps['tools'] ? (await client.listTools()).tools : [] + const resources = caps['resources'] ? (await client.listResources()).resources : [] + const prompts = caps['prompts'] ? (await client.listPrompts()).prompts : [] + return { + tools: tools.map((t) => ({ + name: t.name, + inputSchema: t.inputSchema, + outputSchema: (t as { outputSchema?: unknown }).outputSchema, + description: t.description, + })), + resources: resources.map((r) => ({ uri: r.uri, name: r.name })), + prompts: prompts.map((p) => ({ name: p.name, arguments: p.arguments })), + capabilities: caps, + } + } finally { + await client.close() + } +} + +export async function introspectServer( + config: CodegenServerConfig, +): Promise { + const transport = await resolveTransport(config.transport) + return introspectFromTransport(transport) +} diff --git a/packages/ai-mcp/tests/cli-introspect.test.ts b/packages/ai-mcp/tests/cli-introspect.test.ts new file mode 100644 index 000000000..dca0328eb --- /dev/null +++ b/packages/ai-mcp/tests/cli-introspect.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { makeFullServer } from './helpers/in-memory-server' +import { introspectFromTransport } from '../src/cli/introspect' + +describe('introspect', () => { + it('reads the full server surface', async () => { + const { clientTransport } = await makeFullServer() + const surface = await introspectFromTransport(clientTransport) + expect(surface.tools.length).toBeGreaterThan(0) + expect(surface.capabilities).toBeDefined() + expect(surface.resources.length).toBeGreaterThan(0) + expect(surface.prompts.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/ai-mcp/tests/helpers/in-memory-server.ts b/packages/ai-mcp/tests/helpers/in-memory-server.ts index c29b155e8..3693a670f 100644 --- a/packages/ai-mcp/tests/helpers/in-memory-server.ts +++ b/packages/ai-mcp/tests/helpers/in-memory-server.ts @@ -59,3 +59,48 @@ export async function makeServerWithPrompt() { await server.connect(serverTransport) return { server, clientTransport } } + +/** Build a connected (server, clientTransport) pair that exposes one tool, one resource, and one prompt. */ +export async function makeFullServer() { + const server = new McpServer({ name: 'full-server', version: '1.0.0' }) + + server.registerTool( + 'get_weather', + { + description: 'Get weather for a city', + inputSchema: { city: z.string() }, + }, + async ({ city }) => ({ + content: [{ type: 'text' as const, text: `Sunny in ${city}` }], + }), + ) + + server.registerResource( + 'hello', + 'file:///hello.txt', + { description: 'A simple text resource', mimeType: 'text/plain' }, + async (_uri) => ({ + contents: [{ uri: 'file:///hello.txt', text: 'hello from resource' }], + }), + ) + + server.registerPrompt( + 'review-code', + { + description: 'Review a code snippet', + argsSchema: { code: z.string() }, + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Please review: ${code}` }, + }, + ], + }), + ) + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport } +} From 1cb806f2ec48c5d67b61e61476cc9a5856af28b0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 10:33:22 +0200 Subject: [PATCH 24/71] chore(ai-mcp): sync pnpm-lock.yaml with tsup ^8.5.1 bump --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 315e16035..84fb72880 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1374,7 +1374,7 @@ importers: specifier: ^15.0.4 version: 15.0.4 tsup: - specifier: ^8.3.5 + specifier: ^8.5.1 version: 8.5.1(@microsoft/api-extractor@7.47.7(@types/node@24.10.3))(jiti@2.6.1)(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) vite: specifier: ^7.3.3 From e7b07dca71654a4ecf5e0ce0746de4aa21c9b244 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 10:35:03 +0200 Subject: [PATCH 25/71] feat(ai-mcp): codegen emit of ServerDescriptor types from JSON Schema --- packages/ai-mcp/src/cli/emit.ts | 107 +++++++++++++++++++++++++ packages/ai-mcp/tests/cli-emit.test.ts | 33 ++++++++ 2 files changed, 140 insertions(+) create mode 100644 packages/ai-mcp/src/cli/emit.ts create mode 100644 packages/ai-mcp/tests/cli-emit.test.ts diff --git a/packages/ai-mcp/src/cli/emit.ts b/packages/ai-mcp/src/cli/emit.ts new file mode 100644 index 000000000..aa7596333 --- /dev/null +++ b/packages/ai-mcp/src/cli/emit.ts @@ -0,0 +1,107 @@ +import { compile } from 'json-schema-to-typescript' +import type { ServerSurface } from './introspect' + +/** Convert an arbitrary server/tool name into a PascalCase identifier. */ +function pascal(name: string): string { + return name + .replace(/[^a-zA-Z0-9]+/g, ' ') + .split(' ') + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join('') +} + +/** + * Compile a JSON Schema into a TypeScript type via `json-schema-to-typescript`. + * Returns `'unknown'` for absent/non-object schemas. The compiled output is an + * `export interface { ... }` declaration which callers inline via + * {@link inlineBody}. + */ +async function schemaToType(schema: unknown, typeName: string): Promise { + if (!schema || typeof schema !== 'object') return 'unknown' + const compiled = await compile(schema, typeName, { + bannerComment: '', + additionalProperties: false, + declareExternallyReferenced: true, + }) + return compiled.trim() +} + +export interface EmitInput { + [serverName: string]: { prefix?: string; surface: ServerSurface } +} + +export async function emitDescriptors(input: EmitInput): Promise { + const blocks: Array = [ + '// AUTO-GENERATED by `npx @tanstack/ai-mcp generate`. Do not edit.', + "import type { ServerDescriptor } from '@tanstack/ai-mcp'", + '', + ] + // Track config-key -> interface-name so we can emit the combined pool map. + const mapEntries: Array<[string, string]> = [] + for (const [serverName, { prefix, surface }] of Object.entries(input)) { + const iface = `${pascal(serverName)}Server` + mapEntries.push([serverName, iface]) + const toolEntries: Array = [] + for (const tool of surface.tools) { + const key = prefix ? `${prefix}_${tool.name}` : tool.name + const inputType = await schemaToType( + tool.inputSchema, + `${pascal(key)}Input`, + ) + const outputType = tool.outputSchema + ? await schemaToType(tool.outputSchema, `${pascal(key)}Output`) + : 'unknown' + // Inline the compiled interface bodies as anonymous object types. + toolEntries.push( + ` '${key}': { input: ${inlineBody(inputType)}; output: ${inlineBody(outputType)} }`, + ) + } + blocks.push( + `export interface ${iface} extends ServerDescriptor {`, + ` tools: {`, + toolEntries.join('\n'), + ` }`, + ` resources: ${emitResources(surface)}`, + ` prompts: ${emitPrompts(surface)}`, + ` capabilities: ${JSON.stringify(surface.capabilities)} & Record`, + `}`, + '', + ) + } + // Combined map for createMCPClients(...). Keys = config keys + // verbatim (NOT pascal-cased) so they match the runtime config object and + // pool.clients access. + blocks.push( + 'export interface MCPServers extends Record {', + ...mapEntries.map(([key, iface]) => ` '${key}': ${iface}`), + '}', + '', + ) + return blocks.join('\n') +} + +/** + * Extract the `{ ... }` body from a compiled `export interface X { ... }` + * declaration so it can be inlined as an anonymous object type. Collapses + * newlines onto a single line. Falls back to `unknown` when no brace is found + * (e.g. the compiled type is itself `unknown`). + */ +function inlineBody(compiled: string): string { + const brace = compiled.indexOf('{') + return brace >= 0 ? compiled.slice(brace).replace(/\n/g, ' ').trim() : 'unknown' +} + +function emitResources(s: ServerSurface): string { + if (!s.resources.length) return '{}' + return `{ ${s.resources + .map((r) => `'${r.uri}': { uri: '${r.uri}'; data: unknown }`) + .join('; ')} }` +} + +function emitPrompts(s: ServerSurface): string { + if (!s.prompts.length) return '{}' + return `{ ${s.prompts + .map((p) => `'${p.name}': { args: Record; messages: unknown }`) + .join('; ')} }` +} diff --git a/packages/ai-mcp/tests/cli-emit.test.ts b/packages/ai-mcp/tests/cli-emit.test.ts new file mode 100644 index 000000000..6f2583b9b --- /dev/null +++ b/packages/ai-mcp/tests/cli-emit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { emitDescriptors } from '../src/cli/emit' + +describe('emitDescriptors', () => { + it('emits a ServerDescriptor type per server with typed tool inputs', async () => { + const out = await emitDescriptors({ + weather: { + prefix: undefined, + surface: { + tools: [ + { + name: 'get_weather', + inputSchema: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + }, + }, + ], + resources: [], + prompts: [], + capabilities: { tools: {} }, + }, + }, + }) + expect(out).toContain('export interface WeatherServer') + expect(out).toContain('get_weather') + expect(out).toContain('city') + // Combined pool map, keyed by config key, referencing the per-server interface. + expect(out).toContain('export interface MCPServers') + expect(out).toMatch(/'weather':\s*WeatherServer/) + }) +}) From b6c42abc538c5ebe263b609c311dd99a29d3c0df Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 10:37:09 +0200 Subject: [PATCH 26/71] feat(ai-mcp): generate CLI command --- packages/ai-mcp/src/cli/bin.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/ai-mcp/src/cli/bin.ts diff --git a/packages/ai-mcp/src/cli/bin.ts b/packages/ai-mcp/src/cli/bin.ts new file mode 100644 index 000000000..e380ce6cc --- /dev/null +++ b/packages/ai-mcp/src/cli/bin.ts @@ -0,0 +1,32 @@ +import { writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { loadConfig } from './config' +import { introspectServer } from './introspect' +import { emitDescriptors } from './emit' +import type { EmitInput } from './emit' + +async function generate(cwd = process.cwd()): Promise { + const config = await loadConfig(cwd) + const input: EmitInput = {} + for (const [name, server] of Object.entries(config.servers)) { + process.stderr.write(`Introspecting MCP server "${name}"…\n`) + const surface = await introspectServer(server) + input[name] = { prefix: server.prefix, surface } + } + const out = await emitDescriptors(input) + const outPath = resolve(cwd, config.outFile) + writeFileSync(outPath, out, 'utf8') + process.stderr.write(`Wrote ${outPath}\n`) +} + +const cmd = process.argv[2] +if (cmd === 'generate') { + generate().catch((err: unknown) => { + const msg = err instanceof Error ? (err.stack ?? String(err)) : String(err) + process.stderr.write(msg + '\n') + process.exit(1) + }) +} else { + process.stderr.write('Usage: tanstack-ai-mcp generate\n') + process.exit(cmd ? 1 : 0) +} From f45e2cb97dedf57f620d5ede879b95a86189f6af Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 10:43:51 +0200 Subject: [PATCH 27/71] test(ai-mcp): cover loadConfig JSON fallback and missing-config error --- packages/ai-mcp/tests/cli-config.test.ts | 49 +++++++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/ai-mcp/tests/cli-config.test.ts b/packages/ai-mcp/tests/cli-config.test.ts index 9b9a5de44..e2a6c6328 100644 --- a/packages/ai-mcp/tests/cli-config.test.ts +++ b/packages/ai-mcp/tests/cli-config.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest' -import { defineConfig } from '../src/cli/config' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { defineConfig, loadConfig } from '../src/cli/config' describe('defineConfig', () => { it('returns the config verbatim (identity helper for typing)', () => { @@ -10,3 +13,45 @@ describe('defineConfig', () => { expect(cfg.servers.weather?.transport.type).toBe('http') }) }) + +describe('loadConfig', () => { + let tmpDir: string | undefined + + afterEach(() => { + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }) + tmpDir = undefined + } + }) + + it('JSON fallback: reads and parses mcp.config.json when no .ts file exists', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'mcp-cfg-')) + const configContent = { + servers: { + weather: { + transport: { type: 'http', url: 'https://x/mcp' }, + }, + }, + outFile: './mcp-types.generated.ts', + } + writeFileSync(join(tmpDir, 'mcp.config.json'), JSON.stringify(configContent)) + + const cfg = await loadConfig(tmpDir) + + const transport = cfg.servers['weather']?.transport + expect(transport?.type).toBe('http') + if (transport?.type === 'http' || transport?.type === 'sse') { + expect(transport.url).toBe('https://x/mcp') + } + expect(cfg.outFile).toBe('./mcp-types.generated.ts') + }) + + it('throw on missing: rejects with the real error message when no config file exists', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'mcp-cfg-empty-')) + + await expect(loadConfig(tmpDir)).rejects.toThrow( + /No mcp\.config\.ts or mcp\.config\.json found in /, + ) + await expect(loadConfig(tmpDir)).rejects.toThrow(tmpDir) + }) +}) From 954762e89b8599c3812a2ddd320088bc8c74e870 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 11:02:29 +0200 Subject: [PATCH 28/71] test(e2e): MCP server tool discovery and execution in chat() --- pnpm-lock.yaml | 14 +-- testing/e2e/fixtures/mcp/basic.json | 25 +++++ testing/e2e/package.json | 2 + testing/e2e/src/routeTree.gen.ts | 42 ++++++++ testing/e2e/src/routes/api.mcp-server.ts | 76 +++++++++++++ testing/e2e/src/routes/api.mcp-test.ts | 129 +++++++++++++++++++++++ testing/e2e/tests/mcp.spec.ts | 115 ++++++++++++++++++++ 7 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 testing/e2e/fixtures/mcp/basic.json create mode 100644 testing/e2e/src/routes/api.mcp-server.ts create mode 100644 testing/e2e/src/routes/api.mcp-test.ts create mode 100644 testing/e2e/tests/mcp.spec.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84fb72880..8dc02de67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1832,6 +1832,9 @@ importers: '@copilotkit/aimock': specifier: ^1.27.0 version: 1.27.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) '@openrouter/sdk': specifier: 0.12.35 version: 0.12.35 @@ -1862,6 +1865,9 @@ importers: '@tanstack/ai-groq': specifier: workspace:* version: link:../../packages/ai-groq + '@tanstack/ai-mcp': + specifier: workspace:* + version: link:../../packages/ai-mcp '@tanstack/ai-ollama': specifier: workspace:* version: link:../../packages/ai-ollama @@ -9880,10 +9886,6 @@ packages: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -22713,8 +22715,6 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.1.0: {} - ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -26040,7 +26040,7 @@ snapshots: socks@2.8.7: dependencies: - ip-address: 10.1.0 + ip-address: 10.2.0 smart-buffer: 4.2.0 solid-js@1.9.10: diff --git a/testing/e2e/fixtures/mcp/basic.json b/testing/e2e/fixtures/mcp/basic.json new file mode 100644 index 000000000..8e73db2ed --- /dev/null +++ b/testing/e2e/fixtures/mcp/basic.json @@ -0,0 +1,25 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[mcp] how much is the strat guitar", + "sequenceIndex": 0 + }, + "response": { + "content": "Let me look up the price.", + "toolCalls": [ + { "name": "get_guitar_price", "arguments": "{\"id\":\"strat\"}" } + ] + } + }, + { + "match": { + "userMessage": "[mcp] how much is the strat guitar", + "sequenceIndex": 1 + }, + "response": { + "content": "The strat guitar costs $1999." + } + } + ] +} diff --git a/testing/e2e/package.json b/testing/e2e/package.json index cebaf1b74..4686eb6f8 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@copilotkit/aimock": "^1.27.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/sdk": "0.12.35", "@opentelemetry/api": "^1.9.0", "@tailwindcss/vite": "^4.1.18", @@ -22,6 +23,7 @@ "@tanstack/ai-gemini": "workspace:*", "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-groq": "workspace:*", + "@tanstack/ai-mcp": "workspace:*", "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index b0e11446b..9290fdfc0 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -30,6 +30,8 @@ import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-c import { Route as ApiOpenaiUsageDetailsRouteImport } from './routes/api.openai-usage-details' import { Route as ApiMultimodalToolResultWireRouteImport } from './routes/api.multimodal-tool-result-wire' import { Route as ApiMiddlewareTestRouteImport } from './routes/api.middleware-test' +import { Route as ApiMcpTestRouteImport } from './routes/api.mcp-test' +import { Route as ApiMcpServerRouteImport } from './routes/api.mcp-server' import { Route as ApiImageRouteImport } from './routes/api.image' import { Route as ApiChatRouteImport } from './routes/api.chat' import { Route as ApiAudioRouteImport } from './routes/api.audio' @@ -149,6 +151,16 @@ const ApiMiddlewareTestRoute = ApiMiddlewareTestRouteImport.update({ path: '/api/middleware-test', getParentRoute: () => rootRouteImport, } as any) +const ApiMcpTestRoute = ApiMcpTestRouteImport.update({ + id: '/api/mcp-test', + path: '/api/mcp-test', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMcpServerRoute = ApiMcpServerRouteImport.update({ + id: '/api/mcp-server', + path: '/api/mcp-server', + getParentRoute: () => rootRouteImport, +} as any) const ApiImageRoute = ApiImageRouteImport.update({ id: '/api/image', path: '/api/image', @@ -222,6 +234,8 @@ export interface FileRoutesByFullPath { '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren + '/api/mcp-server': typeof ApiMcpServerRoute + '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute @@ -256,6 +270,8 @@ export interface FileRoutesByTo { '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren + '/api/mcp-server': typeof ApiMcpServerRoute + '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute @@ -291,6 +307,8 @@ export interface FileRoutesById { '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren + '/api/mcp-server': typeof ApiMcpServerRoute + '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute @@ -327,6 +345,8 @@ export interface FileRouteTypes { | '/api/audio' | '/api/chat' | '/api/image' + | '/api/mcp-server' + | '/api/mcp-test' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' | '/api/openai-usage-details' @@ -361,6 +381,8 @@ export interface FileRouteTypes { | '/api/audio' | '/api/chat' | '/api/image' + | '/api/mcp-server' + | '/api/mcp-test' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' | '/api/openai-usage-details' @@ -395,6 +417,8 @@ export interface FileRouteTypes { | '/api/audio' | '/api/chat' | '/api/image' + | '/api/mcp-server' + | '/api/mcp-test' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' | '/api/openai-usage-details' @@ -430,6 +454,8 @@ export interface RootRouteChildren { ApiAudioRoute: typeof ApiAudioRouteWithChildren ApiChatRoute: typeof ApiChatRoute ApiImageRoute: typeof ApiImageRouteWithChildren + ApiMcpServerRoute: typeof ApiMcpServerRoute + ApiMcpTestRoute: typeof ApiMcpTestRoute ApiMiddlewareTestRoute: typeof ApiMiddlewareTestRoute ApiMultimodalToolResultWireRoute: typeof ApiMultimodalToolResultWireRoute ApiOpenaiUsageDetailsRoute: typeof ApiOpenaiUsageDetailsRoute @@ -592,6 +618,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiMiddlewareTestRouteImport parentRoute: typeof rootRouteImport } + '/api/mcp-test': { + id: '/api/mcp-test' + path: '/api/mcp-test' + fullPath: '/api/mcp-test' + preLoaderRoute: typeof ApiMcpTestRouteImport + parentRoute: typeof rootRouteImport + } + '/api/mcp-server': { + id: '/api/mcp-server' + path: '/api/mcp-server' + fullPath: '/api/mcp-server' + preLoaderRoute: typeof ApiMcpServerRouteImport + parentRoute: typeof rootRouteImport + } '/api/image': { id: '/api/image' path: '/api/image' @@ -747,6 +787,8 @@ const rootRouteChildren: RootRouteChildren = { ApiAudioRoute: ApiAudioRouteWithChildren, ApiChatRoute: ApiChatRoute, ApiImageRoute: ApiImageRouteWithChildren, + ApiMcpServerRoute: ApiMcpServerRoute, + ApiMcpTestRoute: ApiMcpTestRoute, ApiMiddlewareTestRoute: ApiMiddlewareTestRoute, ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute, ApiOpenaiUsageDetailsRoute: ApiOpenaiUsageDetailsRoute, diff --git a/testing/e2e/src/routes/api.mcp-server.ts b/testing/e2e/src/routes/api.mcp-server.ts new file mode 100644 index 000000000..345c06ffd --- /dev/null +++ b/testing/e2e/src/routes/api.mcp-server.ts @@ -0,0 +1,76 @@ +import { createFileRoute } from '@tanstack/react-router' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { z } from 'zod' + +/** + * In-process mock MCP server hosted as a TanStack Start API route. + * + * This is a *real* MCP server (via `@modelcontextprotocol/sdk`'s `McpServer`) + * speaking the Streamable HTTP protocol over Web Standard Request/Response. + * The companion `api.mcp-test` route connects to it at this same dev-server + * origin via `@tanstack/ai-mcp`'s `createMCPClient({ transport: { type: + * '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. + * + * 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. + */ +function createMockMcpServer(): McpServer { + const server = new McpServer({ + name: 'guitar-store-mcp-mock', + version: '0.0.1', + }) + + server.registerTool( + 'get_guitar_price', + { + description: 'Get the price of a guitar by its id', + inputSchema: { id: z.string() }, + outputSchema: { id: z.string(), price: z.number() }, + }, + ({ id }) => { + const payload = { id, price: 1999 } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) + + return server +} + +async function handleMcpRequest(request: Request): Promise { + const server = createMockMcpServer() + 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-server')({ + server: { + handlers: { + POST: ({ request }) => handleMcpRequest(request), + GET: ({ request }) => handleMcpRequest(request), + DELETE: ({ request }) => handleMcpRequest(request), + }, + }, +}) diff --git a/testing/e2e/src/routes/api.mcp-test.ts b/testing/e2e/src/routes/api.mcp-test.ts new file mode 100644 index 000000000..a71f806f5 --- /dev/null +++ b/testing/e2e/src/routes/api.mcp-test.ts @@ -0,0 +1,129 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { createMCPClient } from '@tanstack/ai-mcp' +import type { StreamChunk } from '@tanstack/ai' +import type { MCPClient } from '@tanstack/ai-mcp' +import { createTextAdapter } from '@/lib/providers' + +/** + * Wrap the chat stream so the MCP client is closed only AFTER the stream has + * fully drained (or errored). Tool calls fire mid-stream, so closing the + * client earlier would abort an in-flight MCP tool call. + */ +async function* closeMcpOnDrain( + stream: AsyncIterable, + mcp: MCPClient, +): AsyncGenerator { + try { + for await (const chunk of stream) { + yield chunk + } + } finally { + await mcp.close() + } +} + +/** + * Drives a real `chat()` agent loop whose tools are discovered from the + * in-process mock MCP server (`api.mcp-server`) via `@tanstack/ai-mcp`. + * + * Flow: + * 1. `createMCPClient({ transport: { type: 'http', url } })` connects to the + * mock MCP server at this dev server's own origin. + * 2. `mcp.tools()` auto-discovers the server's tools (here: `get_guitar_price`) + * as TanStack `ServerTool`s whose `execute` proxies to the MCP server. + * 3. `chat()` runs the OpenAI adapter against aimock. The aimock fixture + * emits a `get_guitar_price` tool call; the MCP tool executes for real + * against the mock server, returning `{ id, price: 1999 }`; the result is + * fed back and the model emits a final answer mentioning the price. + * 4. The MCP client is closed in `finally` AFTER the stream fully drains — + * closing earlier would kill the connection mid tool-call. + */ +export const Route = createFileRoute('/api/mcp-test')({ + server: { + handlers: { + POST: async ({ request }) => { + if (request.signal.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 }, + ) + } + + const fp = params.forwardedProps + const testId = typeof fp.testId === 'string' ? fp.testId : undefined + const aimockPort = + fp.aimockPort != null ? Number(fp.aimockPort) : undefined + + // The mock MCP server lives at this same dev server's origin. + const origin = new URL(request.url).origin + const mcpUrl = `${origin}/api/mcp-server` + + try { + const mcp = await createMCPClient({ + transport: { type: 'http', url: mcpUrl }, + }) + + let tools + try { + tools = await mcp.tools() + } catch (error) { + await mcp.close() + throw error + } + + const adapterOptions = createTextAdapter( + 'openai', + undefined, + aimockPort, + testId, + ) + + const stream = chat({ + ...adapterOptions, + messages: params.messages, + tools, + threadId: params.threadId, + runId: params.runId, + agentLoopStrategy: maxIterations(5), + abortController, + }) + + // Close the MCP client only after the SSE stream fully drains — + // tool calls fire mid-stream, so an early close would abort them. + return toServerSentEventsResponse(closeMcpOnDrain(stream, mcp), { + abortController, + }) + } catch (error) { + console.error('[api.mcp-test] Error:', error) + if ( + (error instanceof Error && error.name === 'AbortError') || + abortController.signal.aborted + ) { + return new Response(null, { status: 499 }) + } + const message = + error instanceof Error ? error.message : 'An error occurred' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + }, + }, +}) diff --git a/testing/e2e/tests/mcp.spec.ts b/testing/e2e/tests/mcp.spec.ts new file mode 100644 index 000000000..148662c4a --- /dev/null +++ b/testing/e2e/tests/mcp.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from './fixtures' + +/** + * MCP server tool discovery + execution inside chat(). + * + * Proves the `@tanstack/ai-mcp` package end-to-end against the real E2E + * harness: + * - `api.mcp-server` hosts a real in-process MCP server (Streamable HTTP) + * exposing one deterministic tool `get_guitar_price` ({ id } → { id, + * price: 1999 }). + * - `api.mcp-test` connects to it via `createMCPClient`, discovers the tool + * with `mcp.tools()`, and runs it inside a real `chat()` agent loop with + * the LLM mocked by aimock. + * - The aimock fixture (`fixtures/mcp/basic.json`) makes the model emit a + * `get_guitar_price` tool call then a final answer. + * + * The MCP server is the *only* source of the price `1999`: the fixture's + * tool-call arguments don't contain it. So asserting `1999` reaches the + * streamed `TOOL_CALL_END.result` proves the MCP tool actually executed. + */ + +type StreamEvent = { + type: string + toolName?: string + toolCallName?: string + toolCallId?: string + result?: unknown + content?: unknown + delta?: string +} + +function parseSse(body: string): Array { + const events: Array = [] + for (const line of body.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) continue + const json = trimmed.slice('data:'.length).trim() + if (!json) continue + try { + events.push(JSON.parse(json) as StreamEvent) + } catch { + // Ignore non-JSON keepalive lines. + } + } + return events +} + +test.describe('mcp — server tool discovery + execution in chat()', () => { + test('discovers get_guitar_price from the MCP server and the result reaches the transcript', async ({ + request, + testId, + aimockPort, + }) => { + // Minimal valid AG-UI RunAgentInput body (the route parses it via + // chatParamsFromRequestBody). + const res = await request.post('/api/mcp-test', { + headers: { 'Content-Type': 'application/json' }, + data: { + threadId: `mcp-thread-${testId}`, + runId: `mcp-run-${testId}`, + state: {}, + messages: [ + { + id: 'mcp-msg-1', + role: 'user', + content: '[mcp] how much is the strat guitar', + }, + ], + tools: [], + context: [], + forwardedProps: { testId, aimockPort }, + }, + }) + + const body = await res.text() + expect(res.ok(), `mcp-test route failed (${res.status()}): ${body}`).toBe( + true, + ) + + const events = parseSse(body) + + // The agentic loop must have invoked the MCP tool. + const toolStart = events.find( + (e) => + e.type === 'TOOL_CALL_START' && + (e.toolName === 'get_guitar_price' || + e.toolCallName === 'get_guitar_price'), + ) + expect( + toolStart, + 'expected a TOOL_CALL_START for get_guitar_price', + ).toBeTruthy() + + // The MCP tool result is emitted as the AG-UI TOOL_CALL_RESULT event. The + // price 1999 originates ONLY from the real MCP server (the fixture's + // tool-call args don't contain it), so finding it here proves the MCP tool + // actually executed against the in-process server. + const toolResult = events.find((e) => e.type === 'TOOL_CALL_RESULT') + expect(toolResult, 'expected a TOOL_CALL_RESULT event').toBeTruthy() + const resultStr = JSON.stringify(toolResult?.content ?? '') + expect(resultStr).toContain('1999') + expect(resultStr).toContain('strat') + + // And the final assistant text (post tool execution) streamed through. + const finalText = events + .filter((e) => e.type === 'TEXT_MESSAGE_CONTENT' && e.delta) + .map((e) => e.delta) + .join('') + expect(finalText).toContain('1999') + + // The run completed cleanly (no RUN_ERROR). + expect(events.some((e) => e.type === 'RUN_ERROR')).toBe(false) + expect(events.some((e) => e.type === 'RUN_FINISHED')).toBe(true) + }) +}) From da5c7d721fa54f541f37ee5c7524a0130744b7d1 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 11:04:33 +0200 Subject: [PATCH 29/71] docs: document MCP prompts API --- docs/tools/mcp.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index 822bd6267..e02fc8d30 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -347,6 +347,55 @@ const templates = await mcp.resourceTemplates() // templates: Array ``` +## Prompts + +MCP prompts are reusable message templates the server exposes. Fetch a prompt, convert it to `ModelMessage[]` with `mcpPromptToMessages`, and spread it into `chat()` to seed the conversation with server-defined context or instructions. + +```ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient, mcpPromptToMessages } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + }) + + try { + // List all available prompts on the server + const available = await mcp.prompts() + // available: Array<{ name: string; description?: string; arguments?: ... }> + + // Fetch a specific prompt, optionally passing template arguments + const prompt = await mcp.getPrompt('summarize', { language: 'english' }) + + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: [ + // Seed the conversation with the server-defined prompt messages + ...mcpPromptToMessages(prompt), + // Then append the user's own messages + ...messages, + ], + }) + + return toServerSentEventsResponse(stream) + } finally { + await mcp.close() + } +} +``` + +`mcpPromptToMessages` maps each MCP prompt message to a `ModelMessage`: +- `role === 'assistant'` → `{ role: 'assistant', content: text }` +- any other role → `{ role: 'user', content: text }` +- non-text content → `content` is `JSON.stringify`'d + +`getPrompt(name, args?)` accepts an optional `args` parameter typed as `Record` for filling in template variables declared by the prompt. + ## 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. From 866a9a5d3673b72a8361917e4156821e63ae76a2 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 11:15:42 +0200 Subject: [PATCH 30/71] fix(ai-mcp): expose defineConfig via ./cli subpath export --- packages/ai-mcp/package.json | 7 ++++--- packages/ai-mcp/skills/ai-mcp/SKILL.md | 2 +- packages/ai-mcp/vite.config.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json index 17a0df777..46de0975c 100644 --- a/packages/ai-mcp/package.json +++ b/packages/ai-mcp/package.json @@ -6,12 +6,13 @@ "repository": { "type": "git", "url": "git+https://github.com/TanStack/ai.git", "directory": "packages/ai-mcp" }, "keywords": ["ai", "mcp", "model-context-protocol", "tanstack", "tools", "typescript"], "type": "module", - "module": "./dist/esm/index.js", + "module": "./dist/esm/packages/ai-mcp/src/index.js", "types": "./dist/esm/index.d.ts", "bin": { "tanstack-ai-mcp": "./dist/bin/bin.js" }, "exports": { - ".": { "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js" }, - "./stdio": { "types": "./dist/esm/stdio.d.ts", "import": "./dist/esm/stdio.js" } + ".": { "types": "./dist/esm/index.d.ts", "import": "./dist/esm/packages/ai-mcp/src/index.js" }, + "./stdio": { "types": "./dist/esm/stdio.d.ts", "import": "./dist/esm/packages/ai-mcp/src/stdio.js" }, + "./cli": { "types": "./dist/esm/cli/config.d.ts", "import": "./dist/esm/packages/ai-mcp/src/cli/config.js" } }, "files": ["dist", "src"], "scripts": { diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index 81f54c905..9aa0ed8f4 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -321,7 +321,7 @@ Generate end-to-end TypeScript types by introspecting live MCP servers. **1. Create `mcp.config.ts` at your project root:** ```typescript -import { defineConfig } from '@tanstack/ai-mcp' +import { defineConfig } from '@tanstack/ai-mcp/cli' export default defineConfig({ servers: { diff --git a/packages/ai-mcp/vite.config.ts b/packages/ai-mcp/vite.config.ts index f428e9a09..d16bd17c5 100644 --- a/packages/ai-mcp/vite.config.ts +++ b/packages/ai-mcp/vite.config.ts @@ -21,7 +21,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts', './src/stdio.ts'], + entry: ['./src/index.ts', './src/stdio.ts', './src/cli/config.ts'], srcDir: './src', cjs: false, }), From bb4c540400fe390abfdc5e785971597a73ad239c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 11:21:51 +0200 Subject: [PATCH 31/71] fix(ai-mcp): emit flat lib build and expose defineConfig from main entry (no jiti in lib) --- docs/tools/mcp.md | 2 +- packages/ai-mcp/package.json | 7 +++---- packages/ai-mcp/skills/ai-mcp/SKILL.md | 2 +- packages/ai-mcp/src/cli/config.ts | 19 +++---------------- packages/ai-mcp/src/cli/define-config.ts | 17 +++++++++++++++++ packages/ai-mcp/src/cli/introspect.ts | 2 +- packages/ai-mcp/src/index.ts | 2 ++ packages/ai-mcp/tests/cli-config.test.ts | 3 ++- packages/ai-mcp/vite.config.ts | 2 +- 9 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 packages/ai-mcp/src/cli/define-config.ts diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index e02fc8d30..fd2595c72 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -423,7 +423,7 @@ The `generate` CLI introspects a live MCP server and emits TypeScript interface ```ts // mcp.config.ts -import { defineConfig } from '@tanstack/ai-mcp/cli' +import { defineConfig } from '@tanstack/ai-mcp' export default defineConfig({ servers: { diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json index 46de0975c..17a0df777 100644 --- a/packages/ai-mcp/package.json +++ b/packages/ai-mcp/package.json @@ -6,13 +6,12 @@ "repository": { "type": "git", "url": "git+https://github.com/TanStack/ai.git", "directory": "packages/ai-mcp" }, "keywords": ["ai", "mcp", "model-context-protocol", "tanstack", "tools", "typescript"], "type": "module", - "module": "./dist/esm/packages/ai-mcp/src/index.js", + "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", "bin": { "tanstack-ai-mcp": "./dist/bin/bin.js" }, "exports": { - ".": { "types": "./dist/esm/index.d.ts", "import": "./dist/esm/packages/ai-mcp/src/index.js" }, - "./stdio": { "types": "./dist/esm/stdio.d.ts", "import": "./dist/esm/packages/ai-mcp/src/stdio.js" }, - "./cli": { "types": "./dist/esm/cli/config.d.ts", "import": "./dist/esm/packages/ai-mcp/src/cli/config.js" } + ".": { "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js" }, + "./stdio": { "types": "./dist/esm/stdio.d.ts", "import": "./dist/esm/stdio.js" } }, "files": ["dist", "src"], "scripts": { diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index 9aa0ed8f4..81f54c905 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -321,7 +321,7 @@ Generate end-to-end TypeScript types by introspecting live MCP servers. **1. Create `mcp.config.ts` at your project root:** ```typescript -import { defineConfig } from '@tanstack/ai-mcp/cli' +import { defineConfig } from '@tanstack/ai-mcp' export default defineConfig({ servers: { diff --git a/packages/ai-mcp/src/cli/config.ts b/packages/ai-mcp/src/cli/config.ts index 2f61f6899..7e7a58278 100644 --- a/packages/ai-mcp/src/cli/config.ts +++ b/packages/ai-mcp/src/cli/config.ts @@ -1,20 +1,7 @@ -import type { TransportConfig } from '../transport' +import type { MCPCodegenConfig } from './define-config' -export interface CodegenServerConfig { - transport: TransportConfig - /** Tool-name prefix; must match the runtime `createMCPClient({ prefix })`. */ - prefix?: string -} - -export interface MCPCodegenConfig { - servers: Record - /** Output file for the generated descriptor types. */ - outFile: string -} - -export function defineConfig(config: MCPCodegenConfig): MCPCodegenConfig { - return config -} +export type { CodegenServerConfig, MCPCodegenConfig } from './define-config' +export { defineConfig } from './define-config' /** Load mcp.config.ts (via jiti) or mcp.config.json from cwd. */ export async function loadConfig(cwd: string): Promise { diff --git a/packages/ai-mcp/src/cli/define-config.ts b/packages/ai-mcp/src/cli/define-config.ts new file mode 100644 index 000000000..cd0487e30 --- /dev/null +++ b/packages/ai-mcp/src/cli/define-config.ts @@ -0,0 +1,17 @@ +import type { TransportConfig } from '../transport' + +export interface CodegenServerConfig { + transport: TransportConfig + /** Tool-name prefix; must match the runtime `createMCPClient({ prefix })`. */ + prefix?: string +} + +export interface MCPCodegenConfig { + servers: Record + /** Output file for the generated descriptor types. */ + outFile: string +} + +export function defineConfig(config: MCPCodegenConfig): MCPCodegenConfig { + return config +} diff --git a/packages/ai-mcp/src/cli/introspect.ts b/packages/ai-mcp/src/cli/introspect.ts index e724e7104..3f29e84a0 100644 --- a/packages/ai-mcp/src/cli/introspect.ts +++ b/packages/ai-mcp/src/cli/introspect.ts @@ -1,7 +1,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { resolveTransport } from '../transport' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' -import type { CodegenServerConfig } from './config' +import type { CodegenServerConfig } from './define-config' export interface ServerSurface { tools: Array<{ diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 0a7090512..9969ac44f 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -24,4 +24,6 @@ export { mcpResourceToContentPart } from './resources' export { mcpPromptToMessages } from './prompts' export { createMCPClients } from './pool' export type { MCPClients, MCPClientsConfig } from './pool' +export { defineConfig } from './cli/define-config' +export type { MCPCodegenConfig, CodegenServerConfig } from './cli/define-config' diff --git a/packages/ai-mcp/tests/cli-config.test.ts b/packages/ai-mcp/tests/cli-config.test.ts index e2a6c6328..5663c4956 100644 --- a/packages/ai-mcp/tests/cli-config.test.ts +++ b/packages/ai-mcp/tests/cli-config.test.ts @@ -2,7 +2,8 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { defineConfig, loadConfig } from '../src/cli/config' +import { defineConfig } from '../src/cli/define-config' +import { loadConfig } from '../src/cli/config' describe('defineConfig', () => { it('returns the config verbatim (identity helper for typing)', () => { diff --git a/packages/ai-mcp/vite.config.ts b/packages/ai-mcp/vite.config.ts index d16bd17c5..f428e9a09 100644 --- a/packages/ai-mcp/vite.config.ts +++ b/packages/ai-mcp/vite.config.ts @@ -21,7 +21,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts', './src/stdio.ts', './src/cli/config.ts'], + entry: ['./src/index.ts', './src/stdio.ts'], srcDir: './src', cjs: false, }), From 4e6220dd380df5390f514c0bffdd7e27ba55aaa9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:32:30 +0000 Subject: [PATCH 32/71] ci: apply automated fixes --- packages/ai-mcp/package.json | 34 +++++++++++++++---- packages/ai-mcp/skills/ai-mcp/SKILL.md | 14 +++++--- packages/ai-mcp/src/cli/emit.ts | 13 +++++-- packages/ai-mcp/src/cli/introspect.ts | 14 ++++++-- packages/ai-mcp/src/client.ts | 26 +++++++------- packages/ai-mcp/src/errors.ts | 5 ++- packages/ai-mcp/src/index.ts | 1 - packages/ai-mcp/src/pool.ts | 5 +-- packages/ai-mcp/src/tools.ts | 8 +++-- packages/ai-mcp/src/transport.ts | 4 ++- packages/ai-mcp/src/types.ts | 18 +++++----- packages/ai-mcp/tests/cli-config.test.ts | 9 +++-- packages/ai-mcp/tests/client.test.ts | 11 +++--- .../ai-mcp/tests/helpers/in-memory-server.ts | 12 ++++--- packages/ai-mcp/tests/pool.test.ts | 4 ++- packages/ai-mcp/tests/resources.test.ts | 5 ++- packages/ai-mcp/tests/tools.test.ts | 16 ++++++--- packages/ai-mcp/tests/transport.test.ts | 6 +++- .../ai/tests/tool-abort-threading.test.ts | 8 ++++- .../ai/tests/tool-execution-context.test.ts | 13 ++++--- 20 files changed, 157 insertions(+), 69 deletions(-) diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json index 17a0df777..ad4c4f72f 100644 --- a/packages/ai-mcp/package.json +++ b/packages/ai-mcp/package.json @@ -3,17 +3,39 @@ "version": "0.0.1", "description": "Host-side Model Context Protocol client for TanStack AI: discover and run MCP server tools, resources, and prompts in any adapter's chat() loop, with generated end-to-end types.", "license": "MIT", - "repository": { "type": "git", "url": "git+https://github.com/TanStack/ai.git", "directory": "packages/ai-mcp" }, - "keywords": ["ai", "mcp", "model-context-protocol", "tanstack", "tools", "typescript"], + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-mcp" + }, + "keywords": [ + "ai", + "mcp", + "model-context-protocol", + "tanstack", + "tools", + "typescript" + ], "type": "module", "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", - "bin": { "tanstack-ai-mcp": "./dist/bin/bin.js" }, + "bin": { + "tanstack-ai-mcp": "./dist/bin/bin.js" + }, "exports": { - ".": { "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js" }, - "./stdio": { "types": "./dist/esm/stdio.d.ts", "import": "./dist/esm/stdio.js" } + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./stdio": { + "types": "./dist/esm/stdio.d.ts", + "import": "./dist/esm/stdio.js" + } }, - "files": ["dist", "src"], + "files": [ + "dist", + "src" + ], "scripts": { "build": "vite build && tsup --config tsup.bin.config.ts", "clean": "premove ./build ./dist", diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index 81f54c905..2e27356b1 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -56,7 +56,7 @@ import { createMCPClient } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, prefix: 'weather', // optional: prefixes all tool names (e.g. 'weather_get_forecast') - name: 'my-app', // optional: client identity sent to the server + name: 'my-app', // optional: client identity sent to the server }) ``` @@ -182,7 +182,9 @@ See the "Codegen CLI" section below for details. ```typescript // Option 1: explicit close -const client = await createMCPClient({ transport: { type: 'http', url: '...' } }) +const client = await createMCPClient({ + transport: { type: 'http', url: '...' }, +}) try { const tools = await client.tools() const stream = chat({ adapter: openaiText('gpt-4o'), messages, tools }) @@ -275,7 +277,9 @@ Inject resources into a chat turn: import { chat } from '@tanstack/ai' import { createMCPClient, mcpResourceToContentPart } from '@tanstack/ai-mcp' -const client = await createMCPClient({ transport: { type: 'http', url: '...' } }) +const client = await createMCPClient({ + transport: { type: 'http', url: '...' }, +}) const resource = await client.readResource('file:///project/README.md') const parts = resource.contents.map(mcpResourceToContentPart) @@ -447,7 +451,9 @@ middleware: import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { createMCPClient } from '@tanstack/ai-mcp' -const client = await createMCPClient({ transport: { type: 'http', url: '...' } }) +const client = await createMCPClient({ + transport: { type: 'http', url: '...' }, +}) const stream = chat({ adapter: openaiText('gpt-4o'), diff --git a/packages/ai-mcp/src/cli/emit.ts b/packages/ai-mcp/src/cli/emit.ts index aa7596333..03a5595f6 100644 --- a/packages/ai-mcp/src/cli/emit.ts +++ b/packages/ai-mcp/src/cli/emit.ts @@ -17,7 +17,10 @@ function pascal(name: string): string { * `export interface { ... }` declaration which callers inline via * {@link inlineBody}. */ -async function schemaToType(schema: unknown, typeName: string): Promise { +async function schemaToType( + schema: unknown, + typeName: string, +): Promise { if (!schema || typeof schema !== 'object') return 'unknown' const compiled = await compile(schema, typeName, { bannerComment: '', @@ -89,7 +92,9 @@ export async function emitDescriptors(input: EmitInput): Promise { */ function inlineBody(compiled: string): string { const brace = compiled.indexOf('{') - return brace >= 0 ? compiled.slice(brace).replace(/\n/g, ' ').trim() : 'unknown' + return brace >= 0 + ? compiled.slice(brace).replace(/\n/g, ' ').trim() + : 'unknown' } function emitResources(s: ServerSurface): string { @@ -102,6 +107,8 @@ function emitResources(s: ServerSurface): string { function emitPrompts(s: ServerSurface): string { if (!s.prompts.length) return '{}' return `{ ${s.prompts - .map((p) => `'${p.name}': { args: Record; messages: unknown }`) + .map( + (p) => `'${p.name}': { args: Record; messages: unknown }`, + ) .join('; ')} }` } diff --git a/packages/ai-mcp/src/cli/introspect.ts b/packages/ai-mcp/src/cli/introspect.ts index 3f29e84a0..9f38d0583 100644 --- a/packages/ai-mcp/src/cli/introspect.ts +++ b/packages/ai-mcp/src/cli/introspect.ts @@ -21,12 +21,20 @@ export interface ServerSurface { export async function introspectFromTransport( transport: Transport, ): Promise { - const client = new Client({ name: 'tanstack-ai-mcp-codegen', version: '0.0.1' }) + const client = new Client({ + name: 'tanstack-ai-mcp-codegen', + version: '0.0.1', + }) await client.connect(transport) try { - const caps = (client.getServerCapabilities() ?? {}) as Record + const caps = (client.getServerCapabilities() ?? {}) as Record< + string, + unknown + > const tools = caps['tools'] ? (await client.listTools()).tools : [] - const resources = caps['resources'] ? (await client.listResources()).resources : [] + const resources = caps['resources'] + ? (await client.listResources()).resources + : [] const prompts = caps['prompts'] ? (await client.listPrompts()).prompts : [] return { tools: tools.map((t) => ({ diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 1ef3441f5..07177e1ec 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -42,24 +42,23 @@ export interface MCPClient< readResource: (uri: string) => Promise resourceTemplates: () => Promise> prompts: () => Promise> - getPrompt: (name: string, args?: Record) => Promise + getPrompt: ( + name: string, + args?: Record, + ) => Promise close: () => Promise [Symbol.asyncDispose]: () => Promise } -class MCPClientImpl - implements MCPClient -{ +class MCPClientImpl< + TServer extends ServerDescriptor, +> implements MCPClient { capabilities: TServer['capabilities'] = {} readonly #client: Client #closed = false private readonly prefix?: string - constructor( - prefix?: string, - name = 'tanstack-ai-mcp', - version = '0.0.1', - ) { + constructor(prefix?: string, name = 'tanstack-ai-mcp', version = '0.0.1') { this.prefix = prefix this.#client = new Client({ name, version }) } @@ -82,8 +81,8 @@ class MCPClientImpl const isDefs = Array.isArray(defsOrOptions) const options: ToolsOptions = isDefs ? maybeOptions - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - : ((defsOrOptions as ToolsOptions) ?? {}) // SDK interop: defsOrOptions may be undefined at runtime even though TS types it as ToolsOptions here + : // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ((defsOrOptions as ToolsOptions) ?? {}) // SDK interop: defsOrOptions may be undefined at runtime even though TS types it as ToolsOptions here let tools: Array if (isDefs) { @@ -138,7 +137,10 @@ class MCPClientImpl return (await this.#client.listPrompts()).prompts } - async getPrompt(name: string, args?: Record): Promise { + async getPrompt( + name: string, + args?: Record, + ): Promise { if (this.#closed) throw new MCPConnectionError('MCP client is closed') return this.#client.getPrompt({ name, arguments: args }) } diff --git a/packages/ai-mcp/src/errors.ts b/packages/ai-mcp/src/errors.ts index 66647bd51..1d3ca695d 100644 --- a/packages/ai-mcp/src/errors.ts +++ b/packages/ai-mcp/src/errors.ts @@ -1,5 +1,8 @@ export class MCPConnectionError extends Error { - constructor(message: string, public override readonly cause?: unknown) { + constructor( + message: string, + public override readonly cause?: unknown, + ) { super(message) this.name = 'MCPConnectionError' } diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 9969ac44f..80d8a83e5 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -26,4 +26,3 @@ export { createMCPClients } from './pool' export type { MCPClients, MCPClientsConfig } from './pool' export { defineConfig } from './cli/define-config' export type { MCPCodegenConfig, CodegenServerConfig } from './cli/define-config' - diff --git a/packages/ai-mcp/src/pool.ts b/packages/ai-mcp/src/pool.ts index 0ba4d7042..1d9aec930 100644 --- a/packages/ai-mcp/src/pool.ts +++ b/packages/ai-mcp/src/pool.ts @@ -51,8 +51,9 @@ export async function createMCPClients< const ok = settled.filter( ( r, - ): r is PromiseFulfilledResult]> => - r.status === 'fulfilled', + ): r is PromiseFulfilledResult< + readonly [string, MCPClient] + > => r.status === 'fulfilled', ) const failed = settled .map((r, i) => (r.status === 'rejected' ? names[i] : null)) diff --git a/packages/ai-mcp/src/tools.ts b/packages/ai-mcp/src/tools.ts index 6136c98da..893f32c3c 100644 --- a/packages/ai-mcp/src/tools.ts +++ b/packages/ai-mcp/src/tools.ts @@ -11,7 +11,8 @@ export function mcpContentToTanstack( content: Array, ): string | Array { // Single text block → plain string (most common, best for the model). - if (content.length === 1 && content[0]?.type === 'text') return content[0].text + if (content.length === 1 && content[0]?.type === 'text') + return content[0].text return content.map((c): ContentPart => { switch (c.type) { case 'text': @@ -74,7 +75,10 @@ export function toServerTools( __toolSide: 'server', name, description: def.description ?? '', - inputSchema: (def.inputSchema as any) ?? { type: 'object', properties: {} }, + inputSchema: (def.inputSchema as any) ?? { + type: 'object', + properties: {}, + }, ...(def.outputSchema ? { outputSchema: def.outputSchema as any } : {}), ...(options.lazy ? { lazy: true } : {}), metadata: { mcp: { serverToolName: def.name } }, diff --git a/packages/ai-mcp/src/transport.ts b/packages/ai-mcp/src/transport.ts index d6c467e84..9a78ca58c 100644 --- a/packages/ai-mcp/src/transport.ts +++ b/packages/ai-mcp/src/transport.ts @@ -40,7 +40,9 @@ function isTransportInstance(input: TransportInput): input is Transport { return typeof (input as Transport).start === 'function' } -export async function resolveTransport(input: TransportInput): Promise { +export async function resolveTransport( + input: TransportInput, +): Promise { if (isTransportInstance(input)) return input switch (input.type) { diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index 82251ecf8..520e66a46 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -39,17 +39,15 @@ export interface ToolsOptions { * already returns a fully-typed `ServerTool`, so a * mapped tuple over the passed definitions preserves per-tool types. */ -export type ServerToolFromDef = TDef extends ToolDefinition< - infer TInput, - infer TOutput, - infer TName -> - ? ServerTool - : never +export type ServerToolFromDef = + TDef extends ToolDefinition + ? ServerTool + : never -export type MappedServerTools> = { - -readonly [K in keyof TDefs]: ServerToolFromDef -} +export type MappedServerTools> = + { + -readonly [K in keyof TDefs]: ServerToolFromDef + } /** * ServerTool typed from one descriptor tool entry, named by its key `TKey`. diff --git a/packages/ai-mcp/tests/cli-config.test.ts b/packages/ai-mcp/tests/cli-config.test.ts index 5663c4956..77eb28b46 100644 --- a/packages/ai-mcp/tests/cli-config.test.ts +++ b/packages/ai-mcp/tests/cli-config.test.ts @@ -8,7 +8,9 @@ import { loadConfig } from '../src/cli/config' describe('defineConfig', () => { it('returns the config verbatim (identity helper for typing)', () => { const cfg = defineConfig({ - servers: { weather: { transport: { type: 'http', url: 'https://x/mcp' } } }, + servers: { + weather: { transport: { type: 'http', url: 'https://x/mcp' } }, + }, outFile: './mcp-types.generated.ts', }) expect(cfg.servers.weather?.transport.type).toBe('http') @@ -35,7 +37,10 @@ describe('loadConfig', () => { }, outFile: './mcp-types.generated.ts', } - writeFileSync(join(tmpDir, 'mcp.config.json'), JSON.stringify(configContent)) + writeFileSync( + join(tmpDir, 'mcp.config.json'), + JSON.stringify(configContent), + ) const cfg = await loadConfig(tmpDir) diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts index e5c739693..ce675266b 100644 --- a/packages/ai-mcp/tests/client.test.ts +++ b/packages/ai-mcp/tests/client.test.ts @@ -26,10 +26,13 @@ describe('createMCPClient', () => { const tools = await client.tools([getWeather]) expect(tools).toHaveLength(1) expect(tools[0].name).toBe('get_weather') - const result = await tools[0].execute!({ city: 'Brooklyn' }, { - toolCallId: 't', - emitCustomEvent: () => {}, - }) + const result = await tools[0].execute!( + { city: 'Brooklyn' }, + { + toolCallId: 't', + emitCustomEvent: () => {}, + }, + ) expect(JSON.stringify(result)).toContain('Sunny in Brooklyn') }) diff --git a/packages/ai-mcp/tests/helpers/in-memory-server.ts b/packages/ai-mcp/tests/helpers/in-memory-server.ts index 3693a670f..45befdbe1 100644 --- a/packages/ai-mcp/tests/helpers/in-memory-server.ts +++ b/packages/ai-mcp/tests/helpers/in-memory-server.ts @@ -16,7 +16,8 @@ export async function makeServerWithWeatherTool() { content: [{ type: 'text' as const, text: `Sunny in ${city}` }], }), ) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() await server.connect(serverTransport) return { server, clientTransport } } @@ -32,7 +33,8 @@ export async function makeServerWithResource() { contents: [{ uri: 'file:///hello.txt', text: 'hello from resource' }], }), ) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() await server.connect(serverTransport) return { server, clientTransport } } @@ -55,7 +57,8 @@ export async function makeServerWithPrompt() { ], }), ) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() await server.connect(serverTransport) return { server, clientTransport } } @@ -100,7 +103,8 @@ export async function makeFullServer() { }), ) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() await server.connect(serverTransport) return { server, clientTransport } } diff --git a/packages/ai-mcp/tests/pool.test.ts b/packages/ai-mcp/tests/pool.test.ts index eaa24e007..66f3d0093 100644 --- a/packages/ai-mcp/tests/pool.test.ts +++ b/packages/ai-mcp/tests/pool.test.ts @@ -17,7 +17,9 @@ describe('createMCPClients', () => { it('exposes typed per-server access via .clients', async () => { const a = await makeServerWithWeatherTool() - await using pool = await createMCPClients({ alpha: { transport: a.clientTransport } }) + await using pool = await createMCPClients({ + alpha: { transport: a.clientTransport }, + }) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(await pool.clients.alpha!.tools()).toBeDefined() }) diff --git a/packages/ai-mcp/tests/resources.test.ts b/packages/ai-mcp/tests/resources.test.ts index 607bdca8e..6abf1fdd8 100644 --- a/packages/ai-mcp/tests/resources.test.ts +++ b/packages/ai-mcp/tests/resources.test.ts @@ -22,7 +22,10 @@ describe('mcpResourceToContentPart', () => { }) it('falls back to JSON.stringify for unknown content', () => { - const input = { uri: 'file:///unknown', mimeType: 'application/octet-stream' } + const input = { + uri: 'file:///unknown', + mimeType: 'application/octet-stream', + } const part = mcpResourceToContentPart(input) expect(part.type).toBe('text') expect((part as { type: 'text'; content: string }).content).toBe( diff --git a/packages/ai-mcp/tests/tools.test.ts b/packages/ai-mcp/tests/tools.test.ts index a3ede939c..335cfeb9a 100644 --- a/packages/ai-mcp/tests/tools.test.ts +++ b/packages/ai-mcp/tests/tools.test.ts @@ -10,14 +10,20 @@ describe('toServerTools', () => { await client.connect(clientTransport) const defs = (await client.listTools()).tools - const tools = toServerTools(client, defs, { prefix: undefined, lazy: false }) + const tools = toServerTools(client, defs, { + prefix: undefined, + lazy: false, + }) expect(tools.map((t) => t.name)).toContain('get_weather') const tool = tools.find((t) => t.name === 'get_weather')! - const result = await tool.execute!({ city: 'Brooklyn' }, { - toolCallId: 't', - emitCustomEvent: () => {}, - }) + const result = await tool.execute!( + { city: 'Brooklyn' }, + { + toolCallId: 't', + emitCustomEvent: () => {}, + }, + ) expect(JSON.stringify(result)).toContain('Sunny in Brooklyn') await client.close() }) diff --git a/packages/ai-mcp/tests/transport.test.ts b/packages/ai-mcp/tests/transport.test.ts index f172859f9..a66d25b35 100644 --- a/packages/ai-mcp/tests/transport.test.ts +++ b/packages/ai-mcp/tests/transport.test.ts @@ -13,7 +13,11 @@ describe('resolveTransport', () => { }) it('passes through a user-supplied transport instance', async () => { - const fake = { start: async () => {}, send: async () => {}, close: async () => {} } + const fake = { + start: async () => {}, + send: async () => {}, + close: async () => {}, + } const t = await resolveTransport(fake as any) expect(t).toBe(fake) }) diff --git a/packages/ai/tests/tool-abort-threading.test.ts b/packages/ai/tests/tool-abort-threading.test.ts index 572a9e48e..3b5cd2905 100644 --- a/packages/ai/tests/tool-abort-threading.test.ts +++ b/packages/ai/tests/tool-abort-threading.test.ts @@ -16,7 +16,13 @@ describe('executeToolCalls abort threading', () => { seen = ctx?.abortSignal return 'ok' }) - const calls = [{ id: 'c1', type: 'function', function: { name: 'probe', arguments: '{}' } }] + const calls = [ + { + id: 'c1', + type: 'function', + function: { name: 'probe', arguments: '{}' }, + }, + ] const gen = executeToolCalls( calls as any, [tool], diff --git a/packages/ai/tests/tool-execution-context.test.ts b/packages/ai/tests/tool-execution-context.test.ts index b52ed8466..f32ca0d9b 100644 --- a/packages/ai/tests/tool-execution-context.test.ts +++ b/packages/ai/tests/tool-execution-context.test.ts @@ -16,11 +16,14 @@ describe('ToolExecutionContext.abortSignal', () => { return args.v }) // Invoke execute directly with a context to assert the field is typed + forwarded. - await tool.execute!({ v: 'hi' }, { - toolCallId: 't1', - emitCustomEvent: () => {}, - abortSignal: controller.signal, - }) + await tool.execute!( + { v: 'hi' }, + { + toolCallId: 't1', + emitCustomEvent: () => {}, + abortSignal: controller.signal, + }, + ) expect(seen).toBe(controller.signal) }) }) From 16872d5bd8173aa39de4b263459e8c7b492f8653 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 12:35:10 +0200 Subject: [PATCH 33/71] feat(ai): MCPToolSource interface and chat mcp option types --- packages/ai/src/activities/chat/mcp/types.ts | 55 ++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 packages/ai/src/activities/chat/mcp/types.ts diff --git a/packages/ai/src/activities/chat/mcp/types.ts b/packages/ai/src/activities/chat/mcp/types.ts new file mode 100644 index 000000000..cf523f80d --- /dev/null +++ b/packages/ai/src/activities/chat/mcp/types.ts @@ -0,0 +1,55 @@ +import type { ServerTool } from '../tools/tool-definition' + +/** + * Minimal structural shape that `chat({ mcp })` needs from an MCP client. + * + * `@tanstack/ai-mcp`'s `MCPClient` and `MCPClients` satisfy this interface by + * shape — the core `@tanstack/ai` package does NOT import `@tanstack/ai-mcp` + * (ai-mcp depends on ai, not the reverse). + */ +export interface MCPToolSource { + tools: (options?: { lazy?: boolean }) => Promise> + close: () => Promise +} + +/** + * Controls what happens to MCP connections after tool discovery. + * + * - `'close'` (default) — connections are closed after tools are discovered. + * - `'keep-alive'` — connections are kept open for the duration of the chat call. + */ +export type MCPConnectionPolicy = 'close' | 'keep-alive' + +/** + * Options controlling MCP tool discovery and lifecycle for a `chat()` call. + */ +export interface ChatMCPOptions { + /** + * The MCP clients or client pools to discover tools from and manage. + */ + clients: Array + + /** + * Connection lifecycle policy applied to all clients after tool discovery. + * + * Defaults to `'close'`. + */ + connection?: MCPConnectionPolicy + + /** + * When `true`, tool schemas are fetched lazily (forwarded to + * `tools({ lazy: true })`). + * + * Defaults to `false`. + */ + lazyTools?: boolean + + /** + * Called when tool discovery fails for a single source. + * + * - Throw (or re-throw) from this handler to fail the entire chat call fast. + * - Return normally to skip that source and continue with remaining clients. + * - Omit this handler entirely to rethrow the error (fail-fast by default). + */ + onDiscoveryError?: (error: unknown, source: MCPToolSource) => void +} From 2a9010a2ac83b2a8f213531081e6192644e89aca Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 12:36:57 +0200 Subject: [PATCH 34/71] feat(ai): export MCP chat option types --- packages/ai/src/index.ts | 7 +++++++ packages/ai/tests/chat-mcp-types.test-d.ts | 9 +++++++++ 2 files changed, 16 insertions(+) create mode 100644 packages/ai/tests/chat-mcp-types.test-d.ts diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index c406290ea..1c97bef80 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -52,6 +52,13 @@ export { type InferToolOutput, } from './activities/chat/tools/tool-definition' +// MCP chat option types +export type { + MCPToolSource, + ChatMCPOptions, + MCPConnectionPolicy, +} from './activities/chat/mcp/types' + // Schema conversion (Standard JSON Schema compliant) export { convertSchemaToJsonSchema, diff --git a/packages/ai/tests/chat-mcp-types.test-d.ts b/packages/ai/tests/chat-mcp-types.test-d.ts new file mode 100644 index 000000000..66cd66d3a --- /dev/null +++ b/packages/ai/tests/chat-mcp-types.test-d.ts @@ -0,0 +1,9 @@ +import { expectTypeOf } from 'vitest' +import type { MCPToolSource } from '../src' + +// A plain object with tools()/close() satisfies the structural interface. +const fake = { + tools: async () => [], + close: async () => {}, +} +expectTypeOf(fake).toMatchTypeOf() From 3e9fb74390c415f3192e204145dc74282d1052a2 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 12:42:33 +0200 Subject: [PATCH 35/71] =?UTF-8?q?feat(ai):=20MCPManager=20=E2=80=94=20enca?= =?UTF-8?q?psulates=20chat=20mcp=20discovery=20+=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/src/activities/chat/mcp/manager.ts | 82 +++++++++++++++ packages/ai/tests/chat-mcp-manager.test.ts | 99 +++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 packages/ai/src/activities/chat/mcp/manager.ts create mode 100644 packages/ai/tests/chat-mcp-manager.test.ts diff --git a/packages/ai/src/activities/chat/mcp/manager.ts b/packages/ai/src/activities/chat/mcp/manager.ts new file mode 100644 index 000000000..4ca75902a --- /dev/null +++ b/packages/ai/src/activities/chat/mcp/manager.ts @@ -0,0 +1,82 @@ +import type { ServerTool } from '../tools/tool-definition' +import type { ChatMCPOptions, MCPToolSource } from './types' + +export class MCPDuplicateToolNameError extends Error { + constructor(public readonly toolName: string) { + super( + `Duplicate MCP tool name "${toolName}" in chat({ mcp.clients }). ` + + `Set a unique \`prefix\` on one of the MCP clients (or use a pool, ` + + `which auto-prefixes) to disambiguate.`, + ) + this.name = 'MCPDuplicateToolNameError' + } +} + +/** + * Encapsulates MCP tool discovery + connection lifecycle for chat(). + * Built from chat()'s `mcp` option; runners only call `discover()` then + * `dispose()`. A manager built from `undefined` is an inert no-op + * (`discover()` → `[]`, `dispose()` → no-op), so runners need no branching. + */ +export class MCPManager { + static from(options: ChatMCPOptions | undefined): MCPManager { + return new MCPManager(options) + } + + readonly #sources: ReadonlyArray + readonly #shouldClose: boolean + readonly #lazyTools: boolean + readonly #onDiscoveryError?: (error: unknown, source: MCPToolSource) => void + + private constructor(options: ChatMCPOptions | undefined) { + this.#sources = options?.clients ?? [] + // default 'close'; only 'keep-alive' disables closing + this.#shouldClose = options ? options.connection !== 'keep-alive' : false + this.#lazyTools = options?.lazyTools ?? false + this.#onDiscoveryError = options?.onDiscoveryError + } + + /** + * Discover + merge tools from all sources. Throws on a fatal discovery error + * (no `onDiscoveryError`, or it re-threw) or a duplicate tool name; in that + * case it first closes any connected sources when the policy is 'close'. + */ + async discover(): Promise> { + if (this.#sources.length === 0) return [] + try { + const settled = await Promise.allSettled( + this.#sources.map((s) => s.tools({ lazy: this.#lazyTools })), + ) + const tools: Array = [] + const zipped = this.#sources.map( + (source, i) => [source, settled[i]] as const, + ) + for (const [source, result] of zipped) { + if (result === undefined) continue + if (result.status === 'fulfilled') { + tools.push(...result.value) + } else if (this.#onDiscoveryError) { + // throw inside handler ⇒ propagate (fail-fast); return ⇒ skip + this.#onDiscoveryError(result.reason, source) + } else { + throw result.reason + } + } + const seen = new Set() + for (const t of tools) { + if (seen.has(t.name)) throw new MCPDuplicateToolNameError(t.name) + seen.add(t.name) + } + return tools + } catch (err) { + await this.dispose() // cleanup-on-failure (no-op if keep-alive) + throw err + } + } + + /** Close sources iff policy is 'close'. Idempotent; never throws. */ + async dispose(): Promise { + if (!this.#shouldClose || this.#sources.length === 0) return + await Promise.allSettled(this.#sources.map((s) => s.close())) + } +} diff --git a/packages/ai/tests/chat-mcp-manager.test.ts b/packages/ai/tests/chat-mcp-manager.test.ts new file mode 100644 index 000000000..27dbbc836 --- /dev/null +++ b/packages/ai/tests/chat-mcp-manager.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MCPDuplicateToolNameError, + MCPManager, +} from '../src/activities/chat/mcp/manager' +import type { ServerTool } from '../src' + +function tool(name: string): ServerTool { + return { + __toolSide: 'server', + name, + description: '', + inputSchema: { type: 'object', properties: {} }, + execute: async () => 'ok', + } +} + +function source(tools: Array, opts: { fail?: boolean } = {}) { + const s = { + closed: false, + tools: async (_o?: { lazy?: boolean }) => { + if (opts.fail) throw new Error('discovery failed') + return tools + }, + close: async () => { + s.closed = true + }, + } + return s +} + +describe('MCPManager', () => { + it('no-op when built from undefined', async () => { + const m = MCPManager.from(undefined) + expect(await m.discover()).toEqual([]) + await m.dispose() // no throw + }) + + it('discover() merges tools and forwards lazyTools', async () => { + const a = source([tool('a')]) + const b = source([tool('b')]) + const spyA = vi.spyOn(a, 'tools') + const m = MCPManager.from({ clients: [a, b], lazyTools: true }) + expect((await m.discover()).map((t) => t.name)).toEqual(['a', 'b']) + expect(spyA).toHaveBeenCalledWith({ lazy: true }) + }) + + it('discover() throws MCPDuplicateToolNameError on collision', async () => { + const m = MCPManager.from({ + clients: [source([tool('x')]), source([tool('x')])], + }) + await expect(m.discover()).rejects.toBeInstanceOf(MCPDuplicateToolNameError) + }) + + it('default connection closes sources on dispose()', async () => { + const a = source([tool('a')]) + const m = MCPManager.from({ clients: [a] }) + await m.discover() + await m.dispose() + expect(a.closed).toBe(true) + }) + + it("connection 'keep-alive' does NOT close on dispose()", async () => { + const a = source([tool('a')]) + const m = MCPManager.from({ clients: [a], connection: 'keep-alive' }) + await m.discover() + await m.dispose() + expect(a.closed).toBe(false) + }) + + it('rethrows by default on discovery failure and self-cleans (close policy)', async () => { + const a = source([tool('a')]) + const b = source([], { fail: true }) + const m = MCPManager.from({ clients: [a, b] }) // default close + await expect(m.discover()).rejects.toThrow('discovery failed') + expect(a.closed).toBe(true) // cleanup-on-failure + }) + + it('onDiscoveryError returning skips the failed source', async () => { + const onDiscoveryError = vi.fn() + const m = MCPManager.from({ + clients: [source([tool('a')]), source([], { fail: true })], + onDiscoveryError, + }) + expect((await m.discover()).map((t) => t.name)).toEqual(['a']) + expect(onDiscoveryError).toHaveBeenCalledOnce() + }) + + it('onDiscoveryError throwing propagates', async () => { + const m = MCPManager.from({ + clients: [source([], { fail: true })], + onDiscoveryError: () => { + throw new Error('abort') + }, + }) + await expect(m.discover()).rejects.toThrow('abort') + }) +}) + From 40e0a39996608791eaf1715292552f90c9a9af1a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 12:47:55 +0200 Subject: [PATCH 36/71] feat(ai): add mcp option to TextActivityOptions --- packages/ai/src/activities/chat/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index cdf34941f..93a1da9fe 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -69,6 +69,7 @@ import type { MergeContext, UnionToIntersection, } from './runtime-context-types' +import type { ChatMCPOptions } from './mcp/types' // =========================== // Activity Kind @@ -208,6 +209,12 @@ export interface TextActivityOptions< | ProviderTool > | undefined + /** + * Hand MCP clients/pools to chat(): their tools are discovered at run start + * and merged into the run; `connection` controls whether chat() closes them + * when the run ends. See docs/tools/mcp.md "Managing MCP clients with chat()". + */ + mcp?: ChatMCPOptions /** Controls the randomness of the output. Higher values make output more random. Range: [0.0, 2.0] */ temperature?: TextOptions['temperature'] /** Nucleus sampling parameter. The model considers tokens with topP probability mass. */ From c5c7b5da36b167f314bbe64b28a56cf93f53ff63 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 12:51:59 +0200 Subject: [PATCH 37/71] feat(ai): wire MCPManager into chat() runners --- packages/ai/src/activities/chat/index.ts | 69 +++++++++++++++++++----- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 93a1da9fe..2728bf073 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -25,6 +25,7 @@ import { import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies' import { convertMessagesToModelMessages, generateMessageId } from './messages' import { MiddlewareRunner } from './middleware/compose' +import { MCPManager } from './mcp/manager' import type { ApprovalRequest, ClientToolRequest, @@ -2598,10 +2599,16 @@ export function chat< async function* runStreamingText( options: TextActivityOptions, ): AsyncIterable { - const { adapter, middleware, context, debug, ...textOptions } = options + const { adapter, middleware, context, debug, mcp, ...textOptions } = options const model = adapter.model const logger = resolveDebugOption(debug) + const mcpManager = MCPManager.from(mcp) + const mcpTools = await mcpManager.discover() + if (mcpTools.length > 0) { + textOptions.tools = [...(textOptions.tools ?? []), ...mcpTools] + } + const engine = new TextEngine( { adapter, @@ -2616,8 +2623,12 @@ async function* runStreamingText( logger, ) - for await (const chunk of engine.run()) { - yield chunk + try { + for await (const chunk of engine.run()) { + yield chunk + } + } finally { + await mcpManager.dispose() } } @@ -2654,8 +2665,15 @@ async function runAgenticStructuredOutput< >( options: TextActivityOptions, ): Promise> { - const { adapter, outputSchema, middleware, context, debug, ...textOptions } = - options + const { + adapter, + outputSchema, + middleware, + context, + debug, + mcp, + ...textOptions + } = options const model = adapter.model const logger = resolveDebugOption(debug) @@ -2689,6 +2707,12 @@ async function runAgenticStructuredOutput< const nativeCombined = adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true + const mcpManager = MCPManager.from(mcp) + const mcpTools = await mcpManager.discover() + if (mcpTools.length > 0) { + textOptions.tools = [...(textOptions.tools ?? []), ...mcpTools] + } + const engine = new TextEngine( { adapter, @@ -2709,9 +2733,13 @@ async function runAgenticStructuredOutput< logger, ) - // Consume the stream — chunks pipe through middleware but are not yielded externally - for await (const _chunk of engine.run()) { - // intentionally empty + try { + // Consume the stream — chunks pipe through middleware but are not yielded externally + for await (const _chunk of engine.run()) { + // intentionally empty + } + } finally { + await mcpManager.dispose() } const finalizationError = engine.getFinalizationError() @@ -2942,8 +2970,15 @@ async function* runStreamingStructuredOutputImpl< options: TextActivityOptions, jsonSchema: NonNullable>, ): StructuredOutputStreamInternal> { - const { adapter, outputSchema, middleware, context, debug, ...textOptions } = - options + const { + adapter, + outputSchema, + middleware, + context, + debug, + mcp, + ...textOptions + } = options const model = adapter.model const logger = resolveDebugOption(debug) @@ -2957,6 +2992,12 @@ async function* runStreamingStructuredOutputImpl< const nativeCombined = adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true + const mcpManager = MCPManager.from(mcp) + const mcpTools = await mcpManager.discover() + if (mcpTools.length > 0) { + textOptions.tools = [...(textOptions.tools ?? []), ...mcpTools] + } + // Inputs may be UIMessages (from useChat) or ModelMessages (from server-side // callers). TextEngine handles the conversion uniformly. const engine = new TextEngine( @@ -2978,8 +3019,12 @@ async function* runStreamingStructuredOutputImpl< logger, ) - for await (const chunk of engine.run()) { - yield chunk + try { + for await (const chunk of engine.run()) { + yield chunk + } + } finally { + await mcpManager.dispose() } // Schema validation for the streaming variant remains the consumer's From d4c966c3fdb288ec4a0f8f51dc2027352baeb79b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 12:55:26 +0200 Subject: [PATCH 38/71] test(ai): chat({ mcp }) discovery and lifecycle behavior --- packages/ai/tests/chat-mcp.test.ts | 396 +++++++++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 packages/ai/tests/chat-mcp.test.ts diff --git a/packages/ai/tests/chat-mcp.test.ts b/packages/ai/tests/chat-mcp.test.ts new file mode 100644 index 000000000..0acb99449 --- /dev/null +++ b/packages/ai/tests/chat-mcp.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it, vi } from 'vitest' +import { chat } from '../src/activities/chat/index' +import { MCPDuplicateToolNameError } from '../src/activities/chat/mcp/manager' +import type { StreamChunk } from '../src/types' +import type { MCPToolSource } from '../src/activities/chat/mcp/types' +import type { ServerTool } from '../src/activities/chat/tools/tool-definition' +import { ev, createMockAdapter, collectChunks, serverTool } from './test-utils' + +// ============================================================================ +// Fake MCP source factory +// ============================================================================ + +interface FakeSourceOpts { + /** When true, tools() rejects with an error */ + fail?: boolean + /** When true, tools() resolves but close() is counted separately */ + failClose?: boolean +} + +function fakeSource( + toolNames: ReadonlyArray, + opts?: FakeSourceOpts, +): MCPToolSource & { readonly closed: boolean; readonly toolCallCount: number } { + let closed = false + let toolCallCount = 0 + + const source = { + get closed() { + return closed + }, + get toolCallCount() { + return toolCallCount + }, + tools: (options?: { lazy?: boolean }): Promise> => { + toolCallCount++ + if (opts?.fail) { + return Promise.reject(new Error(`discovery failed for ${toolNames[0] ?? 'unknown'}`)) + } + const tools: Array = toolNames.map((name) => + ({ + __toolSide: 'server' as const, + name, + description: `MCP tool: ${name}`, + execute: (_args: unknown) => ({ mcp: true, tool: name }), + }) satisfies ServerTool, + ) + // Spy-able: caller can inspect toolCallCount and options via the spy wrapping tools() + void options // used by spy in lazyTools test + return Promise.resolve(tools) + }, + close: async (): Promise => { + closed = true + }, + } + return source +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('chat({ mcp })', () => { + // -------------------------------------------------------------------------- + // Case 1: default connection:'close' closes every source after stream drains + // -------------------------------------------------------------------------- + it('default connection closes sources after stream drains normally', async () => { + const source = fakeSource(['mcpTool']) + + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textStart(), ev.textContent('hi'), ev.textEnd(), ev.runFinished('stop')], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hello' }], + mcp: { clients: [source] }, + }) + + expect(source.closed).toBe(false) + await collectChunks(stream as AsyncIterable) + expect(source.closed).toBe(true) + }) + + // -------------------------------------------------------------------------- + // Case 2: connection:'keep-alive' does NOT close sources + // -------------------------------------------------------------------------- + it("connection:'keep-alive' does not close sources", async () => { + const source = fakeSource(['mcpTool']) + + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textStart(), ev.textContent('hi'), ev.textEnd(), ev.runFinished('stop')], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hello' }], + mcp: { clients: [source], connection: 'keep-alive' }, + }) + + await collectChunks(stream as AsyncIterable) + expect(source.closed).toBe(false) + }) + + // -------------------------------------------------------------------------- + // Case 3: close fires on ERROR (adapter throws) — source is closed + // -------------------------------------------------------------------------- + it('source is closed when the adapter stream throws an error', async () => { + const source = fakeSource(['mcpTool']) + + const { adapter } = createMockAdapter({ + chatStreamFn: () => + (async function* (): AsyncIterable { + yield ev.runStarted() + throw new Error('adapter boom') + })(), + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hello' }], + mcp: { clients: [source] }, + }) + + await expect( + collectChunks(stream as AsyncIterable), + ).rejects.toThrow('adapter boom') + + expect(source.closed).toBe(true) + }) + + // -------------------------------------------------------------------------- + // Case 4: close fires on ABORT — source is closed after stream ends + // -------------------------------------------------------------------------- + it('source is closed when the run is aborted mid-stream', async () => { + const source = fakeSource(['mcpTool']) + const abortController = new AbortController() + + const { adapter } = createMockAdapter({ + chatStreamFn: () => + (async function* (): AsyncIterable { + yield ev.runStarted() + yield ev.textStart() + yield ev.textContent('chunk1') + yield ev.textContent('chunk2') + yield ev.textContent('chunk3') + yield ev.textEnd() + yield ev.runFinished('stop') + })(), + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hello' }], + mcp: { clients: [source] }, + abortController, + }) + + let count = 0 + for await (const _chunk of stream as AsyncIterable) { + count++ + if (count === 2) { + abortController.abort() + } + } + + // Stream drained (early) — the finally block must have run + expect(source.closed).toBe(true) + }) + + // -------------------------------------------------------------------------- + // Case 5: discovered tools reach the run and execute + // -------------------------------------------------------------------------- + it('discovered MCP tools are merged into the run and execute when called', async () => { + const mcpExecuteSpy = vi.fn().mockReturnValue({ mcp: true, result: 'ok' }) + const source: MCPToolSource = { + tools: async () => [ + { + __toolSide: 'server' as const, + name: 'mcpGetData', + description: 'Fetches data via MCP', + execute: mcpExecuteSpy, + } satisfies ServerTool, + ], + close: async () => {}, + } + + const { adapter } = createMockAdapter({ + iterations: [ + // First iteration: model calls the MCP-discovered tool + [ + ev.runStarted(), + ev.toolStart('call_mcp', 'mcpGetData'), + ev.toolArgs('call_mcp', '{"id":"42"}'), + ev.runFinished('tool_calls'), + ], + // Second iteration: model produces final text + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('Data fetched.'), + ev.textEnd(), + ev.runFinished('stop'), + ], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Get data' }], + mcp: { clients: [source] }, + }) + + const chunks = await collectChunks(stream as AsyncIterable) + + // The MCP tool execute function ran + expect(mcpExecuteSpy).toHaveBeenCalledTimes(1) + expect(mcpExecuteSpy).toHaveBeenCalledWith( + { id: '42' }, + expect.objectContaining({ toolCallId: 'call_mcp' }), + ) + + // There should be a tool result chunk in the stream + const toolResultChunks = chunks.filter( + (c) => c.type === 'TOOL_CALL_RESULT' && 'content' in c && (c as any).content, + ) + expect(toolResultChunks.length).toBeGreaterThanOrEqual(1) + }) + + // -------------------------------------------------------------------------- + // Case 6: lazyTools:true is forwarded to source.tools({ lazy: true }) + // -------------------------------------------------------------------------- + it('lazyTools:true is forwarded as { lazy: true } to source.tools()', async () => { + const toolsSpy = vi.fn().mockResolvedValue([ + { + __toolSide: 'server' as const, + name: 'lazyMcpTool', + description: 'lazy tool', + execute: () => ({ ok: true }), + } satisfies ServerTool, + ]) + + const source: MCPToolSource = { + tools: toolsSpy, + close: async () => {}, + } + + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textStart(), ev.textContent('ok'), ev.textEnd(), ev.runFinished('stop')], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + mcp: { clients: [source], lazyTools: true }, + }) + + await collectChunks(stream as AsyncIterable) + + expect(toolsSpy).toHaveBeenCalledTimes(1) + expect(toolsSpy).toHaveBeenCalledWith({ lazy: true }) + }) + + // -------------------------------------------------------------------------- + // Case 7a: onDiscoveryError returning skips failed source, run proceeds + // -------------------------------------------------------------------------- + it('onDiscoveryError returning skips the failed source and run proceeds', async () => { + const failingSource = fakeSource(['willFail'], { fail: true }) + const goodSource = fakeSource(['goodTool']) + const errorHandler = vi.fn() + + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textStart(), ev.textContent('ok'), ev.textEnd(), ev.runFinished('stop')], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + mcp: { + clients: [failingSource, goodSource], + onDiscoveryError: errorHandler, + }, + }) + + // Run completes without throwing + const chunks = await collectChunks(stream as AsyncIterable) + expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(true) + + // Error handler was called for the failing source + expect(errorHandler).toHaveBeenCalledTimes(1) + expect(errorHandler.mock.calls[0]![0]).toBeInstanceOf(Error) + }) + + // -------------------------------------------------------------------------- + // Case 7b: onDiscoveryError throwing causes chat() to reject + // -------------------------------------------------------------------------- + it('onDiscoveryError throwing causes chat() to reject', async () => { + const failingSource = fakeSource(['willFail'], { fail: true }) + + const { adapter } = createMockAdapter({ + iterations: [], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + mcp: { + clients: [failingSource], + onDiscoveryError: (_err: unknown) => { + throw new Error('fatal discovery') + }, + }, + }) + + await expect( + collectChunks(stream as AsyncIterable), + ).rejects.toThrow('fatal discovery') + }) + + // -------------------------------------------------------------------------- + // Case 8a: Duplicate tool name across sources rejects with MCPDuplicateToolNameError + // -------------------------------------------------------------------------- + it('duplicate MCP tool name rejects with MCPDuplicateToolNameError', async () => { + const source1 = fakeSource(['sharedTool']) + const source2 = fakeSource(['sharedTool']) + + const { adapter } = createMockAdapter({ iterations: [] }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + mcp: { clients: [source1, source2] }, + }) + + await expect( + collectChunks(stream as AsyncIterable), + ).rejects.toBeInstanceOf(MCPDuplicateToolNameError) + }) + + // -------------------------------------------------------------------------- + // Case 8b: cleanup-on-failure — sources that connected are closed when discovery throws + // -------------------------------------------------------------------------- + it('cleanup-on-failure: connected sources are closed when duplicate tool name is detected', async () => { + const source1 = fakeSource(['dupTool']) + const source2 = fakeSource(['dupTool']) + + const { adapter } = createMockAdapter({ iterations: [] }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + mcp: { clients: [source1, source2] }, + }) + + await expect( + collectChunks(stream as AsyncIterable), + ).rejects.toBeInstanceOf(MCPDuplicateToolNameError) + + // The duplicate error triggers dispose() — both sources get closed + expect(source1.closed).toBe(true) + expect(source2.closed).toBe(true) + }) + + // -------------------------------------------------------------------------- + // Case 8c: cleanup-on-failure under default close when error is thrown directly + // -------------------------------------------------------------------------- + it('cleanup-on-failure: sources that connected are closed when onDiscoveryError throws', async () => { + const goodSource = fakeSource(['goodTool']) + const failSource = fakeSource(['badTool'], { fail: true }) + + const { adapter } = createMockAdapter({ iterations: [] }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + mcp: { + clients: [goodSource, failSource], + // No onDiscoveryError handler → fail-fast (throws) + }, + }) + + await expect( + collectChunks(stream as AsyncIterable), + ).rejects.toThrow('discovery failed') + + // goodSource connected and must be closed by cleanup-on-failure + expect(goodSource.closed).toBe(true) + }) +}) From fe08737f4a8e8e0a1f48e0da27a2e19da4b3e0f1 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 13:02:11 +0200 Subject: [PATCH 39/71] fix(ai): remove unused serverTool import in chat-mcp test --- packages/ai/tests/chat-mcp.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/tests/chat-mcp.test.ts b/packages/ai/tests/chat-mcp.test.ts index 0acb99449..43506c5a2 100644 --- a/packages/ai/tests/chat-mcp.test.ts +++ b/packages/ai/tests/chat-mcp.test.ts @@ -4,7 +4,7 @@ import { MCPDuplicateToolNameError } from '../src/activities/chat/mcp/manager' import type { StreamChunk } from '../src/types' import type { MCPToolSource } from '../src/activities/chat/mcp/types' import type { ServerTool } from '../src/activities/chat/tools/tool-definition' -import { ev, createMockAdapter, collectChunks, serverTool } from './test-utils' +import { ev, createMockAdapter, collectChunks } from './test-utils' // ============================================================================ // Fake MCP source factory From 864dc74b113198955b024b7bfde517f69f854c8a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 13:04:43 +0200 Subject: [PATCH 40/71] test(ai-mcp): assert MCPClient/MCPClients satisfy MCPToolSource --- packages/ai-mcp/tests/mcp-tool-source.test-d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 packages/ai-mcp/tests/mcp-tool-source.test-d.ts diff --git a/packages/ai-mcp/tests/mcp-tool-source.test-d.ts b/packages/ai-mcp/tests/mcp-tool-source.test-d.ts new file mode 100644 index 000000000..09a5a8ddb --- /dev/null +++ b/packages/ai-mcp/tests/mcp-tool-source.test-d.ts @@ -0,0 +1,8 @@ +import { expectTypeOf } from 'vitest' +import type { MCPToolSource } from '@tanstack/ai' +import type { MCPClient, MCPClients } from '../src' + +declare const client: MCPClient +declare const pool: MCPClients +expectTypeOf(client).toMatchTypeOf() +expectTypeOf(pool).toMatchTypeOf() From 2b89988f61e68b2bcaae0b619a8fe1b4fb4a923f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 13:04:44 +0200 Subject: [PATCH 41/71] chore: changeset for chat({ mcp }) --- .changeset/chat-mcp-option.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chat-mcp-option.md diff --git a/.changeset/chat-mcp-option.md b/.changeset/chat-mcp-option.md new file mode 100644 index 000000000..64dad7afd --- /dev/null +++ b/.changeset/chat-mcp-option.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': minor +--- + +Add an `mcp` option to `chat()` for managing MCP clients directly: `chat({ mcp: { clients, connection, lazyTools, onDiscoveryError } })` discovers the given MCP clients'/pools' tools at run start, merges them into the run, and (by default, `connection: 'close'`) closes them when the run ends — or keeps them warm with `connection: 'keep-alive'`. Also exports `MCPToolSource`, `ChatMCPOptions`, and `MCPConnectionPolicy`. From 12afdcd08ae4ed874eb115be72ddbe8e933f54e0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 13:06:02 +0200 Subject: [PATCH 42/71] docs(skills): document chat({ mcp }) --- packages/ai-mcp/skills/ai-mcp/SKILL.md | 81 +++++++++++++++++++ .../skills/ai-core/chat-experience/SKILL.md | 63 +++++++++++++++ .../ai/skills/ai-core/tool-calling/SKILL.md | 61 ++++++++++++++ 3 files changed, 205 insertions(+) diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index 81f54c905..b12cbcc18 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -199,6 +199,87 @@ const tools = await client.tools() // client.close() called automatically at scope exit ``` +## `chat({ mcp })` — discovery + lifecycle in one prop + +Rather than calling `client.tools()` and `client.close()` yourself, pass the +`mcp` option to `chat()` and let it manage the full lifecycle. + +```typescript +// ChatMCPOptions shape: +// mcp: { +// clients: Array, +// connection?: 'close' | 'keep-alive', // default: 'close' +// lazyTools?: boolean, +// onDiscoveryError?: (error: unknown, source) => void, +// } +``` + +**Behavior:** + +- `chat()` calls `.tools()` on every entry in `clients` at run start and merges + all results into the tool list. +- `lazyTools: true` is forwarded to `tools({ lazy: true })`. +- `connection: 'close'` (default) — each client is closed after tool discovery + completes. Use `'keep-alive'` when tools may still be called after discovery + (agent loops, multi-turn runs). +- `onDiscoveryError`: throw (or re-throw) to abort the entire call; return + normally to skip that source and continue. Omitting the handler re-throws + (fail-fast). + +**When to use `mcp` vs. the tools spread:** + +| Approach | Use when | +|---|---| +| `chat({ mcp: { clients: [...] } })` | Convenience: discovery + lifecycle handled for you; untyped args are fine | +| `tools: [...await client.tools([toolDefinition(...)])]` | Fully-typed args/results via Zod schemas (`toolDefinition` mode) | + +**Server-side example:** + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const mcpClient = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) + + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + mcp: { + clients: [mcpClient], + connection: 'keep-alive', // keep open while the agent may still call tools + onDiscoveryError: (err, source) => { + console.warn('MCP discovery failed for source, skipping:', err) + // returning skips this source; throw to fail the whole call fast + }, + }, + }) + + return toServerSentEventsResponse(stream) + // mcpClient is closed by chat() when the run finishes (connection: 'keep-alive') +} +``` + +You can also pass an `MCPClients` pool directly: + +```typescript +const pool = await createMCPClients({ + github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } }, + linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } }, +}) + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + mcp: { clients: [pool], connection: 'keep-alive' }, +}) +``` + ## `createMCPClients` — multiple servers Connect to many MCP servers in parallel. Each config key becomes the default diff --git a/packages/ai/skills/ai-core/chat-experience/SKILL.md b/packages/ai/skills/ai-core/chat-experience/SKILL.md index 918fdec1b..89f01370c 100644 --- a/packages/ai/skills/ai-core/chat-experience/SKILL.md +++ b/packages/ai/skills/ai-core/chat-experience/SKILL.md @@ -308,6 +308,69 @@ const { messages, sendMessage } = useChat({ The only difference is swapping `toServerSentEventsResponse` / `fetchServerSentEvents` for `toHttpResponse` / `fetchHttpStream`. Everything else stays identical. +### 5. MCP Tool Discovery via `chat({ mcp })` + +Pass `mcp` to let `chat()` own discovery **and** lifecycle for one or more MCP +clients. Useful when you want minimal boilerplate and don't need to reuse the +clients across calls. + +```typescript +// Prop shape: +// chat({ +// ..., +// mcp: { +// clients: Array, +// connection?: 'close' | 'keep-alive', // default: 'close' +// lazyTools?: boolean, +// onDiscoveryError?: (error: unknown, source) => void, +// } +// }) +``` + +- **`clients`** — one or more `MCPClient` / `MCPClients` instances. +- **`connection`** — `'close'` (default) closes each client after tool discovery; + `'keep-alive'` holds connections open for the duration of the call (needed when + tools execute after discovery, e.g. in a multi-turn agent loop). +- **`lazyTools`** — forwarded to `tools({ lazy: true })` so tool schemas are + sent to the LLM on demand. +- **`onDiscoveryError`** — throw (or re-throw) to fail the entire call fast; + return normally to skip that source and continue. Omit to rethrow (fail-fast). + +**When to use `mcp` vs. the tools spread:** + +| Approach | Use when | +|---|---| +| `chat({ mcp: { clients: [...] } })` | You want discovery + lifecycle managed for you, and don't need fully-typed input/output schemas | +| `tools: [...await client.tools([toolDefinition(...)])]` | You want fully-typed MCP tools with Zod input/output validation | + +**Server-side example:** + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const mcpClient = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) + + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + mcp: { + clients: [mcpClient], + connection: 'keep-alive', // keep open while tools may still be called + }, + }) + + return toServerSentEventsResponse(stream) + // chat() closes mcpClient when the run ends (connection: 'keep-alive' closes on finish) +} +``` + ## Common Mistakes ### a. CRITICAL: Using Vercel AI SDK patterns (streamText, generateText) diff --git a/packages/ai/skills/ai-core/tool-calling/SKILL.md b/packages/ai/skills/ai-core/tool-calling/SKILL.md index 5e28f1273..d9077d4f2 100644 --- a/packages/ai/skills/ai-core/tool-calling/SKILL.md +++ b/packages/ai/skills/ai-core/tool-calling/SKILL.md @@ -500,6 +500,67 @@ const mcp = await createMCPClient({ Import `stdioTransport` from the `/stdio` subpath only — it contains Node.js `child_process` imports and must not be bundled for edge runtimes. +### `chat({ mcp })` — discovery + lifecycle in one prop + +Instead of manually calling `client.tools()` and managing `close()`, pass an +`mcp` object and let `chat()` handle discovery and lifecycle. + +```typescript +// Prop shape (ChatMCPOptions): +// mcp: { +// clients: Array, +// connection?: 'close' | 'keep-alive', // default: 'close' +// lazyTools?: boolean, +// onDiscoveryError?: (error: unknown, source) => void, +// } +``` + +- At run start, `chat()` calls `.tools()` on every entry in `clients` and merges + the results — identical to spreading `await client.tools()` into `tools: [...]`. +- `lazyTools: true` is forwarded to `tools({ lazy: true })`. +- `onDiscoveryError`: throw to fail-fast; return to skip that source. +- `connection: 'close'` (default) closes each client after discovery. Use + `'keep-alive'` when the model may still invoke tools after the discovery phase + (agent loops, multi-turn runs). + +**When to use `mcp` vs. the tools spread:** + +| Approach | Use when | +|---|---| +| `chat({ mcp: { clients: [...] } })` | Convenience: discovery + lifecycle in one place; untyped tool args are acceptable | +| `tools: [...await client.tools([toolDefinition(...)])]` | Fully-typed tool args/results via Zod schemas | + +**Example:** + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const mcpClient = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) + + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + mcp: { + clients: [mcpClient], + connection: 'keep-alive', + onDiscoveryError: (err, source) => { + console.warn('MCP discovery failed, skipping source:', err) + // returning (not throwing) skips this source and continues + }, + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + ## Common Mistakes ### a. HIGH: Not passing tool definitions to both server and client From 38671780ce3b5b33d3085c5312d54967907b861b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 13:06:28 +0200 Subject: [PATCH 43/71] docs: document chat({ mcp }) for managing MCP clients --- docs/tools/mcp.md | 390 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 390 insertions(+) diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index fd2595c72..319c4f002 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -247,6 +247,8 @@ If any server fails to connect, already-connected clients are closed before the The MCP client is **caller-owned**. `chat()` never closes it. +> **Prefer to let `chat()` manage lifecycle?** If you'd rather skip the `try/finally` and have `chat()` discover tools and close clients automatically, see [Managing MCP clients with `chat()`](#managing-mcp-clients-with-chat). + ### Manual close ```ts @@ -270,6 +272,394 @@ const stream = chat({ ..., tools: await mcp.tools() }) return toServerSentEventsResponse(stream) ``` +## Managing MCP clients with `chat()` + +You have one or more live MCP clients (or pools) and you want the model to use their tools — without writing boilerplate `await client.tools()` calls and `try/finally close()` blocks for every route. Pass them to `chat()` via the `mcp` option and it handles both discovery and lifecycle for you. + +> **When to use `mcp` vs the `tools` spread** +> +> - Use `mcp: { clients: [...] }` when you want **discovery + lifecycle** managed for you and you are happy with runtime-typed (`unknown`-argument) tools. +> - Use `tools: [...await client.tools([toolDefinition(...)])]` when you need **fully-typed MCP tools** — the defs overload gives you Zod-validated, TypeScript-typed arguments. See [Three Modes of Type Safety](#three-modes-of-type-safety). +> +> Both coexist in the same `chat()` call. Tools from `mcp.clients` are merged with any tools you pass explicitly via `tools`. + +### Hand a client to `chat()` + +The simplest path: create a client, hand it to `chat()`, and let the run clean it up. `connection` defaults to `'close'`, so the client is closed automatically once the run ends — on success, error, or abort. + +```ts +// app/api/chat/route.ts (Next.js App Router) +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + const mcpClient = await createMCPClient({ + transport: { + type: 'http', + url: process.env.MCP_URL!, + headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, + }, + }) + + // chat() discovers mcpClient's tools and closes the connection when done. + // No try/finally needed. + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [mcpClient], + // connection: 'close' is the default — shown here for clarity + connection: 'close', + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +### Multiple servers and pools + +Pass any mix of `MCPClient` instances and `MCPClients` pools. Their tools are discovered in parallel and merged into one flat tool set. Pools auto-prefix each server's tools with the config key to prevent name collisions. + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient, createMCPClients } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + // A pool of two servers — their tools are prefixed "github_" and "linear_" + const githubLinearPool = await createMCPClients({ + github: { + transport: { + type: 'http', + url: process.env.GITHUB_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, + }, + }, + linear: { + transport: { + type: 'http', + url: process.env.LINEAR_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, + }, + }, + }) + + // A standalone client for an internal server + const internalClient = await createMCPClient({ + transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! }, + }) + + // All three servers' tools are merged: github_*, linear_*, plus internal tools + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [githubLinearPool, internalClient], + connection: 'close', + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +### Keep connections warm + +Creating a new MCP connection on every request adds latency. For production routes with high request rates, create your pool once at module level and pass `connection: 'keep-alive'` so `chat()` never closes it. The pool stays ready for the next request. + +**Server route (`app/api/chat/route.ts`):** + +```ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClients } from '@tanstack/ai-mcp' + +// Created once when the module loads. Shared across all requests. +const sharedPool = await createMCPClients({ + github: { + transport: { + type: 'http', + url: process.env.GITHUB_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, + }, + }, + linear: { + transport: { + type: 'http', + url: process.env.LINEAR_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, + }, + }, +}) + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + // keep-alive: sharedPool is never closed by chat(); stays warm for next call + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [sharedPool], + connection: 'keep-alive', + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +**Client component (`components/Chat.tsx`):** + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +export function Chat() { + const { messages, sendMessage, status } = useChat(chatOptions) + + return ( +
+
    + {messages.map((m) => ( +
  • + {m.role}: {m.content} +
  • + ))} +
+ +
+ ) +} +``` + +### Lazy tool discovery + +When your MCP server exposes dozens of tools, sending every schema to the model inflates prompt size and cost. Set `lazyTools: true` to defer sending tool schemas until the model explicitly requests them. + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + const mcpClient = await createMCPClient({ + transport: { type: 'http', url: process.env.LARGE_MCP_URL! }, + }) + + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [mcpClient], + connection: 'close', + // Tools are registered but schemas are withheld until the model asks + lazyTools: true, + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +`lazyTools: true` is forwarded to each source's `tools({ lazy: true })` call. See [Lazy Tool Discovery](./lazy-tool-discovery) for how the model discovers and loads lazy tools at runtime. + +### Handling discovery failures + +By default, if any source fails during discovery, `chat()` throws immediately (fail-fast). When `connection: 'close'`, any sources that did connect are cleaned up before the error propagates — no leaked connections. + +**Fail-fast (default):** + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + const mcpClient = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + }) + + // If discovery fails, chat() throws before the first model call. + // mcpClient is closed automatically (connection: 'close' default). + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [mcpClient], + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +**Skip a flaky server and proceed:** + +Use `onDiscoveryError` to log the problem and return normally — the failing source is skipped and the run continues with the remaining clients' tools. + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + const primaryClient = await createMCPClient({ + transport: { type: 'http', url: process.env.PRIMARY_MCP_URL! }, + }) + + const optionalClient = await createMCPClient({ + transport: { type: 'http', url: process.env.OPTIONAL_MCP_URL! }, + }) + + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [primaryClient, optionalClient], + connection: 'close', + onDiscoveryError(error, source) { + // Log the failure but let the run proceed without this source's tools. + // Throw here (or re-throw `error`) to fail the whole run instead. + console.warn('MCP discovery failed for a source, skipping.', error) + }, + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +> Sources passed to `onDiscoveryError` may have already connected before discovery failed. When `connection: 'close'`, they are still closed at the end of the run — even if their tools were skipped. + +### Tool-name collisions + +If two sources expose a tool with the same name, `chat()` throws a `DuplicateToolNameError` after merging the discovered tools. Fix it by assigning a `prefix` to one of the clients, or by using `createMCPClients` (which auto-prefixes using the config key). + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + // Both servers expose a tool called "search". Without prefixes this would + // throw DuplicateToolNameError. The prefix option resolves the clash. + const serverA = await createMCPClient({ + transport: { type: 'http', url: process.env.SERVER_A_URL! }, + prefix: 'alpha', // tools become "alpha_search", etc. + }) + + const serverB = await createMCPClient({ + transport: { type: 'http', url: process.env.SERVER_B_URL! }, + prefix: 'beta', // tools become "beta_search", etc. + }) + + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [serverA, serverB], + connection: 'close', + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +See [Tool Name Collisions](#tool-name-collisions) and [Disable or override the prefix](#disable-or-override-the-prefix) for more details. + ## Tool Name Collisions When mixing tools from multiple sources, duplicate names throw `DuplicateToolNameError`: From a0ed9f15bc5a78c9416b24213b287f8bebf5d7de Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 13:14:41 +0200 Subject: [PATCH 44/71] test(e2e): chat({ mcp }) managed-client discovery and execution --- testing/e2e/src/routeTree.gen.ts | 21 ++++ .../e2e/src/routes/api.mcp-managed-test.ts | 96 +++++++++++++++ testing/e2e/tests/mcp-managed.spec.ts | 116 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 testing/e2e/src/routes/api.mcp-managed-test.ts create mode 100644 testing/e2e/tests/mcp-managed.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 9290fdfc0..28f21a789 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as ApiMultimodalToolResultWireRouteImport } from './routes/api.mu import { Route as ApiMiddlewareTestRouteImport } from './routes/api.middleware-test' import { Route as ApiMcpTestRouteImport } from './routes/api.mcp-test' import { Route as ApiMcpServerRouteImport } from './routes/api.mcp-server' +import { Route as ApiMcpManagedTestRouteImport } from './routes/api.mcp-managed-test' import { Route as ApiImageRouteImport } from './routes/api.image' import { Route as ApiChatRouteImport } from './routes/api.chat' import { Route as ApiAudioRouteImport } from './routes/api.audio' @@ -161,6 +162,11 @@ const ApiMcpServerRoute = ApiMcpServerRouteImport.update({ path: '/api/mcp-server', getParentRoute: () => rootRouteImport, } as any) +const ApiMcpManagedTestRoute = ApiMcpManagedTestRouteImport.update({ + id: '/api/mcp-managed-test', + path: '/api/mcp-managed-test', + getParentRoute: () => rootRouteImport, +} as any) const ApiImageRoute = ApiImageRouteImport.update({ id: '/api/image', path: '/api/image', @@ -234,6 +240,7 @@ export interface FileRoutesByFullPath { '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren + '/api/mcp-managed-test': typeof ApiMcpManagedTestRoute '/api/mcp-server': typeof ApiMcpServerRoute '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute @@ -270,6 +277,7 @@ export interface FileRoutesByTo { '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren + '/api/mcp-managed-test': typeof ApiMcpManagedTestRoute '/api/mcp-server': typeof ApiMcpServerRoute '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute @@ -307,6 +315,7 @@ export interface FileRoutesById { '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren + '/api/mcp-managed-test': typeof ApiMcpManagedTestRoute '/api/mcp-server': typeof ApiMcpServerRoute '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute @@ -345,6 +354,7 @@ export interface FileRouteTypes { | '/api/audio' | '/api/chat' | '/api/image' + | '/api/mcp-managed-test' | '/api/mcp-server' | '/api/mcp-test' | '/api/middleware-test' @@ -381,6 +391,7 @@ export interface FileRouteTypes { | '/api/audio' | '/api/chat' | '/api/image' + | '/api/mcp-managed-test' | '/api/mcp-server' | '/api/mcp-test' | '/api/middleware-test' @@ -417,6 +428,7 @@ export interface FileRouteTypes { | '/api/audio' | '/api/chat' | '/api/image' + | '/api/mcp-managed-test' | '/api/mcp-server' | '/api/mcp-test' | '/api/middleware-test' @@ -454,6 +466,7 @@ export interface RootRouteChildren { ApiAudioRoute: typeof ApiAudioRouteWithChildren ApiChatRoute: typeof ApiChatRoute ApiImageRoute: typeof ApiImageRouteWithChildren + ApiMcpManagedTestRoute: typeof ApiMcpManagedTestRoute ApiMcpServerRoute: typeof ApiMcpServerRoute ApiMcpTestRoute: typeof ApiMcpTestRoute ApiMiddlewareTestRoute: typeof ApiMiddlewareTestRoute @@ -632,6 +645,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiMcpServerRouteImport parentRoute: typeof rootRouteImport } + '/api/mcp-managed-test': { + id: '/api/mcp-managed-test' + path: '/api/mcp-managed-test' + fullPath: '/api/mcp-managed-test' + preLoaderRoute: typeof ApiMcpManagedTestRouteImport + parentRoute: typeof rootRouteImport + } '/api/image': { id: '/api/image' path: '/api/image' @@ -787,6 +807,7 @@ const rootRouteChildren: RootRouteChildren = { ApiAudioRoute: ApiAudioRouteWithChildren, ApiChatRoute: ApiChatRoute, ApiImageRoute: ApiImageRouteWithChildren, + ApiMcpManagedTestRoute: ApiMcpManagedTestRoute, ApiMcpServerRoute: ApiMcpServerRoute, ApiMcpTestRoute: ApiMcpTestRoute, ApiMiddlewareTestRoute: ApiMiddlewareTestRoute, diff --git a/testing/e2e/src/routes/api.mcp-managed-test.ts b/testing/e2e/src/routes/api.mcp-managed-test.ts new file mode 100644 index 000000000..459dcc42b --- /dev/null +++ b/testing/e2e/src/routes/api.mcp-managed-test.ts @@ -0,0 +1,96 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { createMCPClient } from '@tanstack/ai-mcp' +import { createTextAdapter } from '@/lib/providers' + +/** + * Drives a real `chat()` agent loop with MCP tool discovery managed entirely + * by `chat({ mcp })` — the caller does NOT manually call `mcp.tools()` or + * `mcp.close()`. This verifies that passing `mcp: { clients, connection }` to + * `chat()` correctly: + * 1. Auto-discovers tools from the MCP client (here: `get_guitar_price`). + * 2. Runs the tool inside the agent loop via the aimock fixture. + * 3. Closes the client after the stream drains (connection: 'close'). + * + * Contrast with `api.mcp-test.ts` which manually calls `mcp.tools()`, wraps + * the stream in a `closeMcpOnDrain` generator, and passes `tools` directly. + */ +export const Route = createFileRoute('/api/mcp-managed-test')({ + server: { + handlers: { + POST: async ({ request }) => { + if (request.signal.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 }, + ) + } + + const fp = params.forwardedProps + const testId = typeof fp.testId === 'string' ? fp.testId : undefined + const aimockPort = + fp.aimockPort != null ? Number(fp.aimockPort) : undefined + + // The mock MCP server lives at this same dev server's origin. + const origin = new URL(request.url).origin + const mcpServerUrl = `${origin}/api/mcp-server` + + try { + // Create the MCP client — but do NOT call client.tools() or + // client.close() here. Pass it to chat({ mcp }) and let chat manage + // the full lifecycle: discovery, execution, and teardown. + const client = await createMCPClient({ + transport: { type: 'http', url: mcpServerUrl }, + }) + + const adapterOptions = createTextAdapter( + 'openai', + undefined, + aimockPort, + testId, + ) + + const stream = chat({ + ...adapterOptions, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + mcp: { clients: [client], connection: 'close' }, + agentLoopStrategy: maxIterations(5), + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error) { + console.error('[api.mcp-managed-test] Error:', error) + if ( + (error instanceof Error && error.name === 'AbortError') || + abortController.signal.aborted + ) { + return new Response(null, { status: 499 }) + } + const message = + error instanceof Error ? error.message : 'An error occurred' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + }, + }, +}) diff --git a/testing/e2e/tests/mcp-managed.spec.ts b/testing/e2e/tests/mcp-managed.spec.ts new file mode 100644 index 000000000..dd688041d --- /dev/null +++ b/testing/e2e/tests/mcp-managed.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from './fixtures' + +/** + * MCP tool discovery + execution managed by `chat({ mcp })`. + * + * Proves that passing `mcp: { clients, connection: 'close' }` to `chat()` is + * sufficient to: + * 1. Auto-discover tools from the MCP client (no manual `mcp.tools()` call). + * 2. Execute the discovered tool (`get_guitar_price`) inside the agent loop. + * 3. Close the MCP client after the stream drains (no manual `mcp.close()`). + * + * The route under test (`api.mcp-managed-test`) does NOT call `mcp.tools()` or + * `mcp.close()` — it delegates both to `chat({ mcp })`. Compare with + * `api.mcp-test`, which manually calls `mcp.tools()` and wraps the stream in a + * `closeMcpOnDrain` generator. + * + * Same fixture as the basic MCP test (`fixtures/mcp/basic.json`). Each test + * gets a unique `testId` so `sequenceIndex` is isolated between the two specs. + */ + +type StreamEvent = { + type: string + toolName?: string + toolCallName?: string + toolCallId?: string + result?: unknown + content?: unknown + delta?: string +} + +function parseSse(body: string): Array { + const events: Array = [] + for (const line of body.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) continue + const json = trimmed.slice('data:'.length).trim() + if (!json) continue + try { + events.push(JSON.parse(json) as StreamEvent) + } catch { + // Ignore non-JSON keepalive lines. + } + } + return events +} + +test.describe('mcp-managed — chat({ mcp }) discovery + lifecycle', () => { + test('chat({ mcp }) discovers get_guitar_price and the result reaches the transcript', async ({ + request, + testId, + aimockPort, + }) => { + // Minimal valid AG-UI RunAgentInput body — identical message to the basic + // MCP spec so the same aimock fixture (`fixtures/mcp/basic.json`) is used. + // Unique testId ensures sequenceIndex isolation from the other spec. + const res = await request.post('/api/mcp-managed-test', { + headers: { 'Content-Type': 'application/json' }, + data: { + threadId: `mcp-managed-thread-${testId}`, + runId: `mcp-managed-run-${testId}`, + state: {}, + messages: [ + { + id: 'mcp-managed-msg-1', + role: 'user', + content: '[mcp] how much is the strat guitar', + }, + ], + tools: [], + context: [], + forwardedProps: { testId, aimockPort }, + }, + }) + + const body = await res.text() + expect( + res.ok(), + `mcp-managed-test route failed (${res.status()}): ${body}`, + ).toBe(true) + + const events = parseSse(body) + + // The agentic loop must have invoked the MCP tool via chat({ mcp }) discovery. + const toolStart = events.find( + (e) => + e.type === 'TOOL_CALL_START' && + (e.toolName === 'get_guitar_price' || + e.toolCallName === 'get_guitar_price'), + ) + expect( + toolStart, + 'expected a TOOL_CALL_START for get_guitar_price', + ).toBeTruthy() + + // The MCP tool result is emitted as the AG-UI TOOL_CALL_RESULT event. The + // price 1999 originates ONLY from the real MCP server (the fixture's + // tool-call args don't contain it), so finding it here proves the MCP tool + // actually executed against the in-process server via chat({ mcp }). + const toolResult = events.find((e) => e.type === 'TOOL_CALL_RESULT') + expect(toolResult, 'expected a TOOL_CALL_RESULT event').toBeTruthy() + const resultStr = JSON.stringify(toolResult?.content ?? '') + expect(resultStr).toContain('1999') + expect(resultStr).toContain('strat') + + // The final assistant text (post tool execution) must contain the price too. + const finalText = events + .filter((e) => e.type === 'TEXT_MESSAGE_CONTENT' && e.delta) + .map((e) => e.delta) + .join('') + expect(finalText).toContain('1999') + + // The run completed cleanly (no RUN_ERROR). + expect(events.some((e) => e.type === 'RUN_ERROR')).toBe(false) + expect(events.some((e) => e.type === 'RUN_FINISHED')).toBe(true) + }) +}) From 0a8888d3e7e4cf02804bdd6cf6315fb0f347c7c3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 13:22:00 +0200 Subject: [PATCH 45/71] docs(skills): fix inverted connection keep-alive semantics in examples --- packages/ai-mcp/skills/ai-mcp/SKILL.md | 2 +- packages/ai/skills/ai-core/chat-experience/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index b12cbcc18..7eb95629a 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -261,7 +261,7 @@ export async function POST(request: Request) { }) return toServerSentEventsResponse(stream) - // mcpClient is closed by chat() when the run finishes (connection: 'keep-alive') + // connection: 'keep-alive' — chat() never closes mcpClient; it stays warm for the next request. } ``` diff --git a/packages/ai/skills/ai-core/chat-experience/SKILL.md b/packages/ai/skills/ai-core/chat-experience/SKILL.md index 89f01370c..8aecba71e 100644 --- a/packages/ai/skills/ai-core/chat-experience/SKILL.md +++ b/packages/ai/skills/ai-core/chat-experience/SKILL.md @@ -367,7 +367,7 @@ export async function POST(request: Request) { }) return toServerSentEventsResponse(stream) - // chat() closes mcpClient when the run ends (connection: 'keep-alive' closes on finish) + // connection: 'keep-alive' — chat() never closes mcpClient; it stays open for reuse across runs. } ``` From 1bcbeb3a34e28ebc7693c738551f3b1988153b04 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 13:36:30 +0200 Subject: [PATCH 46/71] fix(ai): export MCPDuplicateToolNameError and correct cross-client collision docs --- .changeset/chat-mcp-option.md | 2 +- docs/tools/mcp.md | 33 ++++++++++++++++---------- packages/ai-mcp/skills/ai-mcp/SKILL.md | 24 +++++++++++++++---- packages/ai/src/index.ts | 3 +++ 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/.changeset/chat-mcp-option.md b/.changeset/chat-mcp-option.md index 64dad7afd..be74b8005 100644 --- a/.changeset/chat-mcp-option.md +++ b/.changeset/chat-mcp-option.md @@ -2,4 +2,4 @@ '@tanstack/ai': minor --- -Add an `mcp` option to `chat()` for managing MCP clients directly: `chat({ mcp: { clients, connection, lazyTools, onDiscoveryError } })` discovers the given MCP clients'/pools' tools at run start, merges them into the run, and (by default, `connection: 'close'`) closes them when the run ends — or keeps them warm with `connection: 'keep-alive'`. Also exports `MCPToolSource`, `ChatMCPOptions`, and `MCPConnectionPolicy`. +Add an `mcp` option to `chat()` for managing MCP clients directly: `chat({ mcp: { clients, connection, lazyTools, onDiscoveryError } })` discovers the given MCP clients'/pools' tools at run start, merges them into the run, and (by default, `connection: 'close'`) closes them when the run ends — or keeps them warm with `connection: 'keep-alive'`. Also exports `MCPToolSource`, `ChatMCPOptions`, `MCPConnectionPolicy`, and `MCPDuplicateToolNameError` (the error thrown when tools from separate `mcp.clients` entries collide after merging; catchable with `instanceof`). diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index 319c4f002..e04004bf1 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -613,11 +613,11 @@ export async function POST(request: Request) { ### Tool-name collisions -If two sources expose a tool with the same name, `chat()` throws a `DuplicateToolNameError` after merging the discovered tools. Fix it by assigning a `prefix` to one of the clients, or by using `createMCPClients` (which auto-prefixes using the config key). +If two sources in `mcp.clients` expose a tool with the same name, `chat()` throws an `MCPDuplicateToolNameError` (exported from `@tanstack/ai`) after merging the discovered tools. Fix it by assigning a `prefix` to one of the clients, or by using `createMCPClients` (which auto-prefixes using the config key). ```ts // app/api/chat/route.ts -import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { chat, toServerSentEventsResponse, MCPDuplicateToolNameError } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai/adapters' import { createMCPClient } from '@tanstack/ai-mcp' @@ -633,7 +633,7 @@ export async function POST(request: Request) { } // Both servers expose a tool called "search". Without prefixes this would - // throw DuplicateToolNameError. The prefix option resolves the clash. + // throw MCPDuplicateToolNameError. The prefix option resolves the clash. const serverA = await createMCPClient({ transport: { type: 'http', url: process.env.SERVER_A_URL! }, prefix: 'alpha', // tools become "alpha_search", etc. @@ -644,17 +644,24 @@ export async function POST(request: Request) { prefix: 'beta', // tools become "beta_search", etc. }) - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: body.messages, - mcp: { - clients: [serverA, serverB], - connection: 'close', - }, - }) + try { + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [serverA, serverB], + connection: 'close', + }, + }) - return toServerSentEventsResponse(stream) + return toServerSentEventsResponse(stream) + } catch (err) { + if (err instanceof MCPDuplicateToolNameError) { + return new Response(`Tool name conflict: ${err.toolName}`, { status: 409 }) + } + throw err + } } ``` diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index 7eb95629a..0664a63af 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -456,8 +456,12 @@ 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. -- `DuplicateToolNameError` — thrown when two tools end up with the same name - (same server or across pool clients with no prefix). +- `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`. +- `MCPDuplicateToolNameError` — thrown by `chat()` when tools from separate + `mcp.clients` entries collide after merging. Exported from `@tanstack/ai` + (not `@tanstack/ai-mcp`), so users can `instanceof` it at the `chat()` call site. ```typescript import { @@ -465,6 +469,8 @@ import { MCPToolNotFoundError, DuplicateToolNameError, } from '@tanstack/ai-mcp' + +import { MCPDuplicateToolNameError } from '@tanstack/ai' ``` ## Complete server-route example @@ -571,9 +577,17 @@ type-check time (unless generated types are in use). ### d. MEDIUM: not setting a prefix when multiple servers share tool names -If two servers both expose a tool named `search`, the merged pool will throw -`DuplicateToolNameError`. Use `createMCPClients` (which auto-prefixes by config -key) or set an explicit `prefix` on each `createMCPClient` call. +Two different errors can arise depending on where the collision is detected: + +- **Within a single `createMCPClients` pool** — calling `pool.tools()` throws + `DuplicateToolNameError` (from `@tanstack/ai-mcp`) when two servers in that + pool expose the same name with no prefix to separate them. +- **Across separate `mcp.clients` entries in `chat()`** — `chat()` throws + `MCPDuplicateToolNameError` (from `@tanstack/ai`) after merging discovered + tools from all `mcp.clients` entries. + +In both cases, the fix is the same: use `createMCPClients` (which auto-prefixes +by config key) or set an explicit `prefix` on each `createMCPClient` call. ## Cross-References diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 1c97bef80..dbb38722b 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -59,6 +59,9 @@ export type { MCPConnectionPolicy, } from './activities/chat/mcp/types' +// MCP error classes (value exports — usable with instanceof) +export { MCPDuplicateToolNameError } from './activities/chat/mcp/manager' + // Schema conversion (Standard JSON Schema compliant) export { convertSchemaToJsonSchema, From 813b69132c5dfd04b6b751ef389bb30b0dc037fc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 13:41:00 +0200 Subject: [PATCH 47/71] chore: ignore .test-d.ts type-test files in knip --- knip.json | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.json b/knip.json index 67ae81303..a5e8a03e1 100644 --- a/knip.json +++ b/knip.json @@ -8,6 +8,7 @@ ], "ignore": [ "scripts/**", + "**/*.test-d.ts", "packages/ai-code-mode-skills/test-cli/**", ".claude/worktrees/**", "packages/ai-openai/live-tests/**", From 0b9ea1f3d30554984def7ee3aaf479b95037b42c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 14:05:37 +0200 Subject: [PATCH 48/71] feat(ai-mcp): re-export Transport type and InMemoryTransport for the custom-transport escape hatch --- docs/tools/mcp.md | 11 ++++++++++- packages/ai-mcp/src/index.ts | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index e04004bf1..faaf43896 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -135,7 +135,16 @@ const mcp = await createMCPClient({ ### Custom transport (escape hatch) -Pass any `Transport` instance from `@modelcontextprotocol/sdk` directly: +Pass any `Transport` instance directly as the `transport` option. For in-process testing, `InMemoryTransport` is re-exported from `@tanstack/ai-mcp`: + +```ts +import { createMCPClient, InMemoryTransport } from '@tanstack/ai-mcp' + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() +const mcp = await createMCPClient({ transport: clientTransport }) +``` + +For a custom network transport, pass any SDK `Transport`-compatible instance: ```ts import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 80d8a83e5..1b11e55d7 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -14,6 +14,8 @@ export type { SseTransportConfig, StdioTransportConfig, } from './transport' +export type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +export { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' export { MCPConnectionError, DuplicateToolNameError, From adcc4bb396c997ad7d2cfe1dc3e06d7d6eaa1925 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 14:17:14 +0200 Subject: [PATCH 49/71] docs: split MCP docs into focused pages (core client, codegen, chat mcp, manual chat integration) --- docs/config.json | 12 + docs/tools/mcp-chat.md | 412 +++++++++++++++++++++ docs/tools/mcp-codegen.md | 122 ++++++ docs/tools/mcp-with-chat.md | 257 +++++++++++++ docs/tools/mcp.md | 714 +----------------------------------- 5 files changed, 812 insertions(+), 705 deletions(-) create mode 100644 docs/tools/mcp-chat.md create mode 100644 docs/tools/mcp-codegen.md create mode 100644 docs/tools/mcp-with-chat.md diff --git a/docs/config.json b/docs/config.json index 59cc4c84d..3cd740735 100644 --- a/docs/config.json +++ b/docs/config.json @@ -86,6 +86,18 @@ { "label": "MCP Server Tools", "to": "tools/mcp" + }, + { + "label": "MCP Type Generation", + "to": "tools/mcp-codegen" + }, + { + "label": "Managing MCP Clients with chat()", + "to": "tools/mcp-chat" + }, + { + "label": "MCP Resources, Prompts & Manual Tools", + "to": "tools/mcp-with-chat" } ] }, diff --git a/docs/tools/mcp-chat.md b/docs/tools/mcp-chat.md new file mode 100644 index 000000000..60f6191f5 --- /dev/null +++ b/docs/tools/mcp-chat.md @@ -0,0 +1,412 @@ +--- +title: Managing MCP clients with chat() +id: mcp-chat +order: 10 +description: "Hand live MCP clients and pools to chat() via the mcp option and let it own tool discovery and connection lifecycle for you." +keywords: + - tanstack ai + - mcp + - model context protocol + - chat mcp + - mcp clients + - keep-alive + - lazyTools + - onDiscoveryError +--- + +You have one or more live [MCP clients](./mcp) (or pools) and you want the model to use their tools — without writing boilerplate `await client.tools()` calls and `try/finally close()` blocks for every route. By the end of this guide you'll hand those clients to `chat()` via the `mcp` option and let it handle both discovery and lifecycle for you. + +> **Managed (`mcp` prop) vs manual (`tools` spread)** +> +> - Use `mcp: { clients: [...] }` when you want **discovery + lifecycle** managed for you and you are happy with runtime-typed (`unknown`-argument) tools. +> - Use `tools: [...await client.tools([toolDefinition(...)])]` when you need **fully-typed MCP tools** — the defs overload gives you Zod-validated, TypeScript-typed arguments. See [Resources, prompts & manual tools with `chat()`](./mcp-with-chat) and [Three Modes of Type Safety](./mcp#three-modes-of-type-safety). +> +> Both coexist in the same `chat()` call. Tools from `mcp.clients` are merged with any tools you pass explicitly via `tools`. + +## Hand a client to `chat()` + +The simplest path: create a client, hand it to `chat()`, and let the run clean it up. `connection` defaults to `'close'`, so the client is closed automatically once the run ends — on success, error, or abort. + +```ts +// app/api/chat/route.ts (Next.js App Router) +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + const mcpClient = await createMCPClient({ + transport: { + type: 'http', + url: process.env.MCP_URL!, + headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, + }, + }) + + // chat() discovers mcpClient's tools and closes the connection when done. + // No try/finally needed. + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [mcpClient], + // connection: 'close' is the default — shown here for clarity + connection: 'close', + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +## Multiple servers and pools + +Pass any mix of `MCPClient` instances and `MCPClients` pools. Their tools are discovered in parallel and merged into one flat tool set. Pools auto-prefix each server's tools with the config key to prevent name collisions. + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient, createMCPClients } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + // A pool of two servers — their tools are prefixed "github_" and "linear_" + const githubLinearPool = await createMCPClients({ + github: { + transport: { + type: 'http', + url: process.env.GITHUB_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, + }, + }, + linear: { + transport: { + type: 'http', + url: process.env.LINEAR_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, + }, + }, + }) + + // A standalone client for an internal server + const internalClient = await createMCPClient({ + transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! }, + }) + + // All three servers' tools are merged: github_*, linear_*, plus internal tools + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [githubLinearPool, internalClient], + connection: 'close', + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +## Keep connections warm + +Creating a new MCP connection on every request adds latency. For production routes with high request rates, create your pool once at module level and pass `connection: 'keep-alive'` so `chat()` never closes it. The pool stays ready for the next request. + +**Server route (`app/api/chat/route.ts`):** + +```ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClients } from '@tanstack/ai-mcp' + +// Created once when the module loads. Shared across all requests. +const sharedPool = await createMCPClients({ + github: { + transport: { + type: 'http', + url: process.env.GITHUB_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, + }, + }, + linear: { + transport: { + type: 'http', + url: process.env.LINEAR_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, + }, + }, +}) + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + // keep-alive: sharedPool is never closed by chat(); stays warm for next call + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [sharedPool], + connection: 'keep-alive', + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +**Client component (`components/Chat.tsx`):** + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +export function Chat() { + const { messages, sendMessage, status } = useChat(chatOptions) + + return ( +
+
    + {messages.map((m) => ( +
  • + {m.role}: {m.content} +
  • + ))} +
+ +
+ ) +} +``` + +## Lazy tool discovery + +When your MCP server exposes dozens of tools, sending every schema to the model inflates prompt size and cost. Set `lazyTools: true` to defer sending tool schemas until the model explicitly requests them. + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + const mcpClient = await createMCPClient({ + transport: { type: 'http', url: process.env.LARGE_MCP_URL! }, + }) + + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [mcpClient], + connection: 'close', + // Tools are registered but schemas are withheld until the model asks + lazyTools: true, + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +`lazyTools: true` is forwarded to each source's `tools({ lazy: true })` call. See [Lazy Tool Discovery](./lazy-tool-discovery) for how the model discovers and loads lazy tools at runtime, and [the standalone lazy discovery section](./mcp#lazy-tool-discovery) for using `{ lazy: true }` directly with `client.tools()`. + +## Handling discovery failures + +By default, if any source fails during discovery, `chat()` throws immediately (fail-fast). When `connection: 'close'`, any sources that did connect are cleaned up before the error propagates — no leaked connections. + +**Fail-fast (default):** + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + const mcpClient = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + }) + + // If discovery fails, chat() throws before the first model call. + // mcpClient is closed automatically (connection: 'close' default). + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [mcpClient], + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +**Skip a flaky server and proceed:** + +Use `onDiscoveryError` to log the problem and return normally — the failing source is skipped and the run continues with the remaining clients' tools. + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + const primaryClient = await createMCPClient({ + transport: { type: 'http', url: process.env.PRIMARY_MCP_URL! }, + }) + + const optionalClient = await createMCPClient({ + transport: { type: 'http', url: process.env.OPTIONAL_MCP_URL! }, + }) + + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [primaryClient, optionalClient], + connection: 'close', + onDiscoveryError(error, source) { + // Log the failure but let the run proceed without this source's tools. + // Throw here (or re-throw `error`) to fail the whole run instead. + console.warn('MCP discovery failed for a source, skipping.', error) + }, + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +> Sources passed to `onDiscoveryError` may have already connected before discovery failed. When `connection: 'close'`, they are still closed at the end of the run — even if their tools were skipped. + +## Tool name collisions + +If two sources in `mcp.clients` expose a tool with the same name, `chat()` throws an `MCPDuplicateToolNameError` (exported from `@tanstack/ai`) after merging the discovered tools. Fix it by assigning a `prefix` to one of the clients, or by using `createMCPClients` (which auto-prefixes using the config key). + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse, MCPDuplicateToolNameError } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if ( + typeof body !== 'object' || + body === null || + !Array.isArray(body.messages) + ) { + return new Response('Bad request', { status: 400 }) + } + + // Both servers expose a tool called "search". Without prefixes this would + // throw MCPDuplicateToolNameError. The prefix option resolves the clash. + const serverA = await createMCPClient({ + transport: { type: 'http', url: process.env.SERVER_A_URL! }, + prefix: 'alpha', // tools become "alpha_search", etc. + }) + + const serverB = await createMCPClient({ + transport: { type: 'http', url: process.env.SERVER_B_URL! }, + prefix: 'beta', // tools become "beta_search", etc. + }) + + try { + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + mcp: { + clients: [serverA, serverB], + connection: 'close', + }, + }) + + return toServerSentEventsResponse(stream) + } catch (err) { + if (err instanceof MCPDuplicateToolNameError) { + return new Response(`Tool name conflict: ${err.toolName}`, { status: 409 }) + } + throw err + } +} +``` + +For the standalone `pool.tools()` collision behavior and the general `prefix` strategy, see [Tool Name Collisions](./mcp#tool-name-collisions) and [Disable or override the prefix](./mcp#disable-or-override-the-prefix). + +## Going further + +> **Need fully-typed tools, resources, or prompts in the run?** The `mcp` prop gives you runtime-typed tools and discovery. To spread `toolDefinition`-typed MCP tools, inject MCP resources and prompts, or cancel in-flight MCP calls, see [Resources, prompts & manual tools with `chat()`](./mcp-with-chat). diff --git a/docs/tools/mcp-codegen.md b/docs/tools/mcp-codegen.md new file mode 100644 index 000000000..9dffda4aa --- /dev/null +++ b/docs/tools/mcp-codegen.md @@ -0,0 +1,122 @@ +--- +title: MCP Type Generation +id: mcp-codegen +order: 9 +description: "Generate per-server TypeScript interface types from a live MCP server and wire them into createMCPClient for end-to-end type safety." +keywords: + - tanstack ai + - mcp + - model context protocol + - codegen + - type safety + - mcp.config.ts + - defineConfig + - createMCPClient + - generated types +--- + +You have a running MCP server and you call its tools through [`createMCPClient`](./mcp), but tool arguments are typed `unknown` at compile time. By the end of this guide you'll have generated per-server `interface` types from the live server and wired them into `createMCPClient` for end-to-end type safety with zero runtime overhead — this is [Mode 3](./mcp#mode-3--generated-types-createmcpclientgeneratedserver) of MCP type safety. + +The `generate` CLI introspects a live MCP server and emits TypeScript interface types that you pass as a generic to `createMCPClient` / `createMCPClients`. + +## 1. Create `mcp.config.ts` + +Declare each server you want to generate types for. The `defineConfig` helper gives the config file full type checking and autocomplete. + +```ts +// mcp.config.ts +import { defineConfig } from '@tanstack/ai-mcp' + +export default defineConfig({ + servers: { + github: { + transport: { type: 'http', url: 'https://github-mcp.example.com/mcp' }, + }, + linear: { + transport: { type: 'http', url: 'https://linear-mcp.example.com/mcp' }, + prefix: 'linear', // must match runtime createMCPClient({ prefix }) + }, + }, + outFile: './mcp-types.generated.ts', +}) +``` + +## 2. Run the generator + +```bash +npx @tanstack/ai-mcp generate +``` + +The CLI connects to each declared server, introspects its tools, resources, and prompts, and writes the result to `outFile`. + +## 3. Inspect the output + +The generator emits one interface per server plus a combined pool map: + +```ts +// mcp-types.generated.ts — AUTO-GENERATED, do not edit + +import type { ServerDescriptor } from '@tanstack/ai-mcp' + +export interface GithubServer extends ServerDescriptor { + tools: { + 'search_repositories': { input: { query: string; limit?: number }; output: unknown } + 'create_issue': { input: { repo: string; title: string; body?: string }; output: unknown } + } + resources: {} + prompts: {} + capabilities: { tools: {} } & Record +} + +export interface LinearServer extends ServerDescriptor { + tools: { + 'linear_create_issue': { input: { title: string; teamId: string }; output: unknown } + } + resources: {} + prompts: {} + capabilities: { tools: {} } & Record +} + +export interface MCPServers extends Record { + 'github': GithubServer + 'linear': LinearServer +} +``` + +## 4. Use generated types at runtime + +Pass the generated type as a generic to [`createMCPClient`](./mcp) (single server) or `createMCPClients` (pool). Tool names are narrowed to the literal types declared by the server, so a typo is a compile error. + +**Single server:** + +```ts +import type { GithubServer } from './mcp-types.generated' +import { createMCPClient } from '@tanstack/ai-mcp' + +const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.GITHUB_MCP_URL! }, +}) + +const tools = await mcp.tools() +// Each tool name is narrowed from GithubServer['tools'] +``` + +**Multi-server pool:** + +```ts +import type { MCPServers } from './mcp-types.generated' +import { createMCPClients } from '@tanstack/ai-mcp' + +const pool = await createMCPClients({ + github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } }, + linear: { + transport: { type: 'http', url: process.env.LINEAR_MCP_URL! }, + prefix: 'linear', + }, +}) + +// Config keys are constrained to the declared servers — a typo is a compile error +const tools = await pool.tools() +``` + +Now that your tools are typed, hand the generated client to `chat()`. See [Managing MCP clients with `chat()`](./mcp-chat) to let `chat()` own discovery and lifecycle. diff --git a/docs/tools/mcp-with-chat.md b/docs/tools/mcp-with-chat.md new file mode 100644 index 000000000..8d37f6ccd --- /dev/null +++ b/docs/tools/mcp-with-chat.md @@ -0,0 +1,257 @@ +--- +title: Resources, prompts & manual tools with chat() +id: mcp-with-chat +order: 11 +description: "Spread fully-typed MCP tools into chat(), inject MCP resources and prompts as content and messages, and cancel in-flight MCP tool calls." +keywords: + - tanstack ai + - mcp + - model context protocol + - mcp resources + - mcp prompts + - mcpResourceToContentPart + - mcpPromptToMessages + - cancellation + - abortController +--- + +You have a live [MCP client](./mcp) and want to do more than auto-discover tools: spread fully-typed tools into a `chat()` run, inject the server's resources and prompts into the conversation, and cancel in-flight MCP calls when the run aborts. By the end of this guide you'll have wired all of these into a single `chat()` call. + +> **Manual (`tools` spread) vs managed (`mcp` prop)** +> +> This page covers the **manual** path — you call `client.tools()` / `client.resources()` / `client.getPrompt()` yourself and own the `try/finally close()`. If you only need runtime-typed tools with discovery and lifecycle handled for you, use the `mcp` prop instead — see [Managing MCP clients with `chat()`](./mcp-chat). Both paths build on the [`createMCPClient` basics](./mcp). + +## Fully-typed tools via the `tools` spread + +Pass `toolDefinition()` instances to `client.tools([...])` to get Zod-validated, TypeScript-typed arguments ([Mode 2](./mcp#mode-2--explicit-definitions-clienttoolsdefs)), then spread the result into `chat()`'s `tools` option. You own the client, so close it in a `finally` block. + +```ts +// app/api/chat/route.ts +import { chat, toServerSentEventsResponse, toolDefinition } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient } from '@tanstack/ai-mcp' +import { z } from 'zod' + +const searchDef = toolDefinition({ + name: 'search', + description: 'Search for items', + inputSchema: z.object({ query: z.string() }), + outputSchema: z.array(z.object({ id: z.string(), title: z.string() })), +}) + +export async function POST(request: Request) { + const { messages } = await request.json() + + const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + }) + + try { + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages, + // Fully-typed MCP tools, merged with any other tools you pass + tools: [...(await mcp.tools([searchDef]))], + }) + + return toServerSentEventsResponse(stream) + } finally { + await mcp.close() + } +} +``` + +## Resources + +MCP resources are context documents (files, database records, web pages) the server exposes. Fetch them and inject them into `chat()` as content parts. + +```ts +import { mcpResourceToContentPart } from '@tanstack/ai-mcp' + +const resources = await mcp.resources() +// resources: Array<{ uri: string; name: string; ... }> + +const readResult = await mcp.readResource(resources[0].uri) +const parts = readResult.contents.map(mcpResourceToContentPart) + +// Inject as part of a user message +const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: [ + { + role: 'user', + content: [ + ...parts, + { type: 'text', content: 'Summarize the above document.' }, + ], + }, + ], +}) +``` + +`mcpResourceToContentPart` maps each MCP content block to a `ContentPart`: +- `text` field present → `{ type: 'text', content: text }` +- `blob` field present → `{ type: 'text', content: '[binary resource ]' }` +- otherwise → `{ type: 'text', content: JSON.stringify(content) }` + +### Resource templates + +```ts +const templates = await mcp.resourceTemplates() +// templates: Array +``` + +## Prompts + +MCP prompts are reusable message templates the server exposes. Fetch a prompt, convert it to `ModelMessage[]` with `mcpPromptToMessages`, and spread it into `chat()` to seed the conversation with server-defined context or instructions. + +```ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClient, mcpPromptToMessages } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + }) + + try { + // List all available prompts on the server + const available = await mcp.prompts() + // available: Array<{ name: string; description?: string; arguments?: ... }> + + // Fetch a specific prompt, optionally passing template arguments + const prompt = await mcp.getPrompt('summarize', { language: 'english' }) + + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: [ + // Seed the conversation with the server-defined prompt messages + ...mcpPromptToMessages(prompt), + // Then append the user's own messages + ...messages, + ], + }) + + return toServerSentEventsResponse(stream) + } finally { + await mcp.close() + } +} +``` + +`mcpPromptToMessages` maps each MCP prompt message to a `ModelMessage`: +- `role === 'assistant'` → `{ role: 'assistant', content: text }` +- any other role → `{ role: 'user', content: text }` +- non-text content → `content` is `JSON.stringify`'d + +`getPrompt(name, args?)` accepts an optional `args` parameter typed as `Record` for filling in template variables declared by the prompt. + +## 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. + +```ts +const controller = new AbortController() + +const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages, + tools: await mcp.tools(), + abortController: controller, +}) + +// Cancel the run and all in-flight MCP tool calls: +controller.abort() +``` + +## Full Server + Client Example + +Here is a complete Next.js App Router route that connects to two MCP servers and streams the response to the browser. + +**Server route (`app/api/chat/route.ts`):** + +```ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { createMCPClients } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const body = await request.json() + + if (typeof body !== 'object' || body === null || !Array.isArray(body.messages)) { + return new Response('Bad request', { status: 400 }) + } + + const pool = await createMCPClients({ + github: { + transport: { + type: 'http', + url: process.env.GITHUB_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, + }, + }, + linear: { + transport: { + type: 'http', + url: process.env.LINEAR_MCP_URL!, + headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, + }, + }, + }) + + try { + const stream = chat({ + adapter: openaiText(), + model: 'gpt-4o', + messages: body.messages, + tools: await pool.tools(), + }) + + return toServerSentEventsResponse(stream) + } finally { + await pool.close() + } +} +``` + +**Client component (`components/Chat.tsx`):** + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +export function Chat() { + const { messages, sendMessage, status } = useChat(chatOptions) + + return ( +
+
    + {messages.map((m) => ( +
  • + {m.role}: {m.content} +
  • + ))} +
+ +
+ ) +} +``` + +> **Want `chat()` to discover tools and close clients for you?** If you don't need the manual `tools` spread, resources, or prompts, the `mcp` prop removes the `try/finally` boilerplate entirely. See [Managing MCP clients with `chat()`](./mcp-chat). diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index faaf43896..28184a923 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -2,7 +2,7 @@ title: MCP Server Tools id: mcp order: 8 -description: "Connect TanStack AI to any Model Context Protocol server to discover and execute its tools, resources, and prompts inside chat()." +description: "Connect TanStack AI to any Model Context Protocol server with createMCPClient to discover and execute its tools." keywords: - tanstack ai - mcp @@ -12,7 +12,6 @@ keywords: - server tools - createMCPClient - createMCPClients - - codegen - type safety --- @@ -185,22 +184,9 @@ const tools = await mcp.tools([searchDef]) ### Mode 3 — Generated types (`createMCPClient`) -Run the CLI against a live server to generate per-server `interface` types. Pass the generated type as a generic to get end-to-end type safety with zero runtime overhead. +Run the CLI against a live server to generate per-server `interface` types, then pass the generated type as a generic for end-to-end type safety with zero runtime overhead. -```ts -// mcp-types.generated.ts — produced by `npx @tanstack/ai-mcp generate` -import type { GithubServer } from './mcp-types.generated' -import { createMCPClient } from '@tanstack/ai-mcp' - -const mcp = await createMCPClient({ - transport: { type: 'http', url: process.env.GITHUB_MCP_URL! }, -}) - -const tools = await mcp.tools() -// Each tool's name is now a literal type from GithubServer['tools'] -``` - -See [Code Generation](#code-generation) below for CLI setup. +> See [MCP Type Generation](./mcp-codegen) for the full `mcp.config.ts` setup, the `generate` CLI, and how to wire the generated types into `createMCPClient` and `createMCPClients`. ## Multi-Server Pool @@ -256,7 +242,7 @@ If any server fails to connect, already-connected clients are closed before the The MCP client is **caller-owned**. `chat()` never closes it. -> **Prefer to let `chat()` manage lifecycle?** If you'd rather skip the `try/finally` and have `chat()` discover tools and close clients automatically, see [Managing MCP clients with `chat()`](#managing-mcp-clients-with-chat). +> **Prefer to let `chat()` manage lifecycle?** If you'd rather skip the `try/finally` and have `chat()` discover tools and close clients automatically, see [Managing MCP clients with `chat()`](./mcp-chat). ### Manual close @@ -281,401 +267,6 @@ const stream = chat({ ..., tools: await mcp.tools() }) return toServerSentEventsResponse(stream) ``` -## Managing MCP clients with `chat()` - -You have one or more live MCP clients (or pools) and you want the model to use their tools — without writing boilerplate `await client.tools()` calls and `try/finally close()` blocks for every route. Pass them to `chat()` via the `mcp` option and it handles both discovery and lifecycle for you. - -> **When to use `mcp` vs the `tools` spread** -> -> - Use `mcp: { clients: [...] }` when you want **discovery + lifecycle** managed for you and you are happy with runtime-typed (`unknown`-argument) tools. -> - Use `tools: [...await client.tools([toolDefinition(...)])]` when you need **fully-typed MCP tools** — the defs overload gives you Zod-validated, TypeScript-typed arguments. See [Three Modes of Type Safety](#three-modes-of-type-safety). -> -> Both coexist in the same `chat()` call. Tools from `mcp.clients` are merged with any tools you pass explicitly via `tools`. - -### Hand a client to `chat()` - -The simplest path: create a client, hand it to `chat()`, and let the run clean it up. `connection` defaults to `'close'`, so the client is closed automatically once the run ends — on success, error, or abort. - -```ts -// app/api/chat/route.ts (Next.js App Router) -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' -import { createMCPClient } from '@tanstack/ai-mcp' - -export async function POST(request: Request) { - const body = await request.json() - - if ( - typeof body !== 'object' || - body === null || - !Array.isArray(body.messages) - ) { - return new Response('Bad request', { status: 400 }) - } - - const mcpClient = await createMCPClient({ - transport: { - type: 'http', - url: process.env.MCP_URL!, - headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, - }, - }) - - // chat() discovers mcpClient's tools and closes the connection when done. - // No try/finally needed. - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: body.messages, - mcp: { - clients: [mcpClient], - // connection: 'close' is the default — shown here for clarity - connection: 'close', - }, - }) - - return toServerSentEventsResponse(stream) -} -``` - -### Multiple servers and pools - -Pass any mix of `MCPClient` instances and `MCPClients` pools. Their tools are discovered in parallel and merged into one flat tool set. Pools auto-prefix each server's tools with the config key to prevent name collisions. - -```ts -// app/api/chat/route.ts -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' -import { createMCPClient, createMCPClients } from '@tanstack/ai-mcp' - -export async function POST(request: Request) { - const body = await request.json() - - if ( - typeof body !== 'object' || - body === null || - !Array.isArray(body.messages) - ) { - return new Response('Bad request', { status: 400 }) - } - - // A pool of two servers — their tools are prefixed "github_" and "linear_" - const githubLinearPool = await createMCPClients({ - github: { - transport: { - type: 'http', - url: process.env.GITHUB_MCP_URL!, - headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, - }, - }, - linear: { - transport: { - type: 'http', - url: process.env.LINEAR_MCP_URL!, - headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, - }, - }, - }) - - // A standalone client for an internal server - const internalClient = await createMCPClient({ - transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! }, - }) - - // All three servers' tools are merged: github_*, linear_*, plus internal tools - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: body.messages, - mcp: { - clients: [githubLinearPool, internalClient], - connection: 'close', - }, - }) - - return toServerSentEventsResponse(stream) -} -``` - -### Keep connections warm - -Creating a new MCP connection on every request adds latency. For production routes with high request rates, create your pool once at module level and pass `connection: 'keep-alive'` so `chat()` never closes it. The pool stays ready for the next request. - -**Server route (`app/api/chat/route.ts`):** - -```ts -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' -import { createMCPClients } from '@tanstack/ai-mcp' - -// Created once when the module loads. Shared across all requests. -const sharedPool = await createMCPClients({ - github: { - transport: { - type: 'http', - url: process.env.GITHUB_MCP_URL!, - headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, - }, - }, - linear: { - transport: { - type: 'http', - url: process.env.LINEAR_MCP_URL!, - headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, - }, - }, -}) - -export async function POST(request: Request) { - const body = await request.json() - - if ( - typeof body !== 'object' || - body === null || - !Array.isArray(body.messages) - ) { - return new Response('Bad request', { status: 400 }) - } - - // keep-alive: sharedPool is never closed by chat(); stays warm for next call - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: body.messages, - mcp: { - clients: [sharedPool], - connection: 'keep-alive', - }, - }) - - return toServerSentEventsResponse(stream) -} -``` - -**Client component (`components/Chat.tsx`):** - -```tsx -import { useChat } from '@tanstack/ai-react' -import { fetchServerSentEvents } from '@tanstack/ai-client' - -const chatOptions = { - connection: fetchServerSentEvents('/api/chat'), -} - -export function Chat() { - const { messages, sendMessage, status } = useChat(chatOptions) - - return ( -
-
    - {messages.map((m) => ( -
  • - {m.role}: {m.content} -
  • - ))} -
- -
- ) -} -``` - -### Lazy tool discovery - -When your MCP server exposes dozens of tools, sending every schema to the model inflates prompt size and cost. Set `lazyTools: true` to defer sending tool schemas until the model explicitly requests them. - -```ts -// app/api/chat/route.ts -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' -import { createMCPClient } from '@tanstack/ai-mcp' - -export async function POST(request: Request) { - const body = await request.json() - - if ( - typeof body !== 'object' || - body === null || - !Array.isArray(body.messages) - ) { - return new Response('Bad request', { status: 400 }) - } - - const mcpClient = await createMCPClient({ - transport: { type: 'http', url: process.env.LARGE_MCP_URL! }, - }) - - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: body.messages, - mcp: { - clients: [mcpClient], - connection: 'close', - // Tools are registered but schemas are withheld until the model asks - lazyTools: true, - }, - }) - - return toServerSentEventsResponse(stream) -} -``` - -`lazyTools: true` is forwarded to each source's `tools({ lazy: true })` call. See [Lazy Tool Discovery](./lazy-tool-discovery) for how the model discovers and loads lazy tools at runtime. - -### Handling discovery failures - -By default, if any source fails during discovery, `chat()` throws immediately (fail-fast). When `connection: 'close'`, any sources that did connect are cleaned up before the error propagates — no leaked connections. - -**Fail-fast (default):** - -```ts -// app/api/chat/route.ts -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' -import { createMCPClient } from '@tanstack/ai-mcp' - -export async function POST(request: Request) { - const body = await request.json() - - if ( - typeof body !== 'object' || - body === null || - !Array.isArray(body.messages) - ) { - return new Response('Bad request', { status: 400 }) - } - - const mcpClient = await createMCPClient({ - transport: { type: 'http', url: process.env.MCP_URL! }, - }) - - // If discovery fails, chat() throws before the first model call. - // mcpClient is closed automatically (connection: 'close' default). - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: body.messages, - mcp: { - clients: [mcpClient], - }, - }) - - return toServerSentEventsResponse(stream) -} -``` - -**Skip a flaky server and proceed:** - -Use `onDiscoveryError` to log the problem and return normally — the failing source is skipped and the run continues with the remaining clients' tools. - -```ts -// app/api/chat/route.ts -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' -import { createMCPClient } from '@tanstack/ai-mcp' - -export async function POST(request: Request) { - const body = await request.json() - - if ( - typeof body !== 'object' || - body === null || - !Array.isArray(body.messages) - ) { - return new Response('Bad request', { status: 400 }) - } - - const primaryClient = await createMCPClient({ - transport: { type: 'http', url: process.env.PRIMARY_MCP_URL! }, - }) - - const optionalClient = await createMCPClient({ - transport: { type: 'http', url: process.env.OPTIONAL_MCP_URL! }, - }) - - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: body.messages, - mcp: { - clients: [primaryClient, optionalClient], - connection: 'close', - onDiscoveryError(error, source) { - // Log the failure but let the run proceed without this source's tools. - // Throw here (or re-throw `error`) to fail the whole run instead. - console.warn('MCP discovery failed for a source, skipping.', error) - }, - }, - }) - - return toServerSentEventsResponse(stream) -} -``` - -> Sources passed to `onDiscoveryError` may have already connected before discovery failed. When `connection: 'close'`, they are still closed at the end of the run — even if their tools were skipped. - -### Tool-name collisions - -If two sources in `mcp.clients` expose a tool with the same name, `chat()` throws an `MCPDuplicateToolNameError` (exported from `@tanstack/ai`) after merging the discovered tools. Fix it by assigning a `prefix` to one of the clients, or by using `createMCPClients` (which auto-prefixes using the config key). - -```ts -// app/api/chat/route.ts -import { chat, toServerSentEventsResponse, MCPDuplicateToolNameError } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' -import { createMCPClient } from '@tanstack/ai-mcp' - -export async function POST(request: Request) { - const body = await request.json() - - if ( - typeof body !== 'object' || - body === null || - !Array.isArray(body.messages) - ) { - return new Response('Bad request', { status: 400 }) - } - - // Both servers expose a tool called "search". Without prefixes this would - // throw MCPDuplicateToolNameError. The prefix option resolves the clash. - const serverA = await createMCPClient({ - transport: { type: 'http', url: process.env.SERVER_A_URL! }, - prefix: 'alpha', // tools become "alpha_search", etc. - }) - - const serverB = await createMCPClient({ - transport: { type: 'http', url: process.env.SERVER_B_URL! }, - prefix: 'beta', // tools become "beta_search", etc. - }) - - try { - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: body.messages, - mcp: { - clients: [serverA, serverB], - connection: 'close', - }, - }) - - return toServerSentEventsResponse(stream) - } catch (err) { - if (err instanceof MCPDuplicateToolNameError) { - return new Response(`Tool name conflict: ${err.toolName}`, { status: 409 }) - } - throw err - } -} -``` - -See [Tool Name Collisions](#tool-name-collisions) and [Disable or override the prefix](#disable-or-override-the-prefix) for more details. - ## Tool Name Collisions When mixing tools from multiple sources, duplicate names throw `DuplicateToolNameError`: @@ -712,295 +303,13 @@ const tools = await pool.tools({ lazy: true }) See [Lazy Tool Discovery](./lazy-tool-discovery) for how the LLM discovers lazy tools at runtime. -## Resources - -MCP resources are context documents (files, database records, web pages) the server exposes. Fetch them and inject them into `chat()` as content parts. - -```ts -import { mcpResourceToContentPart } from '@tanstack/ai-mcp' - -const resources = await mcp.resources() -// resources: Array<{ uri: string; name: string; ... }> - -const readResult = await mcp.readResource(resources[0].uri) -const parts = readResult.contents.map(mcpResourceToContentPart) - -// Inject as part of a user message -const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: [ - { - role: 'user', - content: [ - ...parts, - { type: 'text', content: 'Summarize the above document.' }, - ], - }, - ], -}) -``` - -`mcpResourceToContentPart` maps each MCP content block to a `ContentPart`: -- `text` field present → `{ type: 'text', content: text }` -- `blob` field present → `{ type: 'text', content: '[binary resource ]' }` -- otherwise → `{ type: 'text', content: JSON.stringify(content) }` - -### Resource templates - -```ts -const templates = await mcp.resourceTemplates() -// templates: Array -``` - -## Prompts - -MCP prompts are reusable message templates the server exposes. Fetch a prompt, convert it to `ModelMessage[]` with `mcpPromptToMessages`, and spread it into `chat()` to seed the conversation with server-defined context or instructions. - -```ts -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' -import { createMCPClient, mcpPromptToMessages } from '@tanstack/ai-mcp' - -export async function POST(request: Request) { - const { messages } = await request.json() - - const mcp = await createMCPClient({ - transport: { type: 'http', url: process.env.MCP_URL! }, - }) - - try { - // List all available prompts on the server - const available = await mcp.prompts() - // available: Array<{ name: string; description?: string; arguments?: ... }> - - // Fetch a specific prompt, optionally passing template arguments - const prompt = await mcp.getPrompt('summarize', { language: 'english' }) - - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: [ - // Seed the conversation with the server-defined prompt messages - ...mcpPromptToMessages(prompt), - // Then append the user's own messages - ...messages, - ], - }) - - return toServerSentEventsResponse(stream) - } finally { - await mcp.close() - } -} -``` - -`mcpPromptToMessages` maps each MCP prompt message to a `ModelMessage`: -- `role === 'assistant'` → `{ role: 'assistant', content: text }` -- any other role → `{ role: 'user', content: text }` -- non-text content → `content` is `JSON.stringify`'d - -`getPrompt(name, args?)` accepts an optional `args` parameter typed as `Record` for filling in template variables declared by the prompt. - -## 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. - -```ts -const controller = new AbortController() - -const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages, - tools: await mcp.tools(), - abortController: controller, -}) - -// Cancel the run and all in-flight MCP tool calls: -controller.abort() -``` - -## Code Generation - -The `generate` CLI introspects a live MCP server and emits TypeScript interface types for [Mode 3](#mode-3--generated-types-createmcpclientgeneratedserver) type safety. - -### 1. Create `mcp.config.ts` - -```ts -// mcp.config.ts -import { defineConfig } from '@tanstack/ai-mcp' - -export default defineConfig({ - servers: { - github: { - transport: { type: 'http', url: 'https://github-mcp.example.com/mcp' }, - }, - linear: { - transport: { type: 'http', url: 'https://linear-mcp.example.com/mcp' }, - prefix: 'linear', // must match runtime createMCPClient({ prefix }) - }, - }, - outFile: './mcp-types.generated.ts', -}) -``` - -### 2. Run the generator - -```bash -npx @tanstack/ai-mcp generate -``` - -### 3. Inspect the output +## Using MCP with `chat()` -The generator emits one interface per server plus a combined pool map: +The Quick Start above hands tools to `chat()` manually via `tools: await mcp.tools()` and closes the client yourself. Two follow-on guides cover richer integrations: -```ts -// mcp-types.generated.ts — AUTO-GENERATED, do not edit - -import type { ServerDescriptor } from '@tanstack/ai-mcp' - -export interface GithubServer extends ServerDescriptor { - tools: { - 'search_repositories': { input: { query: string; limit?: number }; output: unknown } - 'create_issue': { input: { repo: string; title: string; body?: string }; output: unknown } - } - resources: {} - prompts: {} - capabilities: { tools: {} } & Record -} - -export interface LinearServer extends ServerDescriptor { - tools: { - 'linear_create_issue': { input: { title: string; teamId: string }; output: unknown } - } - resources: {} - prompts: {} - capabilities: { tools: {} } & Record -} - -export interface MCPServers extends Record { - 'github': GithubServer - 'linear': LinearServer -} -``` - -### 4. Use generated types at runtime - -**Single server:** - -```ts -import type { GithubServer } from './mcp-types.generated' -import { createMCPClient } from '@tanstack/ai-mcp' - -const mcp = await createMCPClient({ - transport: { type: 'http', url: process.env.GITHUB_MCP_URL! }, -}) - -const tools = await mcp.tools() -// Each tool name is narrowed from GithubServer['tools'] -``` - -**Multi-server pool:** - -```ts -import type { MCPServers } from './mcp-types.generated' -import { createMCPClients } from '@tanstack/ai-mcp' - -const pool = await createMCPClients({ - github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } }, - linear: { - transport: { type: 'http', url: process.env.LINEAR_MCP_URL! }, - prefix: 'linear', - }, -}) - -// Config keys are constrained to the declared servers — a typo is a compile error -const tools = await pool.tools() -``` - -## Full Server + Client Example - -Here is a complete Next.js App Router route that connects to two MCP servers and streams the response to the browser. - -**Server route (`app/api/chat/route.ts`):** - -```ts -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' -import { createMCPClients } from '@tanstack/ai-mcp' - -export async function POST(request: Request) { - const body = await request.json() - - if (typeof body !== 'object' || body === null || !Array.isArray(body.messages)) { - return new Response('Bad request', { status: 400 }) - } - - const pool = await createMCPClients({ - github: { - transport: { - type: 'http', - url: process.env.GITHUB_MCP_URL!, - headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` }, - }, - }, - linear: { - transport: { - type: 'http', - url: process.env.LINEAR_MCP_URL!, - headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_TOKEN}` }, - }, - }, - }) - - try { - const stream = chat({ - adapter: openaiText(), - model: 'gpt-4o', - messages: body.messages, - tools: await pool.tools(), - }) - - return toServerSentEventsResponse(stream) - } finally { - await pool.close() - } -} -``` +> **Let `chat()` own discovery and lifecycle.** Pass live clients and pools to `chat()` via the `mcp` option and it discovers tools and closes connections for you — no `try/finally` per route. See [Managing MCP clients with `chat()`](./mcp-chat). -**Client component (`components/Chat.tsx`):** - -```tsx -import { useChat } from '@tanstack/ai-react' -import { fetchServerSentEvents } from '@tanstack/ai-client' - -const chatOptions = { - connection: fetchServerSentEvents('/api/chat'), -} - -export function Chat() { - const { messages, sendMessage, status } = useChat(chatOptions) - - return ( -
-
    - {messages.map((m) => ( -
  • - {m.role}: {m.content} -
  • - ))} -
- -
- ) -} -``` +> **Resources, prompts, and fully-typed manual tools.** Inject MCP resources and prompts into a `chat()` run, cancel in-flight MCP calls, and spread `toolDefinition`-typed tools. See [Resources, prompts & manual tools with `chat()`](./mcp-with-chat). ## Error Reference @@ -1010,9 +319,4 @@ export function Chat() { | `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 | -## Next Steps - -- [Tools Overview](./tools) — TanStack AI tool concepts -- [Server Tools](./server-tools) — Server-side tool execution patterns -- [Lazy Tool Discovery](./lazy-tool-discovery) — Reduce token usage with large tool sets -- [Tool Approval Flow](./tool-approval) — Require user confirmation before executing tools +For the `MCPDuplicateToolNameError` thrown when merging tools from multiple sources inside a `chat({ mcp })` run, see [Managing MCP clients with `chat()`](./mcp-chat#tool-name-collisions). From 18fea4321740c8f08cf95c1c45c697887b58d180 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 15:02:28 +0200 Subject: [PATCH 50/71] =?UTF-8?q?example(ts-react-chat):=20MCP=20server=20?= =?UTF-8?q?routes=20=E2=80=94=20manual,=20chat({=20mcp=20}),=20and=20pool?= =?UTF-8?q?=20(keyless=20stdio=20reference=20servers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/ts-react-chat/package.json | 1 + examples/ts-react-chat/src/lib/mcp-servers.ts | 29 +++ .../ts-react-chat/src/routes/api.mcp-chat.ts | 104 +++++++++++ .../src/routes/api.mcp-manual.ts | 168 ++++++++++++++++++ .../ts-react-chat/src/routes/api.mcp-pool.ts | 105 +++++++++++ pnpm-lock.yaml | 3 + 6 files changed, 410 insertions(+) create mode 100644 examples/ts-react-chat/src/lib/mcp-servers.ts create mode 100644 examples/ts-react-chat/src/routes/api.mcp-chat.ts create mode 100644 examples/ts-react-chat/src/routes/api.mcp-manual.ts create mode 100644 examples/ts-react-chat/src/routes/api.mcp-pool.ts diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index ed6769348..481758fed 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -13,6 +13,7 @@ "@tailwindcss/vite": "^4.1.18", "@tanstack/ai": "workspace:*", "@tanstack/ai-anthropic": "workspace:*", + "@tanstack/ai-mcp": "workspace:*", "@tanstack/ai-client": "workspace:*", "@tanstack/ai-elevenlabs": "workspace:*", "@tanstack/ai-fal": "workspace:*", diff --git a/examples/ts-react-chat/src/lib/mcp-servers.ts b/examples/ts-react-chat/src/lib/mcp-servers.ts new file mode 100644 index 000000000..c53ec6e4c --- /dev/null +++ b/examples/ts-react-chat/src/lib/mcp-servers.ts @@ -0,0 +1,29 @@ +import { stdioTransport } from '@tanstack/ai-mcp/stdio' +import type { Transport } from '@tanstack/ai-mcp' + +/** + * Keyless official MCP reference servers (run via npx -y; no API keys needed). + * Each factory returns a fresh Transport instance — transports are single-use + * and must not be shared across requests or reused after close(). + */ + +/** @modelcontextprotocol/server-everything — demo tools, resources, and prompts. */ +export const everythingTransport = (): Transport => + stdioTransport({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-everything'], + }) + +/** @modelcontextprotocol/server-memory — persistent knowledge-graph memory tool. */ +export const memoryTransport = (): Transport => + stdioTransport({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-memory'], + }) + +/** @modelcontextprotocol/server-sequential-thinking — step-by-step reasoning tool. */ +export const sequentialThinkingTransport = (): Transport => + stdioTransport({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }) diff --git a/examples/ts-react-chat/src/routes/api.mcp-chat.ts b/examples/ts-react-chat/src/routes/api.mcp-chat.ts new file mode 100644 index 000000000..4e6440797 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.mcp-chat.ts @@ -0,0 +1,104 @@ +/** + * /api/mcp-chat — Managed MCP lifecycle via chat({ mcp }). + * + * Demonstrates the chat({ mcp }) prop pattern with MULTIPLE clients: + * 1. Two MCP clients are created (everything + memory servers, both keyless). + * 2. They are passed to chat() via mcp.clients — chat() handles tool + * discovery and closes both clients when the stream drains (connection: 'close'). + * 3. No manual client.tools() or client.close() calls needed. + * + * Uses @modelcontextprotocol/server-everything and @modelcontextprotocol/server-memory + * (both keyless, via npx). Only OPENAI_API_KEY is required — no MCP-specific keys needed. + */ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient } from '@tanstack/ai-mcp' +import { everythingTransport, memoryTransport } from '@/lib/mcp-servers' + +export const Route = createFileRoute('/api/mcp-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 }, + ) + } + + const model: string = + typeof params.forwardedProps.model === 'string' + ? params.forwardedProps.model + : 'gpt-4o' + + try { + // Connect two keyless MCP servers in parallel. + // Prefixes disambiguate tools if both servers expose same-named tools. + // OPENAI_API_KEY is used by the LLM adapter (separate from the + // keyless MCP server transports which need no credentials). + const [everything, memory] = await Promise.all([ + createMCPClient({ + transport: everythingTransport(), + prefix: 'everything', + }), + createMCPClient({ + transport: memoryTransport(), + prefix: 'memory', + }), + ]) + + // chat() discovers tools from both clients and closes them when the + // stream drains — connection: 'close' (the default; shown explicitly). + // The model is encoded in the adapter; do not pass it separately. + const stream = chat({ + adapter: openaiText(model as 'gpt-4o'), + messages: params.messages, + mcp: { + clients: [everything, memory], + connection: 'close', + }, + agentLoopStrategy: maxIterations(20), + threadId: params.threadId, + runId: params.runId, + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error: any) { + console.error('[api.mcp-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-manual.ts b/examples/ts-react-chat/src/routes/api.mcp-manual.ts new file mode 100644 index 000000000..52b160d96 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.mcp-manual.ts @@ -0,0 +1,168 @@ +/** + * /api/mcp-manual — MANUAL MCP client pattern. + * + * Demonstrates the fully-manual use-case: + * 1. The caller creates the MCP client and owns its lifecycle. + * 2. Tools are discovered via client.tools() and spread into chat() explicitly. + * 3. Resources and prompts from the server are fetched and injected into the + * conversation as extra context before the user's messages. + * 4. The MCP client is closed AFTER the SSE stream fully drains (tool calls + * fire mid-stream, so an earlier close would abort them). + * + * Uses @modelcontextprotocol/server-everything (keyless, via npx) as the MCP + * server. Only OPENAI_API_KEY is required — no MCP-specific API keys needed. + */ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient, mcpResourceToContentPart, mcpPromptToMessages } from '@tanstack/ai-mcp' +import type { StreamChunk, ModelMessage } from '@tanstack/ai' +import type { MCPClient } from '@tanstack/ai-mcp' +import { everythingTransport } from '@/lib/mcp-servers' + +/** + * Wrap the chat stream so the MCP client is closed AFTER the stream has fully + * drained (or errored). Tool calls fire mid-stream; closing the client earlier + * would abort any in-flight MCP tool call. + */ +async function* closeMcpOnDrain( + stream: AsyncIterable, + mcp: MCPClient, +): AsyncGenerator { + try { + for await (const chunk of stream) { + yield chunk + } + } finally { + await mcp.close() + } +} + +export const Route = createFileRoute('/api/mcp-manual')({ + server: { + handlers: { + POST: async ({ request }) => { + // Capture signal before reading body (it may be aborted after consumption) + const requestSignal = request.signal + + if (requestSignal.aborted) { + return new Response(null, { status: 499 }) // 499 = Client Closed Request + } + + 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 }, + ) + } + + // Extract model from forwardedProps; default to gpt-4o. + const model: string = + typeof params.forwardedProps.model === 'string' + ? params.forwardedProps.model + : 'gpt-4o' + + try { + // --- MCP: create and connect to the everything server (keyless, stdio) --- + const client = await createMCPClient({ + transport: everythingTransport(), + }) + + let tools + try { + // Auto-discover all tools from the MCP server. + tools = await client.tools() + } catch (error) { + await client.close() + throw error + } + + // --- MCP: resources — inject the first resource as context (if any) --- + const contextMessages: Array = [] + + try { + const resources = await client.resources() + if (resources.length > 0) { + // Read the first resource and convert each content block to a ContentPart. + const readResult = await client.readResource(resources[0]!.uri) + const parts = readResult.contents.map(mcpResourceToContentPart) + if (parts.length > 0) { + contextMessages.push({ + role: 'user', + content: [ + ...parts, + { + type: 'text', + content: + '[MCP resource context injected from server-everything — use this as background information if relevant]', + }, + ], + }) + } + } + } catch { + // Resources are optional — proceed without them if unavailable. + } + + // --- MCP: prompts — prepend the first available prompt (if any) --- + try { + const availablePrompts = await client.prompts() + if (availablePrompts.length > 0) { + const firstPrompt = availablePrompts[0]! + const promptResult = await client.getPrompt(firstPrompt.name) + const promptMessages = mcpPromptToMessages(promptResult) + // Prepend prompt messages before resource context and user messages. + contextMessages.unshift(...promptMessages) + } + } catch { + // Prompts are optional — proceed without them if unavailable. + } + + // OPENAI_API_KEY is used by the LLM adapter (separate from the + // keyless MCP server transport which needs no credentials). + // The model is encoded in the adapter; do not pass it separately. + const stream = chat({ + adapter: openaiText(model as 'gpt-4o'), + messages: [...contextMessages, ...params.messages], + tools, + agentLoopStrategy: maxIterations(20), + threadId: params.threadId, + runId: params.runId, + abortController, + }) + + // Close the MCP client only after the SSE stream fully drains. + return toServerSentEventsResponse(closeMcpOnDrain(stream, client), { + abortController, + }) + } catch (error: any) { + console.error('[api.mcp-manual] 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-pool.ts b/examples/ts-react-chat/src/routes/api.mcp-pool.ts new file mode 100644 index 000000000..70f15f054 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.mcp-pool.ts @@ -0,0 +1,105 @@ +/** + * /api/mcp-pool — createMCPClients pool pattern. + * + * Demonstrates the createMCPClients() pool API with THREE servers: + * 1. A pool of three keyless MCP servers is created in one call. + * 2. createMCPClients() auto-prefixes each server's tools with its config key + * (everything_*, memory_*, thinking_*) to prevent name collisions. + * 3. The pool is passed to chat() via mcp.clients — chat() owns discovery + * and closes all three connections when the stream drains (connection: 'close'). + * + * Uses @modelcontextprotocol/server-everything, @modelcontextprotocol/server-memory, + * and @modelcontextprotocol/server-sequential-thinking (all keyless, via npx). + * Only OPENAI_API_KEY is required — no MCP-specific API keys needed. + */ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClients } from '@tanstack/ai-mcp' +import { + everythingTransport, + memoryTransport, + sequentialThinkingTransport, +} from '@/lib/mcp-servers' + +export const Route = createFileRoute('/api/mcp-pool')({ + 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 }, + ) + } + + const model: string = + typeof params.forwardedProps.model === 'string' + ? params.forwardedProps.model + : 'gpt-4o' + + 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({ + everything: { transport: everythingTransport() }, + memory: { transport: memoryTransport() }, + thinking: { transport: sequentialThinkingTransport() }, + }) + + // chat() manages discovery and closes all pool connections on drain. + // The model is encoded in the adapter; do not pass it separately. + const stream = chat({ + adapter: openaiText(model as 'gpt-4o'), + messages: params.messages, + mcp: { + clients: [pool], + connection: 'close', + }, + agentLoopStrategy: maxIterations(20), + threadId: params.threadId, + runId: params.runId, + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error: any) { + console.error('[api.mcp-pool] 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/pnpm-lock.yaml b/pnpm-lock.yaml index 8dc02de67..acd5378ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,6 +355,9 @@ importers: '@tanstack/ai-groq': specifier: workspace:* version: link:../../packages/ai-groq + '@tanstack/ai-mcp': + specifier: workspace:* + version: link:../../packages/ai-mcp '@tanstack/ai-ollama': specifier: workspace:* version: link:../../packages/ai-ollama From 262cbdbd0e8a81a070a8927b71e69e4c678d54b4 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 4 Jun 2026 15:14:17 +0200 Subject: [PATCH 51/71] example(ts-react-chat): MCP demo page with manual/chat/pool mode selector --- examples/ts-react-chat/src/routeTree.gen.ts | 84 ++++++ .../ts-react-chat/src/routes/api.mcp-chat.ts | 7 +- .../src/routes/api.mcp-manual.ts | 8 +- .../ts-react-chat/src/routes/api.mcp-pool.ts | 7 +- .../ts-react-chat/src/routes/mcp-demo.tsx | 261 ++++++++++++++++++ 5 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 examples/ts-react-chat/src/routes/mcp-demo.tsx diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 467b07418..f1a73a3b9 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -39,6 +39,10 @@ import { Route as ApiGenerateVideoRouteImport } from './routes/api.generate.vide import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.speech' import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image' import { Route as ApiGenerateAudioRouteImport } from './routes/api.generate.audio' +import { Route as McpDemoRouteImport } from './routes/mcp-demo' +import { Route as ApiMcpManualRouteImport } from './routes/api.mcp-manual' +import { Route as ApiMcpChatRouteImport } from './routes/api.mcp-chat' +import { Route as ApiMcpPoolRouteImport } from './routes/api.mcp-pool' const ThreadsRoute = ThreadsRouteImport.update({ id: '/threads', @@ -193,6 +197,26 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({ path: '/api/generate/audio', getParentRoute: () => rootRouteImport, } as any) +const McpDemoRoute = McpDemoRouteImport.update({ + id: '/mcp-demo', + path: '/mcp-demo', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMcpManualRoute = ApiMcpManualRouteImport.update({ + id: '/api/mcp-manual', + path: '/api/mcp-manual', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMcpChatRoute = ApiMcpChatRouteImport.update({ + id: '/api/mcp-chat', + path: '/api/mcp-chat', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMcpPoolRoute = ApiMcpPoolRouteImport.update({ + id: '/api/mcp-pool', + path: '/api/mcp-pool', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -225,6 +249,10 @@ export interface FileRoutesByFullPath { '/api/generate/video': typeof ApiGenerateVideoRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars/': typeof ExampleGuitarsIndexRoute + '/mcp-demo': typeof McpDemoRoute + '/api/mcp-manual': typeof ApiMcpManualRoute + '/api/mcp-chat': typeof ApiMcpChatRoute + '/api/mcp-pool': typeof ApiMcpPoolRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -257,6 +285,10 @@ export interface FileRoutesByTo { '/api/generate/video': typeof ApiGenerateVideoRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars': typeof ExampleGuitarsIndexRoute + '/mcp-demo': typeof McpDemoRoute + '/api/mcp-manual': typeof ApiMcpManualRoute + '/api/mcp-chat': typeof ApiMcpChatRoute + '/api/mcp-pool': typeof ApiMcpPoolRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -290,6 +322,10 @@ export interface FileRoutesById { '/api/generate/video': typeof ApiGenerateVideoRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars/': typeof ExampleGuitarsIndexRoute + '/mcp-demo': typeof McpDemoRoute + '/api/mcp-manual': typeof ApiMcpManualRoute + '/api/mcp-chat': typeof ApiMcpChatRoute + '/api/mcp-pool': typeof ApiMcpPoolRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -324,6 +360,10 @@ export interface FileRouteTypes { | '/api/generate/video' | '/example/guitars/$guitarId' | '/example/guitars/' + | '/mcp-demo' + | '/api/mcp-manual' + | '/api/mcp-chat' + | '/api/mcp-pool' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -356,6 +396,10 @@ export interface FileRouteTypes { | '/api/generate/video' | '/example/guitars/$guitarId' | '/example/guitars' + | '/mcp-demo' + | '/api/mcp-manual' + | '/api/mcp-chat' + | '/api/mcp-pool' id: | '__root__' | '/' @@ -388,6 +432,10 @@ export interface FileRouteTypes { | '/api/generate/video' | '/example/guitars/$guitarId' | '/example/guitars/' + | '/mcp-demo' + | '/api/mcp-manual' + | '/api/mcp-chat' + | '/api/mcp-pool' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -421,6 +469,10 @@ export interface RootRouteChildren { ApiGenerateVideoRoute: typeof ApiGenerateVideoRoute ExampleGuitarsGuitarIdRoute: typeof ExampleGuitarsGuitarIdRoute ExampleGuitarsIndexRoute: typeof ExampleGuitarsIndexRoute + McpDemoRoute: typeof McpDemoRoute + ApiMcpManualRoute: typeof ApiMcpManualRoute + ApiMcpChatRoute: typeof ApiMcpChatRoute + ApiMcpPoolRoute: typeof ApiMcpPoolRoute } declare module '@tanstack/react-router' { @@ -635,6 +687,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGenerateAudioRouteImport parentRoute: typeof rootRouteImport } + '/mcp-demo': { + id: '/mcp-demo' + path: '/mcp-demo' + fullPath: '/mcp-demo' + preLoaderRoute: typeof McpDemoRouteImport + parentRoute: typeof rootRouteImport + } + '/api/mcp-manual': { + id: '/api/mcp-manual' + path: '/api/mcp-manual' + fullPath: '/api/mcp-manual' + preLoaderRoute: typeof ApiMcpManualRouteImport + parentRoute: typeof rootRouteImport + } + '/api/mcp-chat': { + id: '/api/mcp-chat' + path: '/api/mcp-chat' + fullPath: '/api/mcp-chat' + preLoaderRoute: typeof ApiMcpChatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/mcp-pool': { + id: '/api/mcp-pool' + path: '/api/mcp-pool' + fullPath: '/api/mcp-pool' + preLoaderRoute: typeof ApiMcpPoolRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -669,6 +749,10 @@ const rootRouteChildren: RootRouteChildren = { ApiGenerateVideoRoute: ApiGenerateVideoRoute, ExampleGuitarsGuitarIdRoute: ExampleGuitarsGuitarIdRoute, ExampleGuitarsIndexRoute: ExampleGuitarsIndexRoute, + McpDemoRoute: McpDemoRoute, + ApiMcpManualRoute: ApiMcpManualRoute, + ApiMcpChatRoute: ApiMcpChatRoute, + ApiMcpPoolRoute: ApiMcpPoolRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) 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 4e6440797..f2d84bcba 100644 --- a/examples/ts-react-chat/src/routes/api.mcp-chat.ts +++ b/examples/ts-react-chat/src/routes/api.mcp-chat.ts @@ -43,11 +43,6 @@ export const Route = createFileRoute('/api/mcp-chat')({ ) } - const model: string = - typeof params.forwardedProps.model === 'string' - ? params.forwardedProps.model - : 'gpt-4o' - try { // Connect two keyless MCP servers in parallel. // Prefixes disambiguate tools if both servers expose same-named tools. @@ -68,7 +63,7 @@ export const Route = createFileRoute('/api/mcp-chat')({ // stream drains — connection: 'close' (the default; shown explicitly). // The model is encoded in the adapter; do not pass it separately. const stream = chat({ - adapter: openaiText(model as 'gpt-4o'), + adapter: openaiText('gpt-4o'), messages: params.messages, mcp: { clients: [everything, memory], 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 52b160d96..3b327f560 100644 --- a/examples/ts-react-chat/src/routes/api.mcp-manual.ts +++ b/examples/ts-react-chat/src/routes/api.mcp-manual.ts @@ -66,12 +66,6 @@ export const Route = createFileRoute('/api/mcp-manual')({ ) } - // Extract model from forwardedProps; default to gpt-4o. - const model: string = - typeof params.forwardedProps.model === 'string' - ? params.forwardedProps.model - : 'gpt-4o' - try { // --- MCP: create and connect to the everything server (keyless, stdio) --- const client = await createMCPClient({ @@ -132,7 +126,7 @@ export const Route = createFileRoute('/api/mcp-manual')({ // keyless MCP server transport which needs no credentials). // The model is encoded in the adapter; do not pass it separately. const stream = chat({ - adapter: openaiText(model as 'gpt-4o'), + adapter: openaiText('gpt-4o'), messages: [...contextMessages, ...params.messages], tools, agentLoopStrategy: maxIterations(20), 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 70f15f054..a13ada714 100644 --- a/examples/ts-react-chat/src/routes/api.mcp-pool.ts +++ b/examples/ts-react-chat/src/routes/api.mcp-pool.ts @@ -49,11 +49,6 @@ export const Route = createFileRoute('/api/mcp-pool')({ ) } - const model: string = - typeof params.forwardedProps.model === 'string' - ? params.forwardedProps.model - : 'gpt-4o' - try { // createMCPClients connects all three servers in parallel and // auto-prefixes tools with the config key (everything_*, memory_*, @@ -69,7 +64,7 @@ export const Route = createFileRoute('/api/mcp-pool')({ // chat() manages discovery and closes all pool connections on drain. // The model is encoded in the adapter; do not pass it separately. const stream = chat({ - adapter: openaiText(model as 'gpt-4o'), + adapter: openaiText('gpt-4o'), messages: params.messages, mcp: { clients: [pool], diff --git a/examples/ts-react-chat/src/routes/mcp-demo.tsx b/examples/ts-react-chat/src/routes/mcp-demo.tsx new file mode 100644 index 000000000..13601250f --- /dev/null +++ b/examples/ts-react-chat/src/routes/mcp-demo.tsx @@ -0,0 +1,261 @@ +import { useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { Send, Square } from 'lucide-react' +import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import rehypeHighlight from 'rehype-highlight' +import remarkGfm from 'remark-gfm' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { ThinkingPart } from '@tanstack/ai-react-ui' +import type { UIMessage } from '@tanstack/ai-react' + +type McpMode = 'manual' | 'chat' | 'pool' + +const MODES: Array<{ + value: McpMode + label: string + endpoint: string + description: string +}> = [ + { + value: 'manual', + label: 'Manual', + endpoint: '/api/mcp-manual', + description: + 'Manually spread tools + inject resources/prompts as context before user messages.', + }, + { + value: 'chat', + label: 'chat({ mcp })', + endpoint: '/api/mcp-chat', + description: + 'Pass MCP clients directly to chat(); it handles tool discovery and lifecycle.', + }, + { + value: 'pool', + label: 'Pool', + endpoint: '/api/mcp-pool', + description: + 'createMCPClients() spins up a 3-server pool with auto-prefixed tool names.', + }, +] + +function Messages({ messages }: { messages: Array }) { + const messagesContainerRef = useRef(null) + + const visibleMessages = messages.filter((message) => + message.parts.some( + (part) => + (part.type === 'text' && part.content.trim()) || + part.type === 'thinking', + ), + ) + + if (!visibleMessages.length) { + return ( +
+
+

+ Select a mode above and send a message to try it out. +

+
+
+ ) + } + + return ( +
+ {visibleMessages.map((message) => ( +
+
+ {message.role === 'assistant' ? ( +
+ AI +
+ ) : ( +
+ U +
+ )} +
+ {message.parts.map((part, index) => { + if (part.type === 'thinking') { + const isComplete = message.parts + .slice(index + 1) + .some((p) => p.type === 'text') + return ( +
+ +
+ ) + } + + if (part.type === 'text' && part.content) { + return ( +
+ + {part.content} + +
+ ) + } + + return null + })} +
+
+
+ ))} +
+ ) +} + +function ChatSurface({ endpoint }: { endpoint: string }) { + const { messages, sendMessage, isLoading, error, stop } = useChat({ + connection: fetchServerSentEvents(endpoint), + body: { model: 'gpt-4o' }, + }) + + const [input, setInput] = useState('') + + const handleSend = () => { + if (!input.trim()) return + sendMessage(input.trim()) + setInput('') + } + + return ( +
+ + + {error && ( +
+ {error.message} +
+ )} + +
+
+ {isLoading && ( +
+ +
+ )} +
+
+