diff --git a/.changeset/mcp-request-meta.md b/.changeset/mcp-request-meta.md new file mode 100644 index 0000000000..844be27536 --- /dev/null +++ b/.changeset/mcp-request-meta.md @@ -0,0 +1,8 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-mcp': minor +--- + +Send request context to MCP servers as `_meta`. Set `requestMeta` on `chat({ mcp })` or `tools()`, or pass `meta` to `callTool`, `readResource`, `getPrompt`, and the MCP Apps call handler. + +`createMCPClients().readResource(uri, { serverId })` now reads only from the server with that key. diff --git a/docs/config.json b/docs/config.json index f75826e68f..cab1bb46ce 100644 --- a/docs/config.json +++ b/docs/config.json @@ -636,13 +636,13 @@ "label": "Managed MCP with chat()", "to": "tools/mcp-managed", "addedAt": "2026-06-05", - "updatedAt": "2026-11-02" + "updatedAt": "2026-11-09" }, { "label": "Manual MCP", "to": "tools/mcp-manual", "addedAt": "2026-06-05", - "updatedAt": "2026-11-02" + "updatedAt": "2026-11-09" }, { "label": "MCP Type Generation", @@ -670,7 +670,7 @@ "label": "MCP Apps", "to": "mcp/apps", "addedAt": "2026-06-24", - "updatedAt": "2026-11-02" + "updatedAt": "2026-11-09" }, { "label": "Serve Tools over HTTP", diff --git a/docs/mcp/apps.md b/docs/mcp/apps.md index 94135d750d..774d4f0ce4 100644 --- a/docs/mcp/apps.md +++ b/docs/mcp/apps.md @@ -368,6 +368,51 @@ const handler = createMcpAppCallHandler({ `phase` is `'call'` for a failure to connect, find, or run the tool. It is `'close'` for a failure to close the client after the call. A `'close'` failure does not change the result. +### Send request context with widget calls + +Pass `meta` to the handler in your call route. The handler sends it as the `_meta` of the widget tool call: + +```ts ignore +// src/routes/api.mcp-apps-call.ts (TanStack Start) +import { createFileRoute } from '@tanstack/react-router' +import { createMCPClients } from '@tanstack/ai-mcp' +import { createMcpAppCallHandler } from '@tanstack/ai-mcp/apps' + +const mcp = await createMCPClients({ + weather: { transport: { type: 'http', url: process.env.WEATHER_MCP_URL ?? '' } }, +}) + +const handler = createMcpAppCallHandler({ clients: mcp }) + +export const Route = createFileRoute('/api/mcp-apps/call')({ + server: { + handlers: { + POST: async ({ request }) => { + const body = await request.json() + const result = await handler(body, { + signal: request.signal, + // Use the same keys as the requestMeta of your chat route. + // Your auth gateway sets these headers after it checks the user. + meta: { + 'acme.com/tenantId': request.headers.get('x-tenant-id'), + 'acme.com/traceId': request.headers.get('x-trace-id'), + }, + }) + return new Response(JSON.stringify(result), { + headers: { 'Content-Type': 'application/json' }, + }) + }, + }, + }, +}) +``` + +- A widget call is a new HTTP request, outside the `chat()` run. So the `requestMeta` of `chat()` does not reach it. Read the context from this request. +- Only the `tools/call` gets the `_meta`. The connect and the tool list do not. +- In the chat route, set `requestMeta` so that the `ui://` read of the widget gets the same context. See [Send request context to MCP servers](../tools/mcp-managed#send-request-context-to-mcp-servers). + +Now the server sees the same tenant on the chat tool call, the widget read, and each widget call. + ### Links from a widget - If you pass no `onLink`, the bridge drops the link and logs a warning. `openLink` returns `{ isError: true }`, and the call does not hang. @@ -409,7 +454,7 @@ const handler = createMcpAppCallHandler({ clients: mcp, store }) ### API reference -`createMcpAppCallHandler(options)` from `@tanstack/ai-mcp/apps` returns `(req) => Promise<{ ok: true; result: unknown } | { ok: false; error: string }>`. +`createMcpAppCallHandler(options)` from `@tanstack/ai-mcp/apps` returns `(req, { signal?, meta? }?) => Promise<{ ok: true; result: unknown } | { ok: false; error: string }>`. | Option | Type | Description | | --- | --- | --- | diff --git a/docs/tools/mcp-managed.md b/docs/tools/mcp-managed.md index dcacdabf60..8ad0a1997f 100644 --- a/docs/tools/mcp-managed.md +++ b/docs/tools/mcp-managed.md @@ -14,6 +14,7 @@ keywords: - onDiscoveryError - toolFilter - needsApproval + - requestMeta --- Pass MCP clients to `chat()` in the `mcp` option. `chat()` loads their tools and closes the clients when the run ends. @@ -171,6 +172,82 @@ A `toolFilter` list is strict. If the server has no tool with a listed name, or > [!WARNING] > The hints come from the server, and a compromised server can send false hints. For the trust rules and the list form, see [Limit and Gate Tools](./mcp-advanced#limit-and-gate-tools). +## Send request context to MCP servers + +Set `requestMeta` on `chat({ mcp })`. `chat()` sends its result as `_meta` with each MCP tool call and each MCP Apps `ui://` read in this run. + +Often the MCP server must know who calls: the tenant, the user, or the trace. A shared pool connects once, so its headers are the same for all requests. `_meta` goes with each request, on every transport. + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClients } from '@tanstack/ai-mcp' + +// Made once when the module loads. All requests share it. +const sharedPool = await createMCPClients({ + internal: { transport: { type: 'http', url: process.env.INTERNAL_MCP_URL! } }, + github: { transport: { type: 'http', url: process.env.GITHUB_MCP_URL! } }, +}) + +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request) + // Your auth gateway sets these headers after it checks the user. + const tenantId = request.headers.get('x-tenant-id') + const traceId = request.headers.get('x-trace-id') + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + messages, + threadId, + runId, + mcp: { + clients: [sharedPool], + connection: 'keep-alive', + // Send the context to the internal server only + requestMeta: ({ serverId }) => + serverId === 'internal' + ? { 'acme.com/tenantId': tenantId, 'acme.com/traceId': traceId } + : undefined, + }, + }) + + return toServerSentEventsResponse(stream) +} +``` + +Your `useChat` code does not change. For why the pool is made once, see [Keep connections warm](#keep-connections-warm). + +How the hook works: + +- It runs once for each MCP request, when the request starts. Tool discovery does not call it. +- It gets `method` and `serverId`. A `tools/call` also has `toolName` and `args`. A `resources/read` has `uri`. In a `createMCPClients` pool, `serverId` is the config key. +- Return `undefined` to send no `_meta`. +- The client removes the keys that MCP reserves: `progressToken`, and keys with a prefix like `io.modelcontextprotocol/` or `mcp.dev/`. +- If the hook throws, only that request fails. A tool call goes to the model as a tool error. A `ui://` read fails, and the message has no widget. + +On the server, an MCP SDK request handler finds the object in `request.params._meta`. + +`_meta` is not part of the [`toolCacheMiddleware`](../advanced/built-in-middleware#toolcachemiddleware) key. A cache hit sends no request, so the hook does not run. If tenants share one cache storage, put the tenant in `keyFn`: + +```ts +import { + toolCacheMiddleware, + type ToolCacheStorage, +} from '@tanstack/ai/middlewares' + +// Make the middleware in the route, with the tenant of the request +export function tenantToolCache(tenantId: string, storage: ToolCacheStorage) { + return toolCacheMiddleware({ + storage, + keyFn: (toolName, args) => JSON.stringify([tenantId, toolName, args]), + }) +} +``` + ## Keep connections warm A new MCP connection on each request adds latency. On a busy route, make the pool once at module level and pass `connection: 'keep-alive'`. Then `chat()` never closes the pool, and the next request uses the same connections. diff --git a/docs/tools/mcp-manual.md b/docs/tools/mcp-manual.md index b0dbc52097..f1a40f52f8 100644 --- a/docs/tools/mcp-manual.md +++ b/docs/tools/mcp-manual.md @@ -297,6 +297,71 @@ export const Route = createFileRoute('/api/chat')({ | Any other role | `{ role: 'user', content: text }` | | Content that is not text | `content` is the `JSON.stringify` output | +## Send request context + +Pass `meta` to `callTool`, `readResource`, or `getPrompt`. The client sends it as the `_meta` of that request, so the MCP server knows who calls: + +```ts +import { createMCPClient } from '@tanstack/ai-mcp' + +const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, +}) + +const meta = { 'acme.com/tenantId': 'tenant-42', 'acme.com/traceId': 'trace-7' } + +const result = await mcp.callTool('search', { query: 'invoices' }, { meta }) +const doc = await mcp.readResource('docs://handbook', { meta }) +const prompt = await mcp.getPrompt('summarize', { language: 'english' }, { meta }) +console.log(result.content, doc.contents, prompt.messages) +``` + +`meta` goes on that one request only. The client sends no `_meta` with `tools/list`, `resources/list`, or `prompts/list`. + +On a `createMCPClients` pool, `readResource(uri, { serverId, meta })` reads only from the server with that config key. The other servers do not get the `_meta`. + +### Context for tools that you pass to chat() + +Set `requestMeta` on `tools()`. Each call of the returned tools runs the hook and sends its result as `_meta`. Call `tools()` once for each request, so the hook stays with that request: + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient } from '@tanstack/ai-mcp' + +// Made once when the module loads. All requests share it. +const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, +}) + +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request) + // Your auth gateway sets this header after it checks the user. + const tenantId = request.headers.get('x-tenant-id') + + // New tools for this request. Each tool call sends this tenant. + const tools = await mcp.tools({ + requestMeta: () => ({ 'acme.com/tenantId': tenantId }), + }) + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + messages, + threadId, + runId, + tools, + }) + + return toServerSentEventsResponse(stream) +} +``` + +With typed tools, pass the options second: `mcp.tools([searchDef], { requestMeta })`. For what the hook gets and which keys the client removes, see [Send request context to MCP servers](./mcp-managed#send-request-context-to-mcp-servers). + ## Cancellation A chat run can stop, for example when the user leaves the page or an `AbortController` fires. Then TanStack AI stops the wait for MCP tool calls in progress. The abort signal of the run goes to the execute function of each tool in `ToolExecutionContext.abortSignal`. diff --git a/packages/ai-mcp/src/apps/call-handler.ts b/packages/ai-mcp/src/apps/call-handler.ts index a404b18228..b0866b580a 100644 --- a/packages/ai-mcp/src/apps/call-handler.ts +++ b/packages/ai-mcp/src/apps/call-handler.ts @@ -217,6 +217,12 @@ export function createMcpAppCallHandler(opts: McpAppCallHandlerOptions) { options?: { /** Cancels the connect, the tool list, and the tool call. */ signal?: AbortSignal + /** + * Sent as the `_meta` of the proxied `tools/call`. Pass the request + * context of the HTTP request that carries this widget call (tenant, + * trace). + */ + meta?: Record }, ): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> => { const signal = options?.signal @@ -300,8 +306,9 @@ export function createMcpAppCallHandler(opts: McpAppCallHandlerOptions) { if (!isArgsRecord(args)) { return { ok: false, error: 'Invalid args: expected an object' } } - const result = signal - ? await client.callTool(req.toolName, args, { signal }) + // Only the tools/call gets the meta. The connect and tools/list do not. + const result = options + ? await client.callTool(req.toolName, args, options) : await client.callTool(req.toolName, args) return { ok: true, result } } catch (err) { diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 4c57360344..1b67dacf1c 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -31,6 +31,7 @@ import type { import { callMcpTool, makeMcpExecute, + requestMetaParams, requiresTaskExecution, sdkRequestOptions, serverSupportsTaskCalls, @@ -139,10 +140,11 @@ export interface MCPClient< resources: (options?: { signal?: AbortSignal }) => Promise> /** * Reads one resource. With a typed server, `uri` is one of its resource URIs. + * `options.meta` is sent as the request `_meta`; reserved MCP keys are dropped. */ readResource: ( uri: TServer['resources'][keyof TServer['resources']]['uri'], - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; meta?: Record }, ) => Promise resourceTemplates: (options?: { signal?: AbortSignal @@ -152,11 +154,12 @@ export interface MCPClient< * Renders one prompt. With a typed server, `name` is one of its prompt * names and `args` has that prompt's argument type. MCP sends each * argument as a string. + * `options.meta` is sent as the request `_meta`; reserved MCP keys are dropped. */ getPrompt: ( name: TName, args?: TServer['prompts'][TName]['args'], - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; meta?: Record }, ) => Promise /** * Call a tool directly and return its raw MCP result. Tools declaring @@ -168,11 +171,12 @@ export interface MCPClient< * With a typed server, `name` is one of its tool names and `args` has * that tool's input type. The result is the raw MCP result. For a tool * with an output schema, `structuredContent` has the tool output type. + * `options.meta` is sent as the request `_meta`; reserved MCP keys are dropped. */ callTool: ( name: TName, args?: TServer['tools'][TName]['input'], - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; meta?: Record }, ) => Promise> /** * Sends one request with the SDK `Client.request`, with the same @@ -471,6 +475,7 @@ class MCPClientImpl implements Omit< requiresTaskExecution(serverTool), requestOptions, serverTool.inputSchema, + { requestMeta: options.requestMeta, serverId: this.prefix }, ), ) as ServerTool // A caller-supplied definition may already carry its own `mcp` block, @@ -513,6 +518,7 @@ class MCPClientImpl implements Omit< lazy: options.lazy, needsApproval, requestOptions, + requestMeta: options.requestMeta, }) } @@ -538,14 +544,17 @@ class MCPClientImpl implements Omit< async readResource( uri: string, - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; meta?: Record }, ): Promise { if (this.#closed) throw new MCPConnectionError('MCP client is closed') const request = sdkRequestOptions( this.#policy.requestOptions, options?.signal, ) - return this.#client.readResource({ uri }, request) + return this.#client.readResource( + { uri, ...requestMetaParams(options?.meta) }, + request, + ) } async resourceTemplates(options?: { @@ -572,7 +581,7 @@ class MCPClientImpl implements Omit< async getPrompt( name: string, args?: unknown, - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; meta?: Record }, ): Promise { if (this.#closed) throw new MCPConnectionError('MCP client is closed') // MCP prompt arguments are strings. @@ -585,13 +594,20 @@ class MCPClientImpl implements Omit< this.#policy.requestOptions, options?.signal, ) - return this.#client.getPrompt({ name, arguments: promptArgs }, request) + return this.#client.getPrompt( + { + name, + arguments: promptArgs, + ...requestMetaParams(options?.meta), + }, + request, + ) } async callTool( name: TName, args?: TServer['tools'][TName]['input'], - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; meta?: Record }, ): Promise> { if (this.#closed) throw new MCPConnectionError('MCP client is closed') if (!this.#toolDefinitions) { @@ -623,6 +639,8 @@ class MCPClientImpl implements Omit< undefined, this.#policy.requestOptions, definition?.inputSchema, + undefined, + options?.meta, ) // Trust boundary: the server type says what `structuredContent` holds. // The client does not check the wire value against that type. diff --git a/packages/ai-mcp/src/pool.ts b/packages/ai-mcp/src/pool.ts index fc13c019b7..1a365e7c58 100644 --- a/packages/ai-mcp/src/pool.ts +++ b/packages/ai-mcp/src/pool.ts @@ -29,15 +29,22 @@ export interface MCPClients< */ tools: (options?: ToolsOptions) => Promise> /** - * Reads an MCP resource by URI, routing to the owning client. A `ui://` - * resource read must hit the server that owns it; since the pool does not - * track ownership, each underlying client is tried in turn and the first - * success is returned. If every client fails, the last error is thrown. + * Reads an MCP resource by URI from the server that owns it. A `ui://` + * read must reach that server. + * + * - `options.serverId`: when it equals a client's prefix, only that client + * is read, and its error is thrown as is. Otherwise each client is tried + * in turn and the first result that contains `uri` is returned. If all + * clients fail, the thrown error has every client error as its cause. + * - `options.meta`: sent as the request `_meta` to each server that is read. * * Required so a pool source emits `ui-resource` events for MCP Apps widgets * (the chat manager binds `readResource` only when the source exposes it). */ - readResource: (uri: string) => Promise + readResource: ( + uri: string, + options?: { serverId?: string; meta?: Record }, + ) => Promise /** * The connection descriptors for every server in the pool, keyed by config * key (the serverId / default prefix). Used by `createMcpAppCallHandler` to @@ -172,22 +179,37 @@ export async function createMCPClients< Object.entries(clients).map(([key, c]) => [key, c.getInfo()]), ) }, - async readResource(uri: string): Promise { - // Ownership isn't tracked, so try each client. A non-owning server may - // resolve an unrelated URI, so only accept a result whose `contents` - // actually include the requested `uri`; otherwise keep trying. A ui:// - // read must reach the server that owns it. - const errors: Array = [] + async readResource( + uri: string, + options?: { serverId?: string; meta?: Record }, + ): Promise { + // A serverId that equals a client prefix names the owner: read only it. + // Without an owner, try each client. A non-owning server may resolve an + // unrelated URI, so only accept a result whose `contents` actually + // include the requested `uri`; otherwise keep trying. + // ponytail: a client with `prefix: ''` has no serverId, so its reads + // still try each client, and its meta then reaches every server. + // Upgrade path: track resource ownership. + const serverId = options?.serverId const all = Object.values(clients) - for (const c of all) { + const owner = + serverId === undefined + ? undefined + : all.find((c) => c.getInfo().prefix === serverId) + const candidates = owner === undefined ? all : [owner] + const errors: Array = [] + for (const c of candidates) { try { const result = await (c as MCPClient).readResource( uri, + { meta: options?.meta }, ) if (result.contents.some((entry) => entry.uri === uri)) { return result } } catch (err) { + // The owner's error is the real failure: throw it as is. + if (owner !== undefined) throw err errors.push(err) } } diff --git a/packages/ai-mcp/src/tools.ts b/packages/ai-mcp/src/tools.ts index f08d214f20..e5d0b283cc 100644 --- a/packages/ai-mcp/src/tools.ts +++ b/packages/ai-mcp/src/tools.ts @@ -13,6 +13,7 @@ import type { import type { AnyServerTool, ContentPart, + MCPRequestMeta, ToolInputResponse, } from '@tanstack/ai' import { @@ -27,6 +28,7 @@ interface ConvertOptions { lazy?: boolean needsApproval?: (tool: McpToolDef) => boolean requestOptions?: MCPClientOptions['requestOptions'] + requestMeta?: MCPRequestMeta } /** Reads the MCP Apps `_meta.ui.resourceUri` link from a tool def, if present. */ @@ -145,6 +147,8 @@ export function mcpContentToTanstack( * reads its `x-mcp-header` fields to send the `Mcp-Param-*` headers. * @param askInput - Asks the user for each elicitation round on spec 2026. * The harness sets it. Without it, a call answers one round. + * @param meta - Sent as the request `_meta` on each tools/call of this call. + * Reserved MCP keys are dropped. */ export async function callMcpTool( client: Client, @@ -156,6 +160,7 @@ export async function callMcpTool( requestOptions?: MCPClientOptions['requestOptions'], inputSchema?: McpToolDef['inputSchema'], askInput?: AskInput, + meta?: Record, ) { signal?.throwIfAborted() const isModern = client.getProtocolEra() === 'modern' @@ -163,7 +168,7 @@ export async function callMcpTool( const raw = await sdkRequest( client, 'tools/call', - { name: mcpName, arguments: args, task: {} }, + { name: mcpName, arguments: args, task: {}, ...requestMetaParams(meta) }, signal, requestOptions, ) @@ -180,7 +185,11 @@ export async function callMcpTool( ? { toolDefinition: { name: mcpName, inputSchema } } : {}), } - const params = { name: mcpName, arguments: args } + const params = { + name: mcpName, + arguments: args, + ...requestMetaParams(meta), + } let result = await client.callTool(params, options) // ponytail: the answer is sent on the second call, so the server state never // travels through the browser. The cost is one extra tools/call. @@ -415,6 +424,34 @@ export function sdkRequestOptions( } } +/** + * The `_meta` request params for a per-call `meta`. It drops the keys the MCP + * spec reserves: `progressToken`, and a key whose prefix has an `mcp` or + * `modelcontextprotocol` label next to another label (`io.modelcontextprotocol/x`, + * `mcp.dev/x`). + * It gives `{}` when `meta` is not a plain object or has no keys left, so no + * `_meta` is sent. + * + * @param meta - The caller's meta. A hook can return any value at runtime. + */ +export function requestMetaParams(meta: unknown) { + if (!isRecord(meta)) return {} + const kept = Object.entries(meta).filter(([key]) => !isReservedMetaKey(key)) + return kept.length === 0 ? {} : { _meta: Object.fromEntries(kept) } +} + +function isReservedMetaKey(key: string) { + if (key === 'progressToken') return true + const slash = key.indexOf('/') + if (slash < 0) return false + const labels = key.slice(0, slash).split('.') + // A one-label prefix such as `mcp/x` is not reserved. + return ( + labels.length > 1 && + labels.some((label) => label === 'mcp' || label === 'modelcontextprotocol') + ) +} + async function sdkRequest( client: Client, method: string, @@ -570,6 +607,8 @@ function isRecord(value: unknown): value is Record { * a JSON-in-text blob. Otherwise normalize `content[]` → string | ContentPart[]. * @param requestOptions - The client `requestOptions`, sent with each call * @param inputSchema - The listed tool's input schema, for the Mcp-Param-* headers + * @param call - The `requestMeta` hook and the server id it gets. The hook + * runs before each call. A hook error fails the call. */ export function makeMcpExecute( client: Client, @@ -578,6 +617,7 @@ export function makeMcpExecute( taskRequired = false, requestOptions?: MCPClientOptions['requestOptions'], inputSchema?: McpToolDef['inputSchema'], + call?: { requestMeta?: MCPRequestMeta; serverId?: string }, ) { return async ( args: unknown, @@ -587,16 +627,24 @@ export function makeMcpExecute( askInput?: AskInput }, ) => { + const callArgs = isRecord(args) ? args : {} + const meta = await call?.requestMeta?.({ + method: 'tools/call', + serverId: call.serverId, + toolName: mcpName, + args: callArgs, + }) const result = await callMcpTool( client, mcpName, - isRecord(args) ? args : {}, + callArgs, taskRequired, ctx?.abortSignal, ctx?.inputResponse, requestOptions, inputSchema, ctx?.askInput, + meta, ) if (result.isError) { const text = Array.isArray(result.content) @@ -676,6 +724,7 @@ export function toServerTools( requiresTaskExecution(def), options.requestOptions, def.inputSchema, + { requestMeta: options.requestMeta, serverId: options.prefix }, ), } if (options.needsApproval?.(def)) { diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index b897ed610a..902adc41c2 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -1,4 +1,4 @@ -import type { ServerTool, ToolDefinition } from '@tanstack/ai' +import type { MCPRequestMeta, ServerTool, ToolDefinition } from '@tanstack/ai' import type { ClientOptions, PriorDiscovery, @@ -257,6 +257,12 @@ export interface ToolsOptions { lazy?: boolean /** Cancels the `tools/list` requests. */ signal?: AbortSignal + /** + * Called before each call of a returned tool. Its result is sent as the + * `tools/call` `_meta`. Each `tools()` call builds new tools, so a hook + * passed per request stays with that request. + */ + requestMeta?: MCPRequestMeta } /** diff --git a/packages/ai-mcp/tests/apps/call-handler.test.ts b/packages/ai-mcp/tests/apps/call-handler.test.ts index b555fefdb8..2d894f41a9 100644 --- a/packages/ai-mcp/tests/apps/call-handler.test.ts +++ b/packages/ai-mcp/tests/apps/call-handler.test.ts @@ -519,6 +519,25 @@ describe('createMcpAppCallHandler', () => { expect(callToolMock).toHaveBeenCalledWith('place_order', {}) }) + it('forwards meta to the proxied tools/call', async () => { + const meta = { tenant: 'acme', traceparent: '00-abc-def-01' } + const res = await weatherPoolHandler()( + { threadId: 't1', serverId: 'weather', toolName: 'place_order' }, + { meta }, + ) + expect(res).toEqual({ ok: true, result: expect.anything() }) + expect(callToolMock).toHaveBeenCalledWith( + 'place_order', + {}, + { meta: { tenant: 'acme', traceparent: '00-abc-def-01' } }, + ) + // The connect does not get the meta. Only the tools/call does. + expect(createMCPClient).toHaveBeenCalledWith({ + transport: { type: 'http', url: 'https://x/mcp' }, + prefix: 'weather', + }) + }) + it('rejects a client whose descriptor has no reconnectable transport', async () => { const handler = createMcpAppCallHandler({ // Built from a raw Transport: getInfo().transport is undefined. diff --git a/packages/ai-mcp/tests/client-request-meta.test.ts b/packages/ai-mcp/tests/client-request-meta.test.ts new file mode 100644 index 0000000000..6ccabbaaf9 --- /dev/null +++ b/packages/ai-mcp/tests/client-request-meta.test.ts @@ -0,0 +1,161 @@ +import { InMemoryTransport } from '@modelcontextprotocol/client' +import { Server } from '@modelcontextprotocol/server' +import { describe, expect, it } from 'vitest' +import { createMCPClientFromTransport } from '../src/client' +import { requestMetaParams } from '../src/tools' +import { makeServerWithTaskRequiredTool } from './helpers/in-memory-server' +import type { JSONRPCMessage } from '@modelcontextprotocol/client' + +// A raw server that answers tools/call, resources/read, and prompts/get. +// It keeps the `_meta` of the last request for each method. +async function makeMetaRecordingServer() { + const seen = new Map() + const server = new Server( + { name: 'meta', version: '1.0.0' }, + { capabilities: { tools: {}, resources: {}, prompts: {} } }, + ) + server.setRequestHandler('tools/list', () => ({ + tools: [{ name: 'echo', inputSchema: { type: 'object' as const } }], + })) + server.setRequestHandler('tools/call', (request) => { + seen.set('tools/call', request.params._meta) + return { content: [{ type: 'text' as const, text: 'ok' }] } + }) + server.setRequestHandler('resources/read', (request) => { + seen.set('resources/read', request.params._meta) + return { contents: [{ uri: request.params.uri, text: 'ok' }] } + }) + server.setRequestHandler('prompts/get', (request) => { + seen.set('prompts/get', request.params._meta) + return { messages: [] } + }) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { clientTransport, seen } +} + +// Keeps every message the client sends, so a test can read the wire params. +function recordSent(transport: InMemoryTransport) { + const sent: Array = [] + const send = transport.send.bind(transport) + transport.send = (message, options) => { + sent.push(message) + return send(message, options) + } + return sent +} + +function sentParams(sent: Array, method: string) { + const message = sent.find( + (entry) => 'method' in entry && entry.method === method, + ) + return message !== undefined && 'params' in message + ? message.params + : undefined +} + +const meta = { 'acme.com/tenant': 't1', traceId: 'abc' } + +describe('per-call request meta', () => { + it('callTool sends meta as params._meta', async () => { + const { clientTransport, seen } = await makeMetaRecordingServer() + await using client = await createMCPClientFromTransport(clientTransport) + await client.callTool('echo', {}, { meta }) + expect(seen.get('tools/call')).toMatchObject(meta) + }) + + it('readResource sends meta', async () => { + const { clientTransport, seen } = await makeMetaRecordingServer() + await using client = await createMCPClientFromTransport(clientTransport) + await client.readResource('file:///a', { meta }) + expect(seen.get('resources/read')).toMatchObject(meta) + }) + + it('getPrompt sends meta', async () => { + const { clientTransport, seen } = await makeMetaRecordingServer() + await using client = await createMCPClientFromTransport(clientTransport) + await client.getPrompt('p', undefined, { meta }) + expect(seen.get('prompts/get')).toMatchObject(meta) + }) + + // Read on the wire: the server SDK lifts envelope keys out of `_meta` + // before a handler runs, so a handler cannot see them. + it('drops reserved keys', async () => { + const { clientTransport } = await makeMetaRecordingServer() + const sent = recordSent(clientTransport) + await using client = await createMCPClientFromTransport(clientTransport) + await client.callTool( + 'echo', + {}, + { + meta: { + progressToken: 9, + 'io.modelcontextprotocol/protocolVersion': 'x', + 'mcp.dev/a': 1, + 'api.modelcontextprotocol.org/b': 4, + 'acme.com/mcp-thing': 2, + 'mcp/x': 3, + tenant: 't', + }, + }, + ) + const received = sentParams(sent, 'tools/call')?._meta + expect(received).toMatchObject({ + 'acme.com/mcp-thing': 2, + 'mcp/x': 3, + tenant: 't', + }) + expect(received).not.toHaveProperty(['mcp.dev/a']) + expect(received).not.toHaveProperty(['api.modelcontextprotocol.org/b']) + expect(received).not.toHaveProperty( + ['io.modelcontextprotocol/protocolVersion'], + 'x', + ) + expect(received).not.toHaveProperty('progressToken', 9) + }) + + it('sends no user keys without meta', async () => { + const { clientTransport, seen } = await makeMetaRecordingServer() + await using client = await createMCPClientFromTransport(clientTransport) + await client.callTool('echo', {}) + await client.readResource('file:///a') + await client.getPrompt('p') + // The SDK may add its own keys. None of the test keys may appear. + for (const method of ['tools/call', 'resources/read', 'prompts/get']) { + expect(seen.get(method) ?? {}).not.toHaveProperty('tenant') + expect(seen.get(method) ?? {}).not.toHaveProperty(['acme.com/tenant']) + } + }) + + it('task-required tool sends meta on the task tools/call', async () => { + const { clientTransport } = await makeServerWithTaskRequiredTool() + const sent = recordSent(clientTransport) + await using client = await createMCPClientFromTransport(clientTransport) + const result = await client.callTool( + 'research_task', + { query: 'q' }, + { meta }, + ) + expect(result.content).toEqual([ + { type: 'text', text: 'Research complete: q' }, + ]) + expect(sentParams(sent, 'tools/call')).toMatchObject({ + name: 'research_task', + task: {}, + _meta: meta, + }) + }) +}) + +describe('requestMetaParams', () => { + it.each([ + ['undefined', undefined], + ['null', null], + ['an array', []], + ['an empty object', {}], + ['only reserved keys', { progressToken: 1 }], + ])('gives no _meta for %s', (_label, input) => { + expect(requestMetaParams(input)).toEqual({}) + }) +}) diff --git a/packages/ai-mcp/tests/pool.test.ts b/packages/ai-mcp/tests/pool.test.ts index a6344249a2..2df970b49e 100644 --- a/packages/ai-mcp/tests/pool.test.ts +++ b/packages/ai-mcp/tests/pool.test.ts @@ -151,6 +151,59 @@ describe('createMCPClients', () => { expect(err.cause).toBeUndefined() }) + it('readResource routes the read to the server whose prefix matches serverId', async () => { + const alpha = await makeNamedResourceServer('alpha') + const beta = await makeNamedResourceServer('beta') + await using pool = await createMCPClients({ + alpha: { transport: alpha.clientTransport }, + beta: { transport: beta.clientTransport }, + }) + const read = await pool.readResource('ui://w', { serverId: 'beta' }) + expect(read.contents[0]).toMatchObject({ uri: 'ui://w', text: 'beta' }) + expect(alpha.reads).toEqual([]) + }) + + it('readResource passes meta to the owner only', async () => { + const alpha = await makeNamedResourceServer('alpha') + const beta = await makeNamedResourceServer('beta') + await using pool = await createMCPClients({ + alpha: { transport: alpha.clientTransport }, + beta: { transport: beta.clientTransport }, + }) + await pool.readResource('ui://w', { serverId: 'beta', meta: readMeta }) + expect(beta.reads).toHaveLength(1) + expect(beta.reads[0]).toMatchObject(readMeta) + expect(alpha.reads).toEqual([]) + }) + + it('readResource tries each server, with meta, when serverId matches no client', async () => { + const alpha = await makeNamedResourceServer('alpha') + const beta = await makeNamedResourceServer('beta') + await using pool = await createMCPClients({ + alpha: { transport: alpha.clientTransport }, + beta: { transport: beta.clientTransport }, + }) + const read = await pool.readResource('ui://w', { + serverId: 'nope', + meta: readMeta, + }) + expect(read.contents[0]).toMatchObject({ uri: 'ui://w', text: 'alpha' }) + expect(alpha.reads[0]).toMatchObject(readMeta) + }) + + it('readResource rejects with the owner error and does not try other servers', async () => { + const alpha = await makeNamedResourceServer('alpha') + const beta = await makeNamedResourceServer('beta', { fail: true }) + await using pool = await createMCPClients({ + alpha: { transport: alpha.clientTransport }, + beta: { transport: beta.clientTransport }, + }) + await expect( + pool.readResource('ui://w', { serverId: 'beta' }), + ).rejects.toThrow(/beta failed/) + expect(alpha.reads).toEqual([]) + }) + it('getServers() returns each server descriptor keyed by config key', async () => { const a = await makeServerWithWeatherTool() const b = await makeServerWithWeatherTool() @@ -252,6 +305,30 @@ describe('createMCPClients', () => { }) }) +const readMeta = { 'acme.com/tenant': 't1' } + +// A raw server that serves every uri and puts its own name in the text. +// It keeps the `_meta` of each resources/read that it gets. +async function makeNamedResourceServer( + name: string, + options?: { fail?: boolean }, +) { + const reads: Array = [] + const server = new Server( + { name, version: '1.0.0' }, + { capabilities: { resources: {} } }, + ) + server.setRequestHandler('resources/read', (request) => { + reads.push(request.params._meta) + if (options?.fail) throw new Error(`${name} failed`) + return { contents: [{ uri: request.params.uri, text: name }] } + }) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { clientTransport, reads } +} + // The server has no public setter for the negotiated era, so the test sets it. async function makeModernWeatherTransport() { const [clientTransport, serverTransport] = diff --git a/packages/ai-mcp/tests/tools-request-meta.test.ts b/packages/ai-mcp/tests/tools-request-meta.test.ts new file mode 100644 index 0000000000..b03f25e519 --- /dev/null +++ b/packages/ai-mcp/tests/tools-request-meta.test.ts @@ -0,0 +1,178 @@ +import { InMemoryTransport } from '@modelcontextprotocol/client' +import { Server } from '@modelcontextprotocol/server' +import { toolDefinition } from '@tanstack/ai' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { createMCPClientFromTransport } from '../src/client' +import { createMCPClients } from '../src/pool' +import type { MCPRequestMeta, MCPRequestMetaInput } from '@tanstack/ai' + +interface CallRecord { + who: unknown + meta: unknown +} + +// A raw server with one `echo` tool. It keeps the `who` arg and the `_meta` +// of each tools/call it gets. +async function makeRecordingServer() { + const calls: Array = [] + const server = new Server( + { name: 'meta', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ) + server.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'echo', + inputSchema: { + type: 'object' as const, + properties: { who: { type: 'string' } }, + }, + }, + ], + })) + server.setRequestHandler('tools/call', (request) => { + calls.push({ + who: request.params.arguments?.who, + meta: request.params._meta, + }) + return { content: [{ type: 'text' as const, text: 'ok' }] } + }) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { clientTransport, calls } +} + +// A hook that keeps each input it gets and returns `meta`. +function recordingHook(meta: Record) { + const inputs: Array = [] + const hook: MCPRequestMeta = (request) => { + inputs.push(request) + return meta + } + return { hook, inputs } +} + +const echoDef = toolDefinition({ + name: 'echo', + description: 'Echo', + inputSchema: z.object({ who: z.string() }), +}) + +describe('tools({ requestMeta })', () => { + it('auto-discovered tool sends hook meta', async () => { + const { clientTransport, calls } = await makeRecordingServer() + await using client = await createMCPClientFromTransport( + clientTransport, + 'store', + ) + const { hook, inputs } = recordingHook({ 'acme.com/tenant': 't1' }) + const [tool] = await client.tools({ requestMeta: hook }) + await tool!.execute!({ who: 'a' }) + expect(inputs).toEqual([ + { + method: 'tools/call', + serverId: 'store', + toolName: 'echo', + args: { who: 'a' }, + }, + ]) + expect(calls[0]?.meta).toMatchObject({ 'acme.com/tenant': 't1' }) + }) + + it('explicit tools(defs) tool sends hook meta', async () => { + const { clientTransport, calls } = await makeRecordingServer() + await using client = await createMCPClientFromTransport( + clientTransport, + 'store', + ) + const { hook, inputs } = recordingHook({ tenant: 't2' }) + const [tool] = await client.tools([echoDef], { requestMeta: hook }) + await tool.execute!({ who: 'b' }) + expect(inputs).toEqual([ + { + method: 'tools/call', + serverId: 'store', + toolName: 'echo', + args: { who: 'b' }, + }, + ]) + expect(calls[0]?.meta).toMatchObject({ tenant: 't2' }) + }) + + it('lazy tools send hook meta', async () => { + const { clientTransport, calls } = await makeRecordingServer() + await using client = await createMCPClientFromTransport(clientTransport) + const { hook } = recordingHook({ tenant: 't3' }) + const [tool] = await client.tools({ lazy: true, requestMeta: hook }) + expect(tool!.lazy).toBe(true) + await tool!.execute!({ who: 'c' }) + expect(calls[0]?.meta).toMatchObject({ tenant: 't3' }) + }) + + it('pool.tools({ requestMeta }) calls the hook with each server id', async () => { + const alpha = await makeRecordingServer() + const beta = await makeRecordingServer() + await using pool = await createMCPClients({ + alpha: { transport: alpha.clientTransport }, + beta: { transport: beta.clientTransport }, + }) + const tools = await pool.tools({ + requestMeta: (request) => ({ server: request.serverId }), + }) + for (const tool of tools) await tool.execute!({ who: 'd' }) + expect(alpha.calls.map((call) => call.meta)).toMatchObject([ + { server: 'alpha' }, + ]) + expect(beta.calls.map((call) => call.meta)).toMatchObject([ + { server: 'beta' }, + ]) + }) + + it('two tools() calls with different hooks stay separate', async () => { + const { clientTransport, calls } = await makeRecordingServer() + await using client = await createMCPClientFromTransport(clientTransport) + // Each hook waits, so both calls are in flight at the same time. + const delayedHook = + (tenant: string): MCPRequestMeta => + async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + return { tenant } + } + const [runA] = await client.tools({ requestMeta: delayedHook('a') }) + const [runB] = await client.tools({ requestMeta: delayedHook('b') }) + await Promise.all([ + runA!.execute!({ who: 'a' }), + runB!.execute!({ who: 'b' }), + runA!.execute!({ who: 'a' }), + runB!.execute!({ who: 'b' }), + ]) + expect(calls).toHaveLength(4) + for (const call of calls) { + expect(call.meta).toMatchObject({ tenant: call.who }) + } + }) + + it('hook rejection fails the tool call with that error', async () => { + const { clientTransport, calls } = await makeRecordingServer() + await using client = await createMCPClientFromTransport(clientTransport) + const failure = new Error('no tenant') + const [tool] = await client.tools({ + requestMeta: () => Promise.reject(failure), + }) + await expect(tool!.execute!({ who: 'e' })).rejects.toBe(failure) + expect(calls).toEqual([]) + }) + + it('sends no user meta keys without a hook', async () => { + const { clientTransport, calls } = await makeRecordingServer() + await using client = await createMCPClientFromTransport(clientTransport) + const [tool] = await client.tools() + await tool!.execute!({ who: 'f' }) + expect(calls).toHaveLength(1) + // The SDK may add its own keys. No test key may appear. + expect(calls[0]?.meta ?? {}).not.toHaveProperty('tenant') + expect(calls[0]?.meta ?? {}).not.toHaveProperty('server') + }) +}) diff --git a/packages/ai/src/activities/chat/mcp/manager.ts b/packages/ai/src/activities/chat/mcp/manager.ts index ec0583e017..a9d34d243f 100644 --- a/packages/ai/src/activities/chat/mcp/manager.ts +++ b/packages/ai/src/activities/chat/mcp/manager.ts @@ -1,9 +1,21 @@ import type { AnyServerTool } from '../tools/tool-definition' -import type { ChatMCPOptions, MCPToolSource } from './types' +import type { + ChatMCPOptions, + MCPRequestMeta, + MCPToolSource, + McpResourceReadResult, +} from './types' /** - * Bind the source's `readResource` onto a ui-linked tool's `metadata.mcp` so it - * travels with the tool to the server-tool execution/emit site (`tool-calls.ts`). + * Bind a read of the source's resources onto a ui-linked tool's `metadata.mcp` + * so it travels with the tool to the server-tool execution/emit site + * (`tool-calls.ts`). + * + * The bound read sends the tool's `serverId` to the source. When the run has a + * `requestMeta` hook, the read first calls the hook with + * `{ method: 'resources/read', serverId, uri }` and sends the result as `meta`. + * If the hook throws or rejects, the bound read rejects. The emit site fails + * soft on that, the same as on any other read error. * * `discover()` is the single place in `@tanstack/ai` that has both a tool and * its originating source, and `@tanstack/ai` must not import `@tanstack/ai-mcp`, @@ -18,14 +30,37 @@ import type { ChatMCPOptions, MCPToolSource } from './types' * drains. If discovery results were ever cached and reused across runs, this * would bind a closure over an already-closed source — bind onto a copy then. */ -function bindReadResource(tool: AnyServerTool, source: MCPToolSource): void { +function bindReadResource( + tool: AnyServerTool, + source: MCPToolSource, + requestMeta: MCPRequestMeta | undefined, +): void { if (!source.readResource) return - const meta = ( - tool.metadata as { mcp?: { uiResourceUri?: string } } | undefined + const mcp = ( + tool.metadata as + | { + mcp?: { + uiResourceUri?: string + serverId?: string + readResource?: (uri: string) => Promise + } + } + | undefined )?.mcp - if (!meta?.uiResourceUri) return - ;(meta as { readResource?: MCPToolSource['readResource'] }).readResource = - source.readResource.bind(source) + if (!mcp?.uiResourceUri) return + const read = source.readResource.bind(source) + const { serverId } = mcp + mcp.readResource = async (uri) => { + const meta = await requestMeta?.({ + method: 'resources/read', + serverId, + uri, + }) + return read(uri, { + ...(serverId !== undefined && { serverId }), + ...(meta && { meta }), + }) + } } export class MCPDuplicateToolNameError extends Error { @@ -53,6 +88,7 @@ export class MCPManager { readonly #sources: ReadonlyArray readonly #shouldClose: boolean readonly #lazyTools: boolean + readonly #requestMeta?: MCPRequestMeta readonly #onDiscoveryError?: ( error: unknown, source: MCPToolSource, @@ -63,6 +99,7 @@ export class MCPManager { // default 'close'; only 'keep-alive' disables closing this.#shouldClose = options ? options.connection !== 'keep-alive' : false this.#lazyTools = options?.lazyTools ?? false + this.#requestMeta = options?.requestMeta this.#onDiscoveryError = options?.onDiscoveryError } @@ -70,14 +107,20 @@ export class MCPManager { * 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'. - * `signal` (the chat run signal) goes to each source's `tools()`. + * `signal` (the chat run signal) and `requestMeta` (when set) go to each + * source's `tools()`. */ async discover(signal?: AbortSignal): Promise> { if (this.#sources.length === 0) return [] + const requestMeta = this.#requestMeta try { const settled = await Promise.allSettled( this.#sources.map((s) => - s.tools({ lazy: this.#lazyTools, ...(signal && { signal }) }), + s.tools({ + lazy: this.#lazyTools, + ...(signal && { signal }), + ...(requestMeta && { requestMeta }), + }), ), ) const tools: Array = [] @@ -88,7 +131,7 @@ export class MCPManager { if (result === undefined) continue if (result.status === 'fulfilled') { for (const t of result.value) { - bindReadResource(t, source) + bindReadResource(t, source, requestMeta) tools.push(t) } } else if (this.#onDiscoveryError) { diff --git a/packages/ai/src/activities/chat/mcp/types.ts b/packages/ai/src/activities/chat/mcp/types.ts index 713be05dcb..1b05bea94a 100644 --- a/packages/ai/src/activities/chat/mcp/types.ts +++ b/packages/ai/src/activities/chat/mcp/types.ts @@ -15,6 +15,40 @@ export interface McpResourceReadResult { }> } +/** + * The MCP request that an `MCPRequestMeta` hook is about to send. + * + * - `tools/call`: `toolName` is the server name of the tool (no prefix). + * - `resources/read`: `uri` is the resource that is read. + * + * `serverId` is the client prefix, or `undefined` when the client has none. + */ +export type MCPRequestMetaInput = + | { + method: 'tools/call' + serverId: string | undefined + toolName: string + args: Record + } + | { method: 'resources/read'; serverId: string | undefined; uri: string } + +/** + * Gives the `_meta` for one MCP request. + * + * The hook runs once for each MCP request, when the request executes. Its + * result is sent as the request `_meta`. Return `undefined` to send no + * `_meta`. `@tanstack/ai-mcp` drops the keys that MCP reserves + * (`progressToken` and the `io.modelcontextprotocol/` style prefixes). + * + * If the hook throws or rejects, that one request fails with the error. + */ +export type MCPRequestMeta = ( + request: MCPRequestMetaInput, +) => + | Record + | undefined + | Promise | undefined> + /** * Minimal structural shape that `chat({ mcp })` needs from an MCP client. * @@ -29,20 +63,28 @@ export interface MCPToolSource { /** * Lists the tools. `chat()` passes the run's `signal` so that a cancelled * run also cancels discovery. A source that ignores `signal` still works. + * `chat()` passes `requestMeta` only when the caller set it. */ tools: (options?: { lazy?: boolean signal?: AbortSignal + requestMeta?: MCPRequestMeta }) => Promise> close: () => Promise /** * Reads an MCP resource by URI. Used by the chat manager to eagerly fetch * `ui://` resource widgets (MCP Apps) after a tool result resolves. * + * `options.serverId` is the server that owns the tool. `options.meta` is + * the `_meta` to send with the read. + * * Optional — sources that do not serve `ui://` resources need not implement * this method. `ai-mcp`'s `MCPClient` satisfies this structurally. */ - readResource?: (uri: string) => Promise + readResource?: ( + uri: string, + options?: { serverId?: string; meta?: Record }, + ) => Promise } /** @@ -80,6 +122,16 @@ export interface ChatMCPOptions { */ lazyTools?: boolean + /** + * Gives the `_meta` for each MCP request in this chat run. It covers tool + * calls and the MCP Apps `ui://` resource reads. + * + * The hook runs when the request executes, not at discovery. Meta is not + * part of the tool cache key: a `toolCacheMiddleware` hit sends no request, + * so the hook does not run. + */ + requestMeta?: MCPRequestMeta + /** * Called when tool discovery fails for a single source. * diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 4d2bf58631..35cd356d98 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -190,6 +190,8 @@ export { // MCP chat option types export type { MCPToolSource, + MCPRequestMeta, + MCPRequestMetaInput, ChatMCPOptions, MCPConnectionPolicy, } from './activities/chat/mcp/types' diff --git a/packages/ai/tests/chat-mcp-manager.test.ts b/packages/ai/tests/chat-mcp-manager.test.ts index d47d37a386..208f32ed9c 100644 --- a/packages/ai/tests/chat-mcp-manager.test.ts +++ b/packages/ai/tests/chat-mcp-manager.test.ts @@ -8,7 +8,7 @@ import { type ToolResult, } from '../src/activities/chat/tools/tool-calls' import { EventType } from '../src/types' -import type { AnyServerTool } from '../src' +import type { AnyServerTool, MCPRequestMeta } from '../src' import type { CustomEvent, ToolCall, @@ -103,6 +103,21 @@ describe('MCPManager', () => { expect(spyA).toHaveBeenCalledWith({ lazy: false }) }) + it('discover() forwards requestMeta to every source', async () => { + const a = source([tool('a')]) + const spyA = vi.spyOn(a, 'tools') + const requestMeta = () => ({ 'acme.com/t': 1 }) + await MCPManager.from({ clients: [a], requestMeta }).discover() + expect(spyA).toHaveBeenCalledWith({ lazy: false, requestMeta }) + }) + + it('discover() without requestMeta sends no requestMeta key', async () => { + const a = source([tool('a')]) + const spyA = vi.spyOn(a, 'tools') + await MCPManager.from({ clients: [a] }).discover() + expect(spyA.mock.calls[0]?.[0]).not.toHaveProperty('requestMeta') + }) + it('discover() throws MCPDuplicateToolNameError on collision', async () => { const m = MCPManager.from({ clients: [source([tool('x')]), source([tool('x')])], @@ -284,6 +299,66 @@ describe('MCPManager.discover — ui:// readResource binding', () => { }) }) +/** + * Discover one ui-linked tool from server `store` and return its bound + * `readResource`, plus the source read spy that the bound read calls. + */ +async function discoverStoreRead(requestMeta?: MCPRequestMeta) { + const sourceRead = vi.fn( + async ( + _uri: string, + _options?: { serverId?: string; meta?: Record }, + ) => ({ contents: [] }), + ) + const t = uiTool('store_show') + t.metadata.mcp = { uiResourceUri: 'ui://w', serverId: 'store' } + const m = MCPManager.from({ + clients: [ + { + tools: async () => [t], + close: async () => {}, + readResource: sourceRead, + }, + ], + requestMeta, + }) + const read = readDiscoveredMcpMeta((await m.discover())[0])?.readResource + if (!read) throw new Error('readResource was not bound') + return { read, sourceRead } +} + +describe('MCPManager.discover — readResource with requestMeta', () => { + it('bound readResource calls the hook and passes serverId and meta', async () => { + const hook = vi.fn(() => ({ 'acme.com/t': 1 })) + const { read, sourceRead } = await discoverStoreRead(hook) + await read('ui://w') + expect(hook).toHaveBeenCalledWith({ + method: 'resources/read', + serverId: 'store', + uri: 'ui://w', + }) + expect(sourceRead).toHaveBeenCalledWith('ui://w', { + serverId: 'store', + meta: { 'acme.com/t': 1 }, + }) + }) + + it('bound readResource without a hook passes serverId only', async () => { + const { read, sourceRead } = await discoverStoreRead() + await read('ui://w') + expect(sourceRead).toHaveBeenCalledWith('ui://w', { serverId: 'store' }) + expect(sourceRead.mock.calls[0]?.[1]).not.toHaveProperty('meta') + }) + + it('hook rejection rejects the bound read', async () => { + const { read, sourceRead } = await discoverStoreRead(async () => { + throw new Error('meta failed') + }) + await expect(read('ui://w')).rejects.toThrow('meta failed') + expect(sourceRead).not.toHaveBeenCalled() + }) +}) + describe('executeServerTool — ui:// resource emit (MCP Apps)', () => { it('emits a ui-resource CUSTOM event when a tool links a ui:// resource', async () => { const emitted: Array = [] diff --git a/testing/e2e/src/lib/mcp-meta-log.ts b/testing/e2e/src/lib/mcp-meta-log.ts new file mode 100644 index 0000000000..ef27db14dd --- /dev/null +++ b/testing/e2e/src/lib/mcp-meta-log.ts @@ -0,0 +1,16 @@ +/** + * The `_meta` that the request-meta MCP server got, in order. The server + * route and the wire route run in the same dev-server process, so a + * module-level array is enough. + */ +const records: Array<{ method: string; meta: unknown }> = [] + +/** Adds the `_meta` of one MCP request. */ +export function recordMeta(method: string, meta: unknown) { + records.push({ method, meta }) +} + +/** Returns all records and clears the log. */ +export function takeMeta() { + return records.splice(0) +} diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 31c7217dd5..f77976d93e 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -138,8 +138,10 @@ import { Route as ApiMcpInputTestRouteImport } from './routes/api.mcp-input-test import { Route as ApiMcpLegacyInputServerRouteImport } from './routes/api.mcp-legacy-input-server' import { Route as ApiMcpLifecycleTestRouteImport } from './routes/api.mcp-lifecycle-test' import { Route as ApiMcpManagedTestRouteImport } from './routes/api.mcp-managed-test' +import { Route as ApiMcpMetaServerRouteImport } from './routes/api.mcp-meta-server' import { Route as ApiMcpNoTasksServerRouteImport } from './routes/api.mcp-no-tasks-server' import { Route as ApiMcpPolicyTestRouteImport } from './routes/api.mcp-policy-test' +import { Route as ApiMcpRequestMetaWireRouteImport } from './routes/api.mcp-request-meta-wire' import { Route as ApiMcpServerRouteImport } from './routes/api.mcp-server' import { Route as ApiMcpStatusTestRouteImport } from './routes/api.mcp-status-test' import { Route as ApiMcpTaskErrorsRouteImport } from './routes/api.mcp-task-errors' @@ -892,6 +894,11 @@ const ApiMcpManagedTestRoute = ApiMcpManagedTestRouteImport.update({ path: '/api/mcp-managed-test', getParentRoute: () => rootRouteImport, } as any) +const ApiMcpMetaServerRoute = ApiMcpMetaServerRouteImport.update({ + id: '/api/mcp-meta-server', + path: '/api/mcp-meta-server', + getParentRoute: () => rootRouteImport, +} as any) const ApiMcpNoTasksServerRoute = ApiMcpNoTasksServerRouteImport.update({ id: '/api/mcp-no-tasks-server', path: '/api/mcp-no-tasks-server', @@ -902,6 +909,11 @@ const ApiMcpPolicyTestRoute = ApiMcpPolicyTestRouteImport.update({ path: '/api/mcp-policy-test', getParentRoute: () => rootRouteImport, } as any) +const ApiMcpRequestMetaWireRoute = ApiMcpRequestMetaWireRouteImport.update({ + id: '/api/mcp-request-meta-wire', + path: '/api/mcp-request-meta-wire', + getParentRoute: () => rootRouteImport, +} as any) const ApiMcpServerRoute = ApiMcpServerRouteImport.update({ id: '/api/mcp-server', path: '/api/mcp-server', @@ -1452,8 +1464,10 @@ export interface FileRoutesByFullPath { '/api/mcp-legacy-input-server': typeof ApiMcpLegacyInputServerRoute '/api/mcp-lifecycle-test': typeof ApiMcpLifecycleTestRoute '/api/mcp-managed-test': typeof ApiMcpManagedTestRoute + '/api/mcp-meta-server': typeof ApiMcpMetaServerRoute '/api/mcp-no-tasks-server': typeof ApiMcpNoTasksServerRoute '/api/mcp-policy-test': typeof ApiMcpPolicyTestRoute + '/api/mcp-request-meta-wire': typeof ApiMcpRequestMetaWireRoute '/api/mcp-server': typeof ApiMcpServerRoute '/api/mcp-status-test': typeof ApiMcpStatusTestRoute '/api/mcp-task-errors': typeof ApiMcpTaskErrorsRoute @@ -1664,8 +1678,10 @@ export interface FileRoutesByTo { '/api/mcp-legacy-input-server': typeof ApiMcpLegacyInputServerRoute '/api/mcp-lifecycle-test': typeof ApiMcpLifecycleTestRoute '/api/mcp-managed-test': typeof ApiMcpManagedTestRoute + '/api/mcp-meta-server': typeof ApiMcpMetaServerRoute '/api/mcp-no-tasks-server': typeof ApiMcpNoTasksServerRoute '/api/mcp-policy-test': typeof ApiMcpPolicyTestRoute + '/api/mcp-request-meta-wire': typeof ApiMcpRequestMetaWireRoute '/api/mcp-server': typeof ApiMcpServerRoute '/api/mcp-status-test': typeof ApiMcpStatusTestRoute '/api/mcp-task-errors': typeof ApiMcpTaskErrorsRoute @@ -1877,8 +1893,10 @@ export interface FileRoutesById { '/api/mcp-legacy-input-server': typeof ApiMcpLegacyInputServerRoute '/api/mcp-lifecycle-test': typeof ApiMcpLifecycleTestRoute '/api/mcp-managed-test': typeof ApiMcpManagedTestRoute + '/api/mcp-meta-server': typeof ApiMcpMetaServerRoute '/api/mcp-no-tasks-server': typeof ApiMcpNoTasksServerRoute '/api/mcp-policy-test': typeof ApiMcpPolicyTestRoute + '/api/mcp-request-meta-wire': typeof ApiMcpRequestMetaWireRoute '/api/mcp-server': typeof ApiMcpServerRoute '/api/mcp-status-test': typeof ApiMcpStatusTestRoute '/api/mcp-task-errors': typeof ApiMcpTaskErrorsRoute @@ -2091,8 +2109,10 @@ export interface FileRouteTypes { | '/api/mcp-legacy-input-server' | '/api/mcp-lifecycle-test' | '/api/mcp-managed-test' + | '/api/mcp-meta-server' | '/api/mcp-no-tasks-server' | '/api/mcp-policy-test' + | '/api/mcp-request-meta-wire' | '/api/mcp-server' | '/api/mcp-status-test' | '/api/mcp-task-errors' @@ -2303,8 +2323,10 @@ export interface FileRouteTypes { | '/api/mcp-legacy-input-server' | '/api/mcp-lifecycle-test' | '/api/mcp-managed-test' + | '/api/mcp-meta-server' | '/api/mcp-no-tasks-server' | '/api/mcp-policy-test' + | '/api/mcp-request-meta-wire' | '/api/mcp-server' | '/api/mcp-status-test' | '/api/mcp-task-errors' @@ -2515,8 +2537,10 @@ export interface FileRouteTypes { | '/api/mcp-legacy-input-server' | '/api/mcp-lifecycle-test' | '/api/mcp-managed-test' + | '/api/mcp-meta-server' | '/api/mcp-no-tasks-server' | '/api/mcp-policy-test' + | '/api/mcp-request-meta-wire' | '/api/mcp-server' | '/api/mcp-status-test' | '/api/mcp-task-errors' @@ -2728,8 +2752,10 @@ export interface RootRouteChildren { ApiMcpLegacyInputServerRoute: typeof ApiMcpLegacyInputServerRoute ApiMcpLifecycleTestRoute: typeof ApiMcpLifecycleTestRoute ApiMcpManagedTestRoute: typeof ApiMcpManagedTestRoute + ApiMcpMetaServerRoute: typeof ApiMcpMetaServerRoute ApiMcpNoTasksServerRoute: typeof ApiMcpNoTasksServerRoute ApiMcpPolicyTestRoute: typeof ApiMcpPolicyTestRoute + ApiMcpRequestMetaWireRoute: typeof ApiMcpRequestMetaWireRoute ApiMcpServerRoute: typeof ApiMcpServerRoute ApiMcpStatusTestRoute: typeof ApiMcpStatusTestRoute ApiMcpTaskErrorsRoute: typeof ApiMcpTaskErrorsRoute @@ -3712,6 +3738,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiMcpManagedTestRouteImport parentRoute: typeof rootRouteImport } + '/api/mcp-meta-server': { + id: '/api/mcp-meta-server' + path: '/api/mcp-meta-server' + fullPath: '/api/mcp-meta-server' + preLoaderRoute: typeof ApiMcpMetaServerRouteImport + parentRoute: typeof rootRouteImport + } '/api/mcp-no-tasks-server': { id: '/api/mcp-no-tasks-server' path: '/api/mcp-no-tasks-server' @@ -3726,6 +3759,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiMcpPolicyTestRouteImport parentRoute: typeof rootRouteImport } + '/api/mcp-request-meta-wire': { + id: '/api/mcp-request-meta-wire' + path: '/api/mcp-request-meta-wire' + fullPath: '/api/mcp-request-meta-wire' + preLoaderRoute: typeof ApiMcpRequestMetaWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/mcp-server': { id: '/api/mcp-server' path: '/api/mcp-server' @@ -4474,8 +4514,10 @@ const rootRouteChildren: RootRouteChildren = { ApiMcpLegacyInputServerRoute: ApiMcpLegacyInputServerRoute, ApiMcpLifecycleTestRoute: ApiMcpLifecycleTestRoute, ApiMcpManagedTestRoute: ApiMcpManagedTestRoute, + ApiMcpMetaServerRoute: ApiMcpMetaServerRoute, ApiMcpNoTasksServerRoute: ApiMcpNoTasksServerRoute, ApiMcpPolicyTestRoute: ApiMcpPolicyTestRoute, + ApiMcpRequestMetaWireRoute: ApiMcpRequestMetaWireRoute, ApiMcpServerRoute: ApiMcpServerRoute, ApiMcpStatusTestRoute: ApiMcpStatusTestRoute, ApiMcpTaskErrorsRoute: ApiMcpTaskErrorsRoute, diff --git a/testing/e2e/src/routes/api.mcp-meta-server.ts b/testing/e2e/src/routes/api.mcp-meta-server.ts new file mode 100644 index 0000000000..20eb0cbd33 --- /dev/null +++ b/testing/e2e/src/routes/api.mcp-meta-server.ts @@ -0,0 +1,84 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' +import { recordMeta } from '@/lib/mcp-meta-log' + +const WIDGET_URI = 'ui://meta/widget' + +/** + * Streamable HTTP MCP server for the request `_meta` wire spec. Each + * tools/call, resources/read and prompts/get handler records the `_meta` it + * got. The `echo` tool links `ui://meta/widget`, so `chat()` reads that + * resource after the tool runs. + */ +function createMetaMcpServer() { + const server = new Server( + { name: 'request-meta', version: '0.0.1' }, + { capabilities: { tools: {}, resources: {}, prompts: {} } }, + ) + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [ + { + name: 'echo', + description: 'Echo', + inputSchema: { type: 'object' as const }, + _meta: { ui: { resourceUri: WIDGET_URI } }, + }, + ], + })) + server.setRequestHandler(CallToolRequestSchema, (req) => { + recordMeta('tools/call', req.params._meta) + return { content: [{ type: 'text' as const, text: 'echo' }] } + }) + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [{ uri: WIDGET_URI, name: 'widget', mimeType: 'text/html' }], + })) + server.setRequestHandler(ReadResourceRequestSchema, (req) => { + recordMeta('resources/read', req.params._meta) + return { + contents: [{ uri: WIDGET_URI, mimeType: 'text/html', text: '

ok

' }], + } + }) + server.setRequestHandler(ListPromptsRequestSchema, () => ({ + prompts: [{ name: 'greet' }], + })) + server.setRequestHandler(GetPromptRequestSchema, (req) => { + recordMeta('prompts/get', req.params._meta) + return { + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: 'hi' }, + }, + ], + } + }) + return server +} + +async function handleMcpRequest(request: Request) { + const server = createMetaMcpServer() + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }) + await server.connect(transport) + return transport.handleRequest(request) +} + +export const Route = createFileRoute('/api/mcp-meta-server')({ + server: { + handlers: { + POST: ({ request }) => handleMcpRequest(request), + GET: ({ request }) => handleMcpRequest(request), + DELETE: ({ request }) => handleMcpRequest(request), + }, + }, +}) diff --git a/testing/e2e/src/routes/api.mcp-request-meta-wire.ts b/testing/e2e/src/routes/api.mcp-request-meta-wire.ts new file mode 100644 index 0000000000..193e36d35d --- /dev/null +++ b/testing/e2e/src/routes/api.mcp-request-meta-wire.ts @@ -0,0 +1,77 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, streamToText } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { createMCPClient, createMCPClients } from '@tanstack/ai-mcp' +import { createMcpAppCallHandler } from '@tanstack/ai-mcp/apps' +import { takeMeta } from '@/lib/mcp-meta-log' + +/** + * Sends request `_meta` to `api.mcp-meta-server` in three ways: the client + * methods, the MCP Apps call handler, and `chat({ mcp: { requestMeta } })`. + * Returns the `_meta` the server got for each way. + */ +export const Route = createFileRoute('/api/mcp-request-meta-wire')({ + server: { + handlers: { + POST: async ({ request }) => { + const transport = { + type: 'http' as const, + url: `${new URL(request.url).origin}/api/mcp-meta-server`, + } + takeMeta() + + const meta = { 'acme.com/tenant': 't1', 'mcp.dev/blocked': 1 } + const client = await createMCPClient({ transport }) + try { + await client.callTool('echo', {}, { meta }) + await client.readResource('ui://meta/widget', { meta }) + await client.getPrompt('greet', undefined, { meta }) + } finally { + await client.close() + } + const direct = takeMeta() + + // The handler reads the transport from this client and connects again + // for the call. + const appClient = await createMCPClient({ transport }) + try { + const result = await createMcpAppCallHandler({ clients: appClient })( + { threadId: 't', toolName: 'echo', args: {} }, + { meta: { 'acme.com/tenant': 't2' } }, + ) + if (!result.ok) throw new Error(result.error) + } finally { + await appClient.close() + } + const handler = takeMeta() + + // No network: the fake model calls `meta_echo` once, then answers. + const fake = fakeText() + fake.setResponses([ + { toolCalls: [{ name: 'meta_echo', input: {} }] }, + { text: 'done' }, + ]) + // chat() closes the pool when the run ends. + const pool = await createMCPClients({ meta: { transport } }) + await streamToText( + chat({ + threadId: 'request-meta', + adapter: fake, + messages: [{ role: 'user', content: 'Call echo' }], + mcp: { + clients: [pool], + requestMeta: (req) => ({ + 'acme.com/tenant': 't3', + 'acme.com/method': req.method, + 'acme.com/serverId': req.serverId, + }), + }, + }), + ) + const chatRecords = takeMeta() + + return Response.json({ direct, handler, chat: chatRecords }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/mcp-request-meta-wire.spec.ts b/testing/e2e/tests/mcp-request-meta-wire.spec.ts new file mode 100644 index 0000000000..861b8426bc --- /dev/null +++ b/testing/e2e/tests/mcp-request-meta-wire.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from './fixtures' + +test.describe('mcp: request _meta on the wire', () => { + test('sends caller meta and drops reserved keys', async ({ request }) => { + const response = await request.post('/api/mcp-request-meta-wire') + expect(response.ok()).toBe(true) + const result: unknown = await response.json() + + // The client sent 'mcp.dev/blocked' too. It is a reserved key, so the + // server must not get it. + const t1 = { 'acme.com/tenant': 't1' } + const chatMeta = (method: string) => ({ + 'acme.com/tenant': 't3', + 'acme.com/method': method, + 'acme.com/serverId': 'meta', + }) + expect(result).toEqual({ + direct: [ + { method: 'tools/call', meta: t1 }, + { method: 'resources/read', meta: t1 }, + { method: 'prompts/get', meta: t1 }, + ], + handler: [{ method: 'tools/call', meta: { 'acme.com/tenant': 't2' } }], + chat: [ + { method: 'tools/call', meta: chatMeta('tools/call') }, + { method: 'resources/read', meta: chatMeta('resources/read') }, + ], + }) + }) +})