UNPKG

@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.

779 lines (740 loc) • 26.7 kB
import { defineCommand, definePlugin } from '@tanstack/ai-harness' import { connectTransport } from './client' import type { ElicitResult } from '@modelcontextprotocol/client' import type { AnyTool, JSONSchema, Logger } from '@tanstack/ai' import type { AnyCommand, PluginPrompt, PluginResources, PluginSessionApi, PluginSetupContext, RunContext, TurnInfo, } from '@tanstack/ai-harness' import type { MCPClient, OnElicit } from './client' import type { McpConnectorOptions } from './connector' import type { AskInput } from './tools' import type { MCPClientOptions } from './types' import type { HttpTransportConfig, StdioTransportConfig } from './transport' /** One server of {@link mcp}, keyed by its name. */ export type McpServerConfig = ( | StdioTransportConfig | (HttpTransportConfig & { /** * Sign in with OAuth, like `mcpConnector`. This adds `/connect <name>` * and `/disconnect <name>`. The tools of the server come after the * sign-in, and the tools that are not read-only ask for approval. * `headers` and `authProvider` do not apply then. */ oauth?: boolean }) ) & { /** * The time in milliseconds that this server can take to connect, to send * its tool list, or to answer a tool call. Default: the MCP SDK default, * 60,000. */ timeoutMs?: number /** * Give the tools of this server to code mode: the `codeMode()` plugin of * `@tanstack/ai-code-mode` takes them out of the tool list and lets the * model call them from `execute_typescript`. Without that plugin, nothing * changes. */ codeMode?: boolean /** * Ask the user for approval before a tool of this server runs. `true` asks * for every tool. A function gets the MCP tool definition and returns * `true` to ask. Default: no approval, and for `oauth: true` every tool * that the server does not mark `readOnlyHint`. */ needsApproval?: boolean | McpConnectorOptions['needsApproval'] /** * The name the model sees for each tool of this server. It gets the MCP * tool definition. Default: `<server>_<tool>`. */ toolName?: MCPClientOptions['toolName'] } /** * The status of one server. UIs read the list from * `snapshot().plugins['tanstack/mcp'].servers`. A server is `reconnecting` * after its connection closed, until a new connection is open. */ export type McpServerStatus = { name: string /** How many tools the server gives the model. */ toolCount: number } & ( | { status: 'connecting' | 'connected' | 'reconnecting' } | { status: 'failed'; error: string } ) /** * A harness plugin that connects MCP servers and gives the model their * tools, named `<server>_<tool>`. * * - Each server connects on its own. A server that fails does not stop the * others: it gets the status `failed` with the error. * - The servers connect in the background. The first turn waits for them. * - `/mcp` lists the servers and their status. UIs read the same list from * the plugin state. * - A `stdio` server runs as one child process (Node only) for every * session of the harness on the host that uses this `mcp()` object. It * stops when the last of those sessions closes, or at `host.close()`. * Create the plugin once, outside the `plugins` factory: a factory that * calls `mcp()` for each session starts a process for each session. The * other servers connect for each session. * - An `http` server with `oauth: true` signs in through `/connect <name>`. * - When the connection of a server closes, the server is `reconnecting`. * The plugin connects again after 0.5 s, 1 s, 2 s, and so on (at most * 30 s), until it is connected or its session or host closes. The wait * starts again at 0.5 s only after a connection stayed open for 30 s, so a * server that crashes right after it connects waits longer each time. The * next turn gets the tools of the new connection. A server that fails its * first connect is `failed`, and connects again the same way. * - A spec 2025 server that asks for input (`elicitation/create`) does not * name the tool call that asks. The user of the call is asked only when * all the calls of that server that run now belong to one run. Else the * request is declined, so a question never goes to another user. * - When a server sends `tools/list_changed`, the next turn lists its tools * again. * - When two servers give a tool with the same name, the model gets the * tool of the server that comes first in `servers`. The plugin warns once, * to the host logger (`createHarnessHost({ logger })`). With * `onDuplicateTool: 'error'`, the later server fails instead. * * @param options.servers - The servers, keyed by name. The name is the tool * prefix. * @param options.onDuplicateTool - What happens when a server gives a tool * name that a server earlier in `servers` gives too. `'warn'` (default): * the model does not get that one tool, and the plugin warns once. * `'error'`: the later server gets the status `failed` with an error that * names both servers and the tool, and the model gets none of its tools. * The other servers keep working. * * @example * ```ts * import { mcp } from '@tanstack/ai-mcp/harness' * * // Once, so the sessions share the stdio process. * const tools = mcp({ * servers: { * files: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] }, * docs: { type: 'http', url: 'https://mcp.example.com/mcp', timeoutMs: 10_000 }, * notion: { type: 'http', url: 'https://mcp.notion.com/mcp', oauth: true }, * }, * }) * * plugins: () => [tools] * ``` */ export function mcp(options: { servers: Readonly<Record<string, McpServerConfig>> onDuplicateTool?: 'warn' | 'error' }) { const servers = Object.entries(options.servers) return definePlugin({ name: 'tanstack/mcp', // The stdio servers connect once for the host. The rest is per session. lifetime: 'host', setup: (host) => { // The tool calls of a shared server that run now, newest last. const calling: Array<Caller> = [] // A spec 2025 request of a shared server goes to the run that calls, // only when one run calls. Else it is declined (see `elicitFor`). const askCaller = elicitFor(calling) const shared = new Map<string, SharedServer>() for (const [name, server] of servers) { if (server.type !== 'stdio') continue shared.set(name, openServer(host.resources, name, server, askCaller)) } // The tool name clashes that were warned about, for all sessions. const warned = new Set<string>() const onDuplicate = options.onDuplicateTool ?? 'warn' return { session: (ctx) => sessionPart(ctx, servers, shared, calling, warned, onDuplicate), } }, }) } /** The part of {@link mcp} that each session sets up. */ async function sessionPart( ctx: PluginSetupContext, servers: ReadonlyArray<[string, McpServerConfig]>, shared: ReadonlyMap<string, SharedServer>, calling: Array<Caller>, warned: Set<string>, onDuplicate: 'warn' | 'error', ) { const statuses = new Map<string, McpServerStatus>() for (const [name] of servers) { statuses.set(name, { name, status: 'connecting', toolCount: 0 }) } const state = ctx.state({ servers: [...statuses.values()] }) const setStatus = async (status: McpServerStatus) => { const old = JSON.stringify(statuses.get(status.name)) statuses.set(status.name, status) if (old === JSON.stringify(status)) return await state.update(() => ({ servers: [...statuses.values()] })) } // State from an earlier run of this session is out of date. await state.update(() => ({ servers: [...statuses.values()] })) /** * Connect one server in the background. Returns the tools of the server * for each turn. A stdio server is the shared one of the host. Never * rejects. */ const connect = (name: string, server: McpServerConfig) => { const sharedServer = shared.get(name) // The tool calls of this server for this session that run now. const own: Array<Caller> = [] const opened = sharedServer ?? openServer(ctx.resources, name, server, elicitFor(own)) // After the session closed, the state store can be gone. `/mcp` // reads `statuses`, which is set first. const opening = opened.then(async (link) => { if (link instanceof Error) { await setStatus({ name, status: 'failed', error: link.message, toolCount: 0, }).catch(() => undefined) return undefined } const update = () => setStatus(link.status()).catch(() => undefined) link.watch(() => void update(), ctx.resources.signal) await update() return link }) return async (_turn: TurnInfo) => { const link = await opening if (!link) return [] const tools = trackCaller( await link.tools(), ctx.session, sharedServer ? calling : own, ) return askForInput(tools, ctx.session) } } const commands: Record<string, AnyCommand> = { mcp: defineCommand({ description: 'Show the MCP servers and their status', run: () => [...statuses.values()].map(statusLine).join('\n'), }), } const prompts: Array<PluginPrompt> = [] // One tool list for each server, in the order of `servers`. const lists = await Promise.all( servers.map(async (entry) => { const [name, server] = entry const isOAuth = server.type === 'http' && server.oauth === true if (!isOAuth) return { name, list: connect(name, server) } // Loaded here: the connector needs node:crypto and node:http. const { mcpConnector } = await import('./connector') const connector = await mcpConnector({ id: name, label: name, url: server.url, toolName: server.toolName, needsApproval: approvalOf(server), requestOptions: requestOptionsOf(server), ...(server.fetch ? { fetch: server.fetch } : {}), }).setup(ctx) Object.assign(commands, connector.commands) prompts.push(...connector.prompts) // The sign-in belongs to the sender of the turn, so the status is // the one of the last turn. const list = async (turn: TurnInfo) => { try { const tools = await connector.discoverTools(turn) const signedIn = tools.length > 0 || (await turn.run.credentials.get(name)) !== null await setStatus( signedIn ? { name, status: 'connected', toolCount: tools.length } : { name, status: 'failed', error: `Not signed in. Run /connect ${name}.`, toolCount: 0, }, ) return forCodeMode(server, tools) } catch (error) { // One server that fails must not hide the tools of the others. await setStatus({ name, status: 'failed', error: errorText(error), toolCount: 0, }) return [] } } return { name, list } }), ) return { commands, prompts, discoverTools: async (turn: TurnInfo) => { const found = await Promise.all( lists.map(async ({ name, list }) => ({ name, tools: await list(turn), })), ) return onDuplicate === 'error' ? failOnDuplicate(found, setStatus) : keepFirstTools(found, warned, ctx.logger) }, } } /** A server that the sessions of a host share: its connection, or its error. */ type SharedServer = ReturnType<typeof openServer> /** * Connect one server in `resources`. It never rejects: an error that stops * the setup itself resolves to that error. * * When the first connect fails, the status is `failed` with the error, and * the server connects again with backoff. When the connection closes later, * the status is `reconnecting`, with the same backoff. Both go on until a * connection is open or `resources` is disposed. After * `tools/list_changed`, the next `tools()` lists the tools again. */ function openServer( resources: PluginResources, name: string, server: McpServerConfig, onElicit: OnElicit, ) { const { signal } = resources const watchers = new Set<() => void>() let status: McpServerStatus = { name, status: 'connecting', toolCount: 0 } let client: MCPClient | undefined let tools: ReadonlyArray<AnyTool> = [] // Set by tools/list_changed. The next `tools()` lists the tools again. let stale = false let listing: Promise<void> | undefined // The backoff goes on across drops, so a server that crashes right after // it connects waits longer each time. It resets only after a connection // stayed open for `stableMs`. let attempt = 0 let openedAt = 0 const setStatus = (next: McpServerStatus) => { status = next for (const watcher of watchers) watcher() } /** One connection with its tool list. Throws when the server fails. */ const open = async () => { // Loaded here: the stdio transport needs node:child_process. const transport = server.type === 'stdio' ? (await import('./stdio')).stdioTransport(server) : server const next = await connectTransport( { transport, prefix: name, toolName: server.toolName, needsApproval: approvalOf(server), requestOptions: requestOptionsOf(server), clientOptions: harnessClientOptions, onClose: () => dropped(), onToolListChanged: () => { stale = true }, }, onElicit, ) let listed: ReadonlyArray<AnyTool> try { listed = forCodeMode(server, await next.tools()) } catch (error) { // Connected, but no tool list: the server failed. await next.close().catch(() => undefined) throw error } // Disposed while this connect ran: nothing uses the connection. if (signal.aborted) { await next.close().catch(() => undefined) throw signal.reason } client = next tools = listed stale = false openedAt = Date.now() setStatus({ name, status: 'connected', toolCount: listed.length }) } /** The connection closed, and this plugin did not close it. */ const dropped = () => { if (status.status !== 'connected' || signal.aborted) return client = undefined tools = [] if (Date.now() - openedAt >= stableMs) attempt = 0 setStatus({ name, status: 'reconnecting', toolCount: 0 }) void reconnect() } const reconnect = async () => { for (;;) { await delay(backoffMs(attempt++), signal) if (signal.aborted) return const isOpen = await open().then( () => true, () => false, ) if (isOpen) return } } /** List the tools of `current` again. On a failure, the old list stays. */ const relist = async (current: MCPClient) => { stale = false const listed = await current.tools().then( (found) => forCodeMode(server, found), () => undefined, ) // The connection closed while it listed. if (client !== current) return if (listed === undefined) { stale = true return } tools = listed setStatus({ name, status: 'connected', toolCount: listed.length }) } const link = { status: () => status, /** The tools of the open connection. Never rejects. */ tools: async () => { if (stale && client) { listing ??= relist(client).finally(() => { listing = undefined }) await listing } return tools }, /** Calls `watcher` after each status change, until `until` aborts. */ watch: (watcher: () => void, until: AbortSignal) => { if (until.aborted) return watchers.add(watcher) until.addEventListener('abort', () => watchers.delete(watcher), { once: true, }) }, } return resources .acquire( async () => { try { await open() } catch (error) { if (signal.aborted) throw error // A server that is not up yet (a slow `npx`, a short outage) gets // its tools at a later turn. setStatus({ name, status: 'failed', error: errorText(error), toolCount: 0, }) void reconnect() } return link }, () => client?.close(), ) .catch((error: unknown) => new Error(errorText(error))) } /** How long a connection stays open before the backoff starts again at 0.5 s. */ const stableMs = 30_000 /** * The wait before reconnect `attempt` (0 is the first): 0.5 s, 1 s, 2 s, * and so on, at most 30 s. A random part of up to half the wait keeps the * sessions of many hosts from connecting at the same moment. */ function backoffMs(attempt: number) { const full = Math.min(500 * 2 ** attempt, 30_000) return full / 2 + Math.random() * (full / 2) } /** Resolves after `ms`, or at once when `signal` aborts. */ function delay(ms: number, signal: AbortSignal) { return new Promise<void>((resolve) => { // An aborted signal never fires `abort` again. if (signal.aborted) return resolve() const done = () => { clearTimeout(timer) signal.removeEventListener('abort', done) resolve() } const timer = setTimeout(done, ms) signal.addEventListener('abort', done, { once: true }) }) } /** The `needsApproval` of a server for the client and the connector. */ function approvalOf(server: McpServerConfig) { const { needsApproval } = server if (typeof needsApproval !== 'boolean') return needsApproval return () => needsApproval } /** * The tools of all servers, in server order. A tool whose name an earlier * server already gives is dropped. Each pair of servers warns once, to * `logger`. */ function keepFirstTools( found: ReadonlyArray<{ name: string; tools: ReadonlyArray<AnyTool> }>, warned: Set<string>, logger: Logger, ) { const owners = new Map<string, string>() const kept: Array<AnyTool> = [] for (const { name, tools } of found) { for (const tool of tools) { const owner = owners.get(tool.name) if (owner === undefined) { owners.set(tool.name, name) kept.push(tool) continue } const pair = JSON.stringify([owner, name]) if (warned.has(pair)) continue warned.add(pair) logger.warn( `[@tanstack/ai-mcp] The MCP servers ${JSON.stringify(owner)} and ${JSON.stringify(name)} both give the tool ${JSON.stringify(tool.name)}. The model gets the tool of ${JSON.stringify(owner)}. Set toolName on one of the servers to rename its tools.`, ) } } return kept } /** * The tools of all servers, in server order. A server that gives a tool name * of an earlier server gets the status `failed`, and none of its tools. */ async function failOnDuplicate( found: ReadonlyArray<{ name: string; tools: ReadonlyArray<AnyTool> }>, setStatus: (status: McpServerStatus) => Promise<void>, ) { const owners = new Map<string, string>() const kept: Array<AnyTool> = [] for (const { name, tools } of found) { const clash = tools.find((tool) => owners.has(tool.name)) if (clash) { const owner = owners.get(clash.name) await setStatus({ name, status: 'failed', error: `The MCP servers ${JSON.stringify(owner)} and ${JSON.stringify(name)} both give the tool ${JSON.stringify(clash.name)}. Set toolName on one of the servers to rename its tools.`, toolCount: 0, }) continue } for (const tool of tools) owners.set(tool.name, name) kept.push(...tools) } return kept } /** A tool call that runs now: its session, and the run that calls. */ export interface Caller { session: PluginSessionApi run?: RunContext } /** * Puts each call of `tools` in `calling` while it runs, with its session * and its run, so a spec 2025 elicitation can find the run that calls. */ export function trackCaller( tools: ReadonlyArray<AnyTool>, session: PluginSessionApi, calling: Array<Caller>, ) { return tools.map((tool): AnyTool => { const execute = tool.execute if (!execute) return tool return { ...tool, execute: async (args, context) => { const run = runOf(context) const caller: Caller = { session, ...(run ? { run } : {}) } calling.push(caller) try { return await execute(args, context) } finally { calling.splice(calling.lastIndexOf(caller), 1) } }, } }) } /** * The harness asks the user for form and URL elicitations, so its clients * declare both. `chat()` clients keep the default: form only. */ export const harnessClientOptions = { capabilities: { elicitation: { form: {}, url: {} } }, } /** * Ask the session user when an MCP tool asks for input (a form or a URL * elicitation). The tool call waits for the answer, then sends it to the * server. On spec 2026, a tool call can ask up to 5 times. The answer is the * form content, or an MCP result such as `{ action: 'decline' }` or * `{ action: 'cancel' }`. */ export function askForInput( tools: ReadonlyArray<AnyTool>, session: PluginSessionApi, ) { return tools.map((tool): AnyTool => { const execute = tool.execute if (!execute) return tool return { ...tool, execute: (args, context) => { // The question is a wait of the run of this call. const run = runOf(context) const askInput: AskInput = async (request) => ({ status: 'resolved', payload: await ask(session, request, run), }) return execute(args, { ...(isRecord(context) ? context : {}), askInput, }) }, } }) } /** * Answers the `elicitation/create` requests of a spec 2025 server. The * request does not name the tool call that it belongs to. So it asks only * when the calls of `calling` all belong to one run of one session, as a * live wait of that run. With no call, or with calls of more than one run * (two users or tenants can share a server), it declines: a question must * never go to the user of another run. */ export function elicitFor(calling: ReadonlyArray<Caller>): OnElicit { return async (params) => { const [caller] = calling if (!caller) return { action: 'decline' } const isOneRun = calling.every( (other) => other.session === caller.session && other.run !== undefined && other.run.runId === caller.run?.runId, ) if (!isOneRun) return { action: 'decline' } return elicitResult(await ask(caller.session, params, caller.run)) } } /** * Asks the session user for one MCP elicitation request body, as a live * wait of `run`, by the name `mcp-elicitation`. With no run, it declines: * only a run can wait for the user. */ async function ask( session: PluginSessionApi, request: unknown, run: RunContext | undefined, ) { if (!run) return { action: 'decline' } const body = isRecord(request) ? request : {} const url = body.mode === 'url' && typeof body.url === 'string' ? body.url : undefined const answer = await session.ask({ message: typeof body.message === 'string' ? body.message : 'The MCP server asks for input.', schema: isJsonSchema(body.requestedSchema) ? body.requestedSchema : undefined, ...(url ? { url } : {}), live: { name: 'mcp-elicitation', run }, }) // A URL request has no content: the answer only says "done". return url !== undefined && !(isRecord(answer) && 'action' in answer) ? { action: 'accept' } : answer } /** Maps an answer to the MCP result: decline, cancel, or accept. */ function elicitResult(answer: unknown): ElicitResult { if ( isRecord(answer) && (answer.action === 'decline' || answer.action === 'cancel') ) { return { action: answer.action } } const content = isRecord(answer) && answer.action === 'accept' ? answer.content : answer return isElicitContent(content) ? { action: 'accept', content } : { action: 'accept' } } // ponytail: the SDK checks the result against the schema of the request. function isElicitContent( value: unknown, ): value is NonNullable<ElicitResult['content']> { return isRecord(value) } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null } /** The run of a harness tool call, from its context: `ctx.context.run`. */ function runOf(context: unknown): RunContext | undefined { const run = isRecord(context) && isRecord(context.context) ? context.context.run : undefined return isRunContext(run) ? run : undefined } // ponytail: the harness sets `ctx.context.run`. A run id and a signal tell // it apart from other context values. function isRunContext(value: unknown): value is RunContext { return ( isRecord(value) && typeof value.runId === 'string' && value.signal instanceof AbortSignal ) } // ponytail: the server owns the schema. The session passes it on as is. function isJsonSchema(value: unknown): value is JSONSchema { return isRecord(value) } /** The SDK request options for `timeoutMs`. */ function requestOptionsOf(server: McpServerConfig) { return server.timeoutMs === undefined ? undefined : { timeout: server.timeoutMs } } /** Marks the tools of a `codeMode: true` server for the `codeMode()` plugin. */ function forCodeMode(server: McpServerConfig, tools: ReadonlyArray<AnyTool>) { if (server.codeMode !== true) return tools return tools.map((tool) => ({ ...tool, metadata: { ...tool.metadata, codeMode: true }, })) } function statusLine(server: McpServerStatus) { switch (server.status) { case 'connecting': return `${server.name}: connecting` case 'reconnecting': return `${server.name}: reconnecting` case 'connected': { const unit = server.toolCount === 1 ? 'tool' : 'tools' return `${server.name}: connected (${server.toolCount} ${unit})` } case 'failed': return `${server.name}: failed: ${server.error}` } } /** The error text for the status. A connect error keeps its reason in `cause`. */ function errorText(error: unknown) { if (!(error instanceof Error)) return String(error) const { cause } = error return cause instanceof Error ? `${error.message}: ${cause.message}` : error.message }