diff --git a/.changeset/mcp-tool-annotations.md b/.changeset/mcp-tool-annotations.md new file mode 100644 index 000000000..9b1d1edaa --- /dev/null +++ b/.changeset/mcp-tool-annotations.md @@ -0,0 +1,28 @@ +--- +'@tanstack/ai-mcp': minor +--- + +Forward MCP tool annotations and titles onto discovered tools. Each tool's +`metadata.mcp` now carries the server's `annotations` object verbatim +(`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, +`annotations.title`) plus a resolved display `title` (`title` → +`annotations.title` → `name`), on both the auto-discovery and explicit +`tools([...defs])` paths. Hosts can now label MCP tools and gate approvals on +the server's hints instead of only seeing a name and description. + +The metadata is typed, not just documented. Every `tools()` overload — the +single client, the explicit `tools([...defs])` path, and the `createMCPClients` +pool — now returns `McpServerTool`s: structurally still `ServerTool`s (they drop +straight into `chat({ tools })`), but with `metadata.mcp` statically known to be +present and shaped like `McpToolMetadata`. So the read infers on its own: + +```ts +const tools = await mcp.tools() +tools.map((tool) => tool.metadata.mcp.annotations?.readOnlyHint) // boolean | undefined +tools.map((tool) => tool.metadata.mcp.annotaions) // compile error +``` + +Adds the exported `McpServerTool` and `McpToolMetadata` types, and re-exports +the SDK's `ToolAnnotations` type. `McpToolMetadata.serverToolName` and `.title` +are required (both are always stamped), so consumers no longer write a fallback +for a value that is never missing. diff --git a/docs/config.json b/docs/config.json index 9bbf14527..3f1ad5f31 100644 --- a/docs/config.json +++ b/docs/config.json @@ -126,7 +126,8 @@ { "label": "MCP Server Tools", "to": "tools/mcp", - "addedAt": "2026-06-05" + "addedAt": "2026-06-05", + "updatedAt": "2026-07-31" }, { "label": "Managed MCP with chat()", diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index ad7cf1ffb..651fce6a9 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -271,6 +271,113 @@ Run the CLI against a live server to generate per-server `interface` types, then > 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`. +## Tool Titles & Annotations + +MCP servers can ship display and behavior metadata alongside each tool: a human-readable `title` and a set of `annotations` hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, plus a legacy `annotations.title`). `@tanstack/ai-mcp` forwards all of it onto each discovered tool's `metadata.mcp`, on **both** the auto-discovery and explicit-definition paths, so you can label tools in your UI and decide which ones need a confirmation step. + +| `metadata.mcp` field | Value | +|---|---| +| `title` | `string` — display name, resolved with the spec's precedence: the tool's `title` → `annotations.title` → `name`. Always set. | +| `annotations` | The server's `annotations` object, forwarded verbatim. Absent when the server declares none. | +| `serverToolName` | `string` — server-native (unprefixed) tool name. | +| `serverId` | The client's `prefix` (undefined when there is none). | +| `uiResourceUri` | [MCP Apps](../mcp/apps) widget link, when the tool declares one. | + +The block is typed, so just read it. `tools()` returns `McpServerTool`s — a plain `ServerTool` (it still drops straight into `chat({ tools })`) whose `metadata.mcp` is statically known to be present and shaped like the table above. No annotation, no cast, and a misspelled field is a compile error: + +```ts +import { createMCPClient } from '@tanstack/ai-mcp' + +const url = 'https://my-mcp-server.example.com/mcp' + +// Trust comes from YOUR configuration — an allowlist of servers you operate or +// have vetted — never from anything the server itself sends. +const trustedServers = new Set(['https://my-mcp-server.example.com/mcp']) +const serverIsTrusted = trustedServers.has(url) + +const mcp = await createMCPClient({ transport: { type: 'http', url } }) + +const tools = (await mcp.tools()).map((tool) => { + const meta = tool.metadata.mcp + const advertisedReadOnly = meta.annotations?.readOnlyHint === true + return { + ...tool, + // Approval is the default. A hint may only relax it for a server whose + // trust you established independently; on any other server the same hint + // is a label/recommendation and changes nothing about approval. + needsApproval: !(serverIsTrusted && advertisedReadOnly), + } +}) +``` + +> **Annotations are advisory, never a security boundary.** The MCP spec is explicit that every field — including `title` — is a hint that may not faithfully describe what the tool actually does, and a malicious or compromised server can claim anything (`readOnlyHint: true` on a tool that deletes records). Do not use them as the security boundary for an untrusted server: never let a hint alone waive approval, sandboxing, or authorization. On a server you have independently established as trusted, a hint may *relax* a confirmation step, as above; everywhere else, treat annotations as display labels and recommendations only — surface `readOnlyHint` as a badge (see the UI example below) rather than acting on it. + +Titles are display-only: they never change the tool `name` sent to the model, and a `prefix` still applies to the name (`wx_get_weather`), not to the title. + +`McpToolMetadata` and `McpServerTool` are both exported if you need to name the shapes in your own signatures (`ToolAnnotations` too, re-exported from the MCP SDK). You don't need them just to read the block. + +To label tools in your UI, expose the forwarded metadata from a server route — the MCP client itself must stay server-side: + +```ts ignore +// src/routes/api.mcp-tools.ts +import { createFileRoute } from '@tanstack/react-router' +import { createMCPClient } from '@tanstack/ai-mcp' + +export const Route = createFileRoute('/api/mcp-tools')({ + server: { + handlers: { + GET: async () => { + await using mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + }) + const catalog = (await mcp.tools()).map((tool) => ({ + name: tool.name, + // `title` is always set — the fallback chain already ran. + title: tool.metadata.mcp.title, + description: tool.description, + readOnly: tool.metadata.mcp.annotations?.readOnlyHint === true, + })) + return Response.json({ tools: catalog }) + }, + }, + }, +}) +``` + +```tsx +// src/components/ToolCatalog.tsx +import { useEffect, useState } from 'react' + +interface ToolSummary { + name: string + title: string + description?: string + readOnly: boolean +} + +export function ToolCatalog() { + const [tools, setTools] = useState>([]) + + useEffect(() => { + fetch('/api/mcp-tools') + .then((res) => res.json()) + .then((body: { tools: Array }) => setTools(body.tools)) + }, []) + + return ( + + ) +} +``` + ## 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. diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index bc8345ccb..3c7db2b11 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -5,7 +5,12 @@ import { MCPTaskRequiredToolError, MCPToolNotFoundError, } from './errors' -import { makeMcpExecute, requiresTaskExecution, toServerTools } from './tools' +import { + makeMcpExecute, + requiresTaskExecution, + toolMcpMetadata, + toServerTools, +} from './tools' import { isTransportInstance, resolveTransport } from './transport' import type { TransportConfig } from './transport' import type { @@ -14,6 +19,7 @@ import type { DescriptorTools, MCPClientOptions, MappedServerTools, + McpServerTool, ServerDescriptor, ToolsOptions, } from './types' @@ -35,6 +41,9 @@ export interface MCPClient< * Auto-discovery: every server tool as a ServerTool. With a generated * descriptor, tool names are typed as the descriptor's name literals; * args/results stay untyped — use the `tools(defs)` overload for typed args. + * + * Both overloads yield {@link McpServerTool}s, so `tool.metadata.mcp` (the + * server's title / annotations) is typed without an annotation or a cast. */ tools: { (options?: ToolsOptions): Promise> @@ -122,7 +131,7 @@ class MCPClientImpl< async tools( defsOrOptions?: ReadonlyArray | ToolsOptions, maybeOptions: ToolsOptions = {}, - ): Promise> { + ): Promise> { if (this.#closed) throw new MCPConnectionError('MCP client is closed') const isDefs = Array.isArray(defsOrOptions) @@ -131,7 +140,7 @@ class MCPClientImpl< : // 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 + let tools: Array if (isDefs) { // Explicit path: bind each TanStack toolDefinition to the server by name. const available = new Map( @@ -144,22 +153,34 @@ class MCPClientImpl< // on every callTool with -32600) — unlike discovery, which skips them. if (requiresTaskExecution(serverTool)) throw new MCPTaskRequiredToolError(def.name) - const tool = def.server( + const bound = 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.lazy = true - // Stamp MCP metadata so `serverToolNameOf` (and the call handler) can - // recover the UNPREFIXED native name + serverId — mirror toServerTools. - // `metadata.mcp` is `unknown`; only spread it when it's a plain object. - const existingMcp = tool.metadata?.mcp + // A caller-supplied definition may already carry its own `mcp` block, + // and `metadata.mcp` is untyped there — only spread it when it really + // is a plain object. + const existingMcp: unknown = bound.metadata?.mcp const mcpBase = existingMcp !== null && typeof existingMcp === 'object' ? existingMcp : {} - tool.metadata = { - ...tool.metadata, - mcp: { ...mcpBase, serverToolName: def.name, serverId: this.prefix }, + // Rebuilt rather than mutated in place: assigning `metadata` on a + // `ServerTool` can't narrow its declared `Record | + // undefined` type, so a fresh literal is what lets the return value be + // an `McpServerTool` (typed `metadata.mcp`) without a cast. + // + // Stamping MCP metadata lets `serverToolNameOf` (and the call handler) + // recover the UNPREFIXED native name + serverId, and carries the + // server's display title / annotations to the host — mirrors + // toServerTools. + const tool: McpServerTool = { + ...bound, + ...(this.prefix ? { name: `${this.prefix}_${def.name}` } : {}), + ...(options.lazy ? { lazy: true } : {}), + metadata: { + ...bound.metadata, + mcp: { ...mcpBase, ...toolMcpMetadata(serverTool, this.prefix) }, + }, } return tool }) diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index d16ff0bd6..7c13715f6 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -3,10 +3,13 @@ export type { MCPClient } from './client' export type { AnyToolDefinition, MappedServerTools, + McpServerTool, + McpToolMetadata, MCPClientOptions, ServerDescriptor, ToolsOptions, } from './types' +export type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js' export type { TransportConfig, TransportInput, diff --git a/packages/ai-mcp/src/pool.ts b/packages/ai-mcp/src/pool.ts index 84ec90839..a997309d5 100644 --- a/packages/ai-mcp/src/pool.ts +++ b/packages/ai-mcp/src/pool.ts @@ -1,9 +1,13 @@ import { createMCPClient } from './client' import { DuplicateToolNameError, MCPConnectionError } from './errors' import type { MCPClient } from './client' -import type { MCPClientOptions, ServerDescriptor, ToolsOptions } from './types' +import type { + MCPClientOptions, + McpServerTool, + ServerDescriptor, + ToolsOptions, +} from './types' import type { TransportConfig } from './transport' -import type { ServerTool } from '@tanstack/ai' import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js' export type MCPClientsConfig = Record @@ -20,7 +24,7 @@ export interface MCPClients< * All servers' tools, flattened and auto-prefixed by config key. * `options` (including `lazy`) is forwarded to every client's `tools()`. */ - tools: (options?: ToolsOptions) => Promise> + 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 @@ -111,7 +115,7 @@ export async function createMCPClients< const pool: MCPClients = { clients, - async tools(options?: ToolsOptions): Promise> { + async tools(options?: ToolsOptions): Promise> { // Settle (like the connect path) so a single failing server is reported // by config key instead of rejecting with an unattributed SDK error. const entries = Object.entries(clients) diff --git a/packages/ai-mcp/src/tools.ts b/packages/ai-mcp/src/tools.ts index c311f80af..422261b10 100644 --- a/packages/ai-mcp/src/tools.ts +++ b/packages/ai-mcp/src/tools.ts @@ -1,6 +1,10 @@ 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' +import type { + Tool as McpToolDef, + ToolAnnotations, +} from '@modelcontextprotocol/sdk/types.js' +import type { ContentPart } from '@tanstack/ai' +import type { McpServerTool, McpToolMetadata } from './types' interface ConvertOptions { prefix?: string @@ -14,6 +18,43 @@ export function extractUiResourceUri(def: McpToolDef): string | undefined { return typeof uri === 'string' ? uri : undefined } +/** + * The human-readable display name for a tool, following the MCP spec's + * precedence: the top-level `title` field wins, then the legacy + * `annotations.title`, and finally the programmatic `name`. + */ +function toolDisplayTitle(def: McpToolDef): string { + return def.title ?? def.annotations?.title ?? def.name +} + +/** + * Build the `metadata.mcp` block stamped onto every discovered/bound tool. + * Shared by auto-discovery (`toServerTools`) and the explicit `tools(defs)` + * path in `client.ts` so the two cannot drift. + * + * `annotations` is the server's own object, forwarded verbatim. Per the MCP + * spec its fields (including `title`) are **hints** — a host may use them for + * display or to shape an approval UI, but never as a security boundary. + * + * Fields the server didn't declare are OMITTED rather than set to `undefined`: + * the explicit path merges this over any `mcp` block the caller already put on + * their tool definition, and an `undefined` value would blank out what they set. + */ +export function toolMcpMetadata( + def: McpToolDef, + serverId: string | undefined, +): McpToolMetadata { + const uiResourceUri = extractUiResourceUri(def) + const annotations: ToolAnnotations | undefined = def.annotations + return { + serverToolName: def.name, + serverId, + title: toolDisplayTitle(def), + ...(uiResourceUri !== undefined ? { uiResourceUri } : {}), + ...(annotations !== undefined ? { annotations } : {}), + } +} + export function mcpContentToTanstack( content: Array, ): string | Array { @@ -114,12 +155,12 @@ export function toServerTools( client: Client, defs: Array, options: ConvertOptions, -): Array { +): Array { return defs .filter((def) => !requiresTaskExecution(def)) .map((def) => { const name = options.prefix ? `${options.prefix}_${def.name}` : def.name - const tool: ServerTool = { + const tool: McpServerTool = { __toolSide: 'server', name, description: def.description ?? '', @@ -130,11 +171,7 @@ export function toServerTools( ...(def.outputSchema ? { outputSchema: def.outputSchema as any } : {}), ...(options.lazy ? { lazy: true } : {}), metadata: { - mcp: { - serverToolName: def.name, - serverId: options.prefix, - uiResourceUri: extractUiResourceUri(def), - }, + mcp: toolMcpMetadata(def, options.prefix), }, execute: makeMcpExecute(client, def.name, Boolean(def.outputSchema)), } diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index b0025bb8e..bf2fdebc8 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -1,9 +1,73 @@ import type { ServerTool, ToolDefinition } from '@tanstack/ai' +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js' import type { TransportInput } from './transport' /** A bare tool definition (from `toolDefinition({...})`, no `.server()`/`.client()` called). */ export type AnyToolDefinition = ToolDefinition +/** + * The `mcp` block stamped onto every tool this package produces + * (`tool.metadata.mcp`), on BOTH the auto-discovery and explicit + * `tools(defs)` paths. + * + * You rarely name this type: `tools()` returns {@link McpServerTool}s, whose + * `metadata.mcp` is already typed as this shape, so the read needs no + * annotation and no cast: + * + * ```ts + * const tools = await mcp.tools() + * for (const tool of tools) { + * if (tool.metadata.mcp.annotations?.readOnlyHint) { + * // e.g. skip the approval prompt for a read-only tool + * } + * } + * ``` + */ +export interface McpToolMetadata { + /** Server-native (UNPREFIXED) tool name, even when the client sets a `prefix`. */ + serverToolName: string + /** + * Human-readable display name, resolved with the MCP spec's precedence: + * `title` → `annotations.title` → `name`. Always set, so a UI can render it + * without re-implementing the fallback chain. + */ + title: string + /** The owning client's `prefix` (the value a widget sends as `serverId`). */ + serverId?: string + /** MCP Apps widget link, from the tool def's `_meta.ui.resourceUri`. */ + uiResourceUri?: string + /** + * The server's `annotations` for this tool, forwarded verbatim (absent when + * the server declares none). All fields are **hints** — useful for display + * and for shaping an approval UI, never a security boundary. + */ + annotations?: ToolAnnotations +} + +/** + * A `ServerTool` produced by this package — structurally a plain `ServerTool` + * (so it drops straight into `chat({ tools })`) with one difference: its + * `metadata.mcp` block is statically known to be present and typed as + * {@link McpToolMetadata}. + * + * `ServerTool['metadata']` is `Record | undefined`, so reading + * `tool.metadata.mcp` off a bare `ServerTool` neither compiles (possibly + * undefined) nor type-checks the fields under it (`any`). Every `tools()` + * overload returns these instead, which makes the natural read work and a + * misspelling a compile error: + * + * ```ts + * const [tool] = await mcp.tools() + * tool.metadata.mcp.title // string + * tool.metadata.mcp.annotaions // compile error (typo) + * ``` + */ +export type McpServerTool< + TTool extends ServerTool = ServerTool, +> = Omit & { + metadata: Record & { mcp: McpToolMetadata } +} + /** Compile-time-only descriptor of an MCP server, emitted by the codegen CLI. */ export interface ServerDescriptor { tools: Record @@ -32,11 +96,12 @@ export interface ToolsOptions { /** * 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. + * mapped tuple over the passed definitions preserves per-tool types. Wrapped + * in {@link McpServerTool} because the explicit path stamps `metadata.mcp` too. */ export type ServerToolFromDef = TDef extends ToolDefinition - ? ServerTool + ? McpServerTool> : never export type MappedServerTools> = @@ -53,7 +118,9 @@ export type MappedServerTools> = * emitted by the codegen CLI. Per-tool argument/result typing comes from the * explicit `tools(defs)` overload via `MappedServerTools`. */ -type DescribedTool = ServerTool +type DescribedTool = McpServerTool< + ServerTool +> /** * Discovery result typed from the generated descriptor: an array whose diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts index 3ca15b554..9082327a9 100644 --- a/packages/ai-mcp/tests/client.test.ts +++ b/packages/ai-mcp/tests/client.test.ts @@ -7,6 +7,7 @@ import { MCPTaskRequiredToolError, } from '../src/errors' import { + makeServerWithAnnotatedTool, makeServerWithTaskRequiredTool, makeServerWithWeatherTool, } from './helpers/in-memory-server' @@ -107,12 +108,48 @@ describe('createMCPClient', () => { const tools = await client.tools([getWeather]) // The runtime name is prefixed, but the UNPREFIXED native name + serverId // must be recoverable from metadata (mirrors auto-discovery). - expect(tools[0].metadata?.mcp).toMatchObject({ + expect(tools[0].metadata.mcp).toMatchObject({ serverToolName: 'get_weather', serverId: 'wx', }) }) + it('forwards server annotations + display title on auto-discovery', async () => { + const { clientTransport } = await makeServerWithAnnotatedTool() + await using client = await createMCPClientFromTransport(clientTransport) + const tool = (await client.tools()).find((t) => t.name === 'get_weather')! + // `tools()` returns `McpServerTool`s — the read is typed as + // `McpToolMetadata` with no annotation and no optional chaining. + const mcp = tool.metadata.mcp + expect(mcp.annotations).toEqual({ + title: 'Legacy Weather Title', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }) + // Top-level `title` wins over the legacy `annotations.title`. + expect(mcp.title).toBe('Weather Lookup') + }) + + it('forwards server annotations + display title on bound definitions', async () => { + const { clientTransport } = await makeServerWithAnnotatedTool() + 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() }), + }) + // The explicit path binds the caller's definition, but the SERVER's + // annotations still have to reach the host (mirrors auto-discovery). + const tools = await client.tools([getWeather]) + const mcp = tools[0].metadata.mcp + expect(mcp.annotations?.readOnlyHint).toBe(true) + expect(mcp.title).toBe('Weather Lookup') + }) + it('excludes task-required tools from auto-discovery', async () => { const { clientTransport } = await makeServerWithTaskRequiredTool() await using client = await createMCPClientFromTransport(clientTransport) diff --git a/packages/ai-mcp/tests/helpers/in-memory-server.ts b/packages/ai-mcp/tests/helpers/in-memory-server.ts index cbace5212..133793b82 100644 --- a/packages/ai-mcp/tests/helpers/in-memory-server.ts +++ b/packages/ai-mcp/tests/helpers/in-memory-server.ts @@ -22,6 +22,37 @@ export async function makeServerWithWeatherTool() { return { server, clientTransport } } +/** + * Build a connected (server, clientTransport) pair whose tool declares a + * display `title` plus the full set of MCP `annotations` hints, so the + * annotation-forwarding path can be exercised against a real server. + */ +export async function makeServerWithAnnotatedTool() { + const server = new McpServer({ name: 'annotated', version: '1.0.0' }) + server.registerTool( + 'get_weather', + { + title: 'Weather Lookup', + description: 'Get weather for a city', + inputSchema: { city: z.string() }, + annotations: { + title: 'Legacy Weather Title', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ city }) => ({ + content: [{ type: 'text' as const, text: `Sunny in ${city}` }], + }), + ) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport } +} + /** Build a connected (server, clientTransport) pair whose only tool always returns an MCP error result. */ export async function makeServerWithFailingTool() { const server = new McpServer({ name: 'failing', version: '1.0.0' }) diff --git a/packages/ai-mcp/tests/tools.test.ts b/packages/ai-mcp/tests/tools.test.ts index b91c28de3..3e8ee7c4a 100644 --- a/packages/ai-mcp/tests/tools.test.ts +++ b/packages/ai-mcp/tests/tools.test.ts @@ -13,7 +13,6 @@ import type { CallToolResult, Tool as McpToolDef, } from '@modelcontextprotocol/sdk/types.js' -import type { ServerTool } from '@tanstack/ai' /** * Build an MCP tool definition for `toServerTools`. The MCP-Apps `_meta.ui` @@ -23,8 +22,16 @@ import type { ServerTool } from '@tanstack/ai' */ function mcpToolDef(def: { name: string + title?: string description?: string inputSchema?: { type: 'object'; properties?: Record } + annotations?: { + title?: string + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + openWorldHint?: boolean + } _meta?: { ui?: { resourceUri?: string } } }): McpToolDef { return { @@ -201,22 +208,6 @@ describe('makeMcpExecute', () => { }) }) -/** The MCP-Apps metadata block `toServerTools` stamps onto each tool. */ -interface ToolMcpMeta { - serverToolName?: string - serverId?: string - uiResourceUri?: string -} - -/** - * Read the `mcp` metadata block off a produced ServerTool. `metadata` is - * `Record` upstream, so the access is already `any` — annotating - * the return documents the real shape without a cast. - */ -function readToolMcpMeta(tool: ServerTool): ToolMcpMeta { - return tool.metadata!.mcp -} - describe('toServerTools — MCP Apps metadata', () => { it('captures serverId (prefix) and the _meta.ui.resourceUri link', () => { const tool = toServerTools( @@ -246,12 +237,77 @@ describe('toServerTools — MCP Apps metadata', () => { [mcpToolDef({ name: 't' })], {}, )[0]! - const mcp = readToolMcpMeta(tool) + // `toServerTools` returns `McpServerTool`s, so `metadata.mcp` reads + // straight through — no annotation, no non-null assertion, no cast. + const mcp = tool.metadata.mcp expect(mcp.uiResourceUri).toBeUndefined() expect(mcp.serverId).toBeUndefined() }) }) +describe('toServerTools — annotations + title', () => { + it('forwards the server annotations verbatim', () => { + const annotations = { + title: 'Weather Lookup', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + } + const tool = toServerTools( + fakeMcpClient(vi.fn()), + [mcpToolDef({ name: 'get_weather', description: 'w', annotations })], + {}, + )[0]! + expect(tool.metadata.mcp.annotations).toEqual(annotations) + }) + + it('omits annotations entirely when the server declares none', () => { + const tool = toServerTools( + fakeMcpClient(vi.fn()), + [mcpToolDef({ name: 'get_weather' })], + {}, + )[0]! + // `toServerTools` returns `McpServerTool`s, so `metadata.mcp` reads + // straight through — no annotation, no non-null assertion, no cast. + const mcp = tool.metadata.mcp + expect(mcp.annotations).toBeUndefined() + // Omitted, not present-with-undefined — the explicit tools(defs) path + // merges this block over caller-supplied metadata. + expect('annotations' in mcp).toBe(false) + }) + + it('resolves title with MCP precedence: title > annotations.title > name', () => { + const [both, annotationsOnly, neither] = toServerTools( + fakeMcpClient(vi.fn()), + [ + mcpToolDef({ + name: 'a', + title: 'Top Level', + annotations: { title: 'Legacy' }, + }), + mcpToolDef({ name: 'b', annotations: { title: 'Legacy' } }), + mcpToolDef({ name: 'c' }), + ], + {}, + ) + expect(both!.metadata.mcp.title).toBe('Top Level') + expect(annotationsOnly!.metadata.mcp.title).toBe('Legacy') + expect(neither!.metadata.mcp.title).toBe('c') + }) + + it('keeps the prefixed tool name independent of the display title', () => { + const tool = toServerTools( + fakeMcpClient(vi.fn()), + [mcpToolDef({ name: 'get_weather', title: 'Weather Lookup' })], + { prefix: 'wx' }, + )[0]! + // The title is display-only — it must never leak into the model-facing name. + expect(tool.name).toBe('wx_get_weather') + expect(tool.metadata.mcp.title).toBe('Weather Lookup') + }) +}) + describe('toServerTools', () => { it('discovers tools and proxies execute to callTool', async () => { const { clientTransport } = await makeServerWithWeatherTool() diff --git a/packages/ai-mcp/tests/types.test-d.ts b/packages/ai-mcp/tests/types.test-d.ts index b96885a36..145dbf474 100644 --- a/packages/ai-mcp/tests/types.test-d.ts +++ b/packages/ai-mcp/tests/types.test-d.ts @@ -2,8 +2,15 @@ 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 { MCPClients } from '../src/pool' +import type { + MappedServerTools, + McpServerTool, + McpToolMetadata, + ServerDescriptor, +} from '../src/types' import type { ServerTool } from '@tanstack/ai' +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js' interface WeatherServer extends ServerDescriptor { tools: { get_weather: { input: { city: string }; output: string } } @@ -38,3 +45,35 @@ const getWeather = toolDefinition({ }) const bound = await client.tools([getWeather]) expectTypeOf(bound).toEqualTypeOf>() + +// `metadata.mcp` is typed on EVERY tools() path, so a consumer reads the +// server's title / annotations with no annotation, no optional chaining on +// `metadata`, and no cast. +expectTypeOf(discovered).items.toHaveProperty('metadata').toExtend<{ + mcp: McpToolMetadata +}>() +expectTypeOf(bound).items.toHaveProperty('metadata').toExtend<{ + mcp: McpToolMetadata +}>() +expectTypeOf(defaultDiscovered).items.toHaveProperty('metadata').toExtend<{ + mcp: McpToolMetadata +}>() + +declare const pool: MCPClients +const pooled = await pool.tools() +expectTypeOf(pooled).items.toHaveProperty('metadata').toExtend<{ + mcp: McpToolMetadata +}>() + +// The whole point: these resolve without help, and a misspelling is an error. +expectTypeOf(discovered[0]!.metadata.mcp.title).toEqualTypeOf() +expectTypeOf(discovered[0]!.metadata.mcp.serverToolName).toEqualTypeOf() +expectTypeOf(discovered[0]!.metadata.mcp.annotations).toEqualTypeOf< + ToolAnnotations | undefined +>() +// @ts-expect-error — `annotaions` is not a field of McpToolMetadata. +discovered[0]!.metadata.mcp.annotaions + +// An McpServerTool still drops into anything that wants a plain ServerTool +// (e.g. `chat({ tools })`) — the metadata guarantee only narrows. +expectTypeOf().toExtend() diff --git a/testing/e2e/src/routes/api.mcp-server.ts b/testing/e2e/src/routes/api.mcp-server.ts index 75ffcf1a8..337d05c42 100644 --- a/testing/e2e/src/routes/api.mcp-server.ts +++ b/testing/e2e/src/routes/api.mcp-server.ts @@ -32,9 +32,19 @@ function createMockMcpServer(): McpServer { server.registerTool( 'get_guitar_price', { + // A display `title` plus behavior `annotations` — server-declared hints + // that @tanstack/ai-mcp must forward onto `metadata.mcp` so a host can + // label the tool and shape its approval UI (see api.mcp-status-test). + title: 'Guitar Price Lookup', description: 'Get the price of a guitar by its id', inputSchema: { id: z.string() }, outputSchema: { id: z.string(), price: z.number() }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, }, ({ id }) => { const payload = { id, price: 1999 } diff --git a/testing/e2e/src/routes/api.mcp-status-test.ts b/testing/e2e/src/routes/api.mcp-status-test.ts index 40d1d2a95..fa7e78f30 100644 --- a/testing/e2e/src/routes/api.mcp-status-test.ts +++ b/testing/e2e/src/routes/api.mcp-status-test.ts @@ -14,6 +14,10 @@ import { * result as JSON so a spec can validate the resource/prompt read+convert path * end-to-end against a real Streamable-HTTP MCP server, with no LLM involved. * + * `toolMeta` additionally carries each discovered tool's forwarded MCP + * metadata (display `title` + behavior `annotations`), proving the server's + * hints survive discovery and reach the host. + * * No aimock dependency: this exercises only the MCP client surface. */ export const Route = createFileRoute('/api/mcp-status-test')({ @@ -27,7 +31,17 @@ export const Route = createFileRoute('/api/mcp-status-test')({ transport: { type: 'http', url: mcpUrl }, }) try { - const tools = (await client.tools()).map((t) => t.name) + const discovered = await client.tools() + const tools = discovered.map((t) => t.name) + // The server's display title + behavior annotations, as forwarded + // onto `metadata.mcp` by discovery. `tools()` returns + // `McpServerTool`s, so this reads straight through — typed, no + // annotation, no cast. + const toolMeta = discovered.map((t) => ({ + name: t.name, + title: t.metadata.mcp.title, + annotations: t.metadata.mcp.annotations, + })) const resourceList = await client.resources().catch(() => []) const resourceContent: Array = [] @@ -47,6 +61,7 @@ export const Route = createFileRoute('/api/mcp-status-test')({ return Response.json({ tools, + toolMeta, resources: resourceList.map((r) => r.uri), prompts: promptList.map((p) => p.name), resourceContent, diff --git a/testing/e2e/tests/mcp-status.spec.ts b/testing/e2e/tests/mcp-status.spec.ts index 942be3e5f..764effd1f 100644 --- a/testing/e2e/tests/mcp-status.spec.ts +++ b/testing/e2e/tests/mcp-status.spec.ts @@ -24,6 +24,11 @@ test.describe('mcp — resource/prompt discovery + conversion', () => { const json = JSON.parse(body) as { tools: Array + toolMeta: Array<{ + name: string + title?: string + annotations?: Record + }> resources: Array prompts: Array resourceContent: Array<{ type: string; content: string }> @@ -33,6 +38,18 @@ test.describe('mcp — resource/prompt discovery + conversion', () => { // Tool discovered. expect(json.tools).toContain('get_guitar_price') + // The server's display title + behavior annotations are forwarded onto the + // discovered tool's `metadata.mcp` — a host can label the tool and shape + // its approval UI without a second tools/list round-trip. + const priceMeta = json.toolMeta.find((t) => t.name === 'get_guitar_price') + expect(priceMeta?.title).toBe('Guitar Price Lookup') + expect(priceMeta?.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }) + // Task-required tool (execution.taskSupport: 'required') is excluded from // discovery — plain callTool can never execute it (-32600). expect(json.tools).not.toContain('appraise_guitar_collection')