@tanstack/ai-mcp
Version:
Host-side Model Context Protocol client for TanStack AI: discover and run MCP server tools, resources, and prompts in any adapter's chat() loop, with generated end-to-end types.
181 lines (172 loc) • 6.58 kB
text/typescript
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
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
lazy?: boolean
}
/** Reads the MCP Apps `_meta.ui.resourceUri` link from a tool def, if present. */
export function extractUiResourceUri(def: McpToolDef): string | undefined {
const meta = (def as { _meta?: { ui?: { resourceUri?: unknown } } })._meta
const uri = meta?.ui?.resourceUri
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> {
// A valid MCP result may carry only structuredContent (no content[]) → guard
// against undefined/non-array before reading length/map.
if (!Array.isArray(content)) return ''
// Single text block → plain string (most common, best for the model).
if (content.length === 1 && content[0]?.type === 'text')
return content[0].text
const parts = content
.map((c): ContentPart => {
switch (c.type) {
case 'text':
return { type: 'text', content: c.text }
case 'image':
return {
type: 'image',
source: { type: 'data', value: c.data, mimeType: c.mimeType },
}
case 'resource': {
const uri = c.resource?.uri
if (typeof uri === 'string' && uri.startsWith('ui://')) {
// ui:// resources are surfaced via readResource (MCP Apps); omit from model text.
return { type: 'text', content: '' }
}
return { type: 'text', content: JSON.stringify(c.resource) }
}
default:
return { type: 'text', content: JSON.stringify(c) }
}
})
.filter((p) => !(p.type === 'text' && p.content === ''))
return parts.length ? parts : ''
}
/**
* Build the execute body that proxies a TanStack tool call to an MCP server's
* `callTool`. Shared by auto-discovery and the definition path.
*
* @param preferStructured when true (i.e. the tool declares an outputSchema),
* return `result.structuredContent` if present so the existing output
* validation in `executeServerTool` validates MCP's typed payload rather than
* a JSON-in-text blob. Otherwise normalize `content[]` → string | ContentPart[].
*/
export function makeMcpExecute(
client: Client,
mcpName: string,
preferStructured: boolean,
) {
return async (args: unknown, ctx?: { abortSignal?: AbortSignal }) => {
ctx?.abortSignal?.throwIfAborted()
const result = await client.callTool(
{ name: mcpName, arguments: (args ?? {}) as Record<string, unknown> },
undefined,
{ signal: ctx?.abortSignal },
)
if (result.isError) {
const text = Array.isArray(result.content)
? mcpContentToTanstack(result.content)
: undefined
const detail =
typeof text === 'string'
? text
: text === undefined
? undefined
: JSON.stringify(text)
// An empty/absent detail (e.g. a ui://-only error body) would render a
// dangling colon — fall back to the bare message.
throw new Error(
!detail
? `MCP tool "${mcpName}" returned an error`
: `MCP tool "${mcpName}" returned an error: ${detail}`,
)
}
if (preferStructured && result.structuredContent !== undefined) {
return result.structuredContent
}
return mcpContentToTanstack(result.content as Array<any>)
}
}
/**
* A tool with `execution.taskSupport: 'required'` can only run through the
* SDK's experimental task-based execution (`tasks/callToolStream`) — plain
* `callTool` is rejected by the server with -32600. Until task execution is
* supported, such tools must not be offered to the model.
*/
export function requiresTaskExecution(def: McpToolDef): boolean {
return def.execution?.taskSupport === 'required'
}
/**
* Auto-discovery path: turn raw MCP tool defs into ServerTools (args typed
* `unknown`). Task-required tools are excluded — they cannot be invoked via
* plain `callTool` (see {@link requiresTaskExecution}).
*/
export function toServerTools(
client: Client,
defs: Array<McpToolDef>,
options: ConvertOptions,
): Array<McpServerTool> {
return defs
.filter((def) => !requiresTaskExecution(def))
.map((def) => {
const name = options.prefix ? `${options.prefix}_${def.name}` : def.name
const tool: McpServerTool = {
__toolSide: 'server',
name,
description: def.description ?? '',
inputSchema: (def.inputSchema as any) ?? {
type: 'object',
properties: {},
},
...(def.outputSchema ? { outputSchema: def.outputSchema as any } : {}),
...(options.lazy ? { lazy: true } : {}),
metadata: {
mcp: toolMcpMetadata(def, options.prefix),
},
execute: makeMcpExecute(client, def.name, Boolean(def.outputSchema)),
}
return tool
})
}