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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/mcp-tool-annotations.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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()",
Expand Down
107 changes: 107 additions & 0 deletions docs/tools/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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! },
})
Comment on lines +330 to +332

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect project conventions for validating this environment variable.
rg -n -C 4 'process\.env\.MCP_URL|createMCPClient' docs packages/ai-mcp

Repository: TanStack/ai

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Target file context:"
fd '^mcp\.md$' docs -x sh -c 'wc -l "$1"; echo "--- $1"; sed -n "290,335p" "$1"' sh {}

echo
echo "Exact process.env.MCP_URL occurrences:"
rg -n -C 3 'process\.env\.MCP_URL|createMCPClient\(\{' docs packages/ai-mcp/skills/ai-mcp/SKILL.md packages/ai-mcp/README.md

echo
echo "Relevant client.ts signatures/top:"
fd '^client\.ts$' packages/ai-mcp/src -x sh -c 'wc -l "$1"; echo "--- $1"; sed -n "1,220p" "$1"' sh {} 2>/dev/null || true

echo
echo "HTTP transport URL behavior in MCP SDK references in repo:"
git ls-files | rg 'modelcontextprotocol|http-stream|streamable|stream-http|ClientTransport' | head -100

Repository: TanStack/ai

Length of output: 33670


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "transport.ts:"
fd '^transport\.ts$' packages/ai-mcp/src -x sh -c 'wc -l "$1"; echo "--- $1"; cat -n "$1"' sh {}

echo
echo "TransportConfig definitions/usages in ai-mcp src:"
rg -n -C 3 'interface TransportConfig|type TransportConfig|resolveTransport|StreamableHTTPClientTransport|new URL|new StreamableHTTPClientTransport|new (StreamableHTTPClientTransport|SSEClientTransport|StdioClientTransport)' packages/ai-mcp/src

echo
echo "Lockfile SDK versions:"
rg -n '"(`@modelcontextprotocol/sdk`)|streamableHttp|SSEClientTransport|StdioClientTransport' package.json pnpm-lock.yaml package-lock.json npm-shrinkwrap.json yarn.lock 2>/dev/null | head -200 || true

echo
echo "Package deps:"
fd '^package\.json$' packages/ai-mcp -x sh -c 'echo "--- $1"; sed -n "1,120p" "$1"' sh {}

Repository: TanStack/ai

Length of output: 10103


Validate MCP_URL before constructing the MCP client.

process.env.MCP_URL! only bypasses TypeScript checking. If the variable is missing, createMCPClient({ transport: { type: 'http', url: process.env.MCP_URL! } }) passes undefined to resolveTransport, and the HTTP transport constructor receives new URL(undefined), which throws before configuration validation. Return a clear response when MCP_URL is absent, then pass the validated URL.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/tools/mcp.md` around lines 319 - 321, Validate that process.env.MCP_URL
is present before the createMCPClient call, returning a clear response when it
is absent. Store the validated value and pass it to the HTTP transport instead
of using the non-null assertion, ensuring resolveTransport receives a defined
URL.

Source: Coding guidelines

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<Array<ToolSummary>>([])

useEffect(() => {
fetch('/api/mcp-tools')
.then((res) => res.json())
.then((body: { tools: Array<ToolSummary> }) => setTools(body.tools))
}, [])

return (
<ul>
{tools.map((tool) => (
<li key={tool.name}>
{/* Server-declared title, with the hint driving the badge */}
<strong>{tool.title}</strong> {tool.readOnly ? '(read-only)' : '(writes)'}
<div>{tool.description}</div>
</li>
))}
</ul>
)
}
```

## 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.
Expand Down
47 changes: 34 additions & 13 deletions packages/ai-mcp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,6 +19,7 @@ import type {
DescriptorTools,
MCPClientOptions,
MappedServerTools,
McpServerTool,
ServerDescriptor,
ToolsOptions,
} from './types'
Expand All @@ -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<DescriptorTools<TServer>>
Expand Down Expand Up @@ -122,7 +131,7 @@ class MCPClientImpl<
async tools(
defsOrOptions?: ReadonlyArray<AnyToolDefinition> | ToolsOptions,
maybeOptions: ToolsOptions = {},
): Promise<Array<ServerTool>> {
): Promise<Array<McpServerTool>> {
if (this.#closed) throw new MCPConnectionError('MCP client is closed')

const isDefs = Array.isArray(defsOrOptions)
Expand All @@ -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<ServerTool>
let tools: Array<McpServerTool>
if (isDefs) {
// Explicit path: bind each TanStack toolDefinition to the server by name.
const available = new Map(
Expand All @@ -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<string, any> |
// 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
})
Expand Down
3 changes: 3 additions & 0 deletions packages/ai-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions packages/ai-mcp/src/pool.ts
Original file line number Diff line number Diff line change
@@ -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<string, MCPClientOptions>
Expand All @@ -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<Array<ServerTool>>
tools: (options?: ToolsOptions) => Promise<Array<McpServerTool>>
/**
* 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
Expand Down Expand Up @@ -111,7 +115,7 @@ export async function createMCPClients<

const pool: MCPClients<TServers> = {
clients,
async tools(options?: ToolsOptions): Promise<Array<ServerTool>> {
async tools(options?: ToolsOptions): Promise<Array<McpServerTool>> {
// 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)
Expand Down
55 changes: 46 additions & 9 deletions packages/ai-mcp/src/tools.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<any>,
): string | Array<ContentPart> {
Expand Down Expand Up @@ -114,12 +155,12 @@ export function toServerTools(
client: Client,
defs: Array<McpToolDef>,
options: ConvertOptions,
): Array<ServerTool> {
): Array<McpServerTool> {
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 ?? '',
Expand All @@ -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)),
}
Expand Down
Loading
Loading