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.

561 lines (560 loc) • 19 kB
import { connectTransport } from "./client.js"; import { defineCommand, definePlugin } from "@tanstack/ai-harness"; //#region src/harness-plugin.ts /** * 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] * ``` */ function mcp(options) { const servers = Object.entries(options.servers); return definePlugin({ name: "tanstack/mcp", lifetime: "host", setup: (host) => { const calling = []; const askCaller = elicitFor(calling); const shared = /* @__PURE__ */ new Map(); for (const [name, server] of servers) { if (server.type !== "stdio") continue; shared.set(name, openServer(host.resources, name, server, askCaller)); } const warned = /* @__PURE__ */ new Set(); 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, servers, shared, calling, warned, onDuplicate) { const statuses = /* @__PURE__ */ new Map(); for (const [name] of servers) statuses.set(name, { name, status: "connecting", toolCount: 0 }); const state = ctx.state({ servers: [...statuses.values()] }); const setStatus = async (status) => { 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()] })); }; 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, server) => { const sharedServer = shared.get(name); const own = []; const opening = (sharedServer ?? openServer(ctx.resources, name, server, elicitFor(own))).then(async (link) => { if (link instanceof Error) { await setStatus({ name, status: "failed", error: link.message, toolCount: 0 }).catch(() => void 0); return; } const update = () => setStatus(link.status()).catch(() => void 0); link.watch(() => void update(), ctx.resources.signal); await update(); return link; }); return async (_turn) => { const link = await opening; if (!link) return []; return askForInput(trackCaller(await link.tools(), ctx.session, sharedServer ? calling : own), ctx.session); }; }; const commands = { mcp: defineCommand({ description: "Show the MCP servers and their status", run: () => [...statuses.values()].map(statusLine).join("\n") }) }; const prompts = []; const lists = await Promise.all(servers.map(async (entry) => { const [name, server] = entry; if (!(server.type === "http" && server.oauth === true)) return { name, list: connect(name, server) }; const { mcpConnector } = await import("./connector.js"); 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); const list = async (turn) => { 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) { await setStatus({ name, status: "failed", error: errorText(error), toolCount: 0 }); return []; } }; return { name, list }; })); return { commands, prompts, discoverTools: async (turn) => { 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); } }; } /** * 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, name, server, onElicit) { const { signal } = resources; const watchers = /* @__PURE__ */ new Set(); let status = { name, status: "connecting", toolCount: 0 }; let client; let tools = []; let stale = false; let listing; let attempt = 0; let openedAt = 0; const setStatus = (next) => { status = next; for (const watcher of watchers) watcher(); }; /** One connection with its tool list. Throws when the server fails. */ const open = async () => { const transport = server.type === "stdio" ? (await import("./stdio.js")).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; try { listed = forCodeMode(server, await next.tools()); } catch (error) { await next.close().catch(() => void 0); throw error; } if (signal.aborted) { await next.close().catch(() => void 0); 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 = void 0; tools = []; if (Date.now() - openedAt >= stableMs) attempt = 0; setStatus({ name, status: "reconnecting", toolCount: 0 }); reconnect(); }; const reconnect = async () => { for (;;) { await delay(backoffMs(attempt++), signal); if (signal.aborted) return; if (await open().then(() => true, () => false)) return; } }; /** List the tools of `current` again. On a failure, the old list stays. */ const relist = async (current) => { stale = false; const listed = await current.tools().then((found) => forCodeMode(server, found), () => void 0); if (client !== current) return; if (listed === void 0) { 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 = void 0; }); await listing; } return tools; }, /** Calls `watcher` after each status change, until `until` aborts. */ watch: (watcher, until) => { 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; setStatus({ name, status: "failed", error: errorText(error), toolCount: 0 }); reconnect(); } return link; }, () => client?.close()).catch((error) => new Error(errorText(error))); } /** How long a connection stays open before the backoff starts again at 0.5 s. */ var stableMs = 3e4; /** * 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) { const full = Math.min(500 * 2 ** attempt, 3e4); return full / 2 + Math.random() * (full / 2); } /** Resolves after `ms`, or at once when `signal` aborts. */ function delay(ms, signal) { return new Promise((resolve) => { 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) { 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, warned, logger) { const owners = /* @__PURE__ */ new Map(); const kept = []; for (const { name, tools } of found) for (const tool of tools) { const owner = owners.get(tool.name); if (owner === void 0) { 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, setStatus) { const owners = /* @__PURE__ */ new Map(); const kept = []; 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; } /** * 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. */ function trackCaller(tools, session, calling) { return tools.map((tool) => { const execute = tool.execute; if (!execute) return tool; return { ...tool, execute: async (args, context) => { const run = runOf(context); const 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. */ var 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' }`. */ function askForInput(tools, session) { return tools.map((tool) => { const execute = tool.execute; if (!execute) return tool; return { ...tool, execute: (args, context) => { const run = runOf(context); const 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. */ function elicitFor(calling) { return async (params) => { const [caller] = calling; if (!caller) return { action: "decline" }; if (!calling.every((other) => other.session === caller.session && other.run !== void 0 && other.run.runId === caller.run?.runId)) 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, request, run) { if (!run) return { action: "decline" }; const body = isRecord(request) ? request : {}; const url = body.mode === "url" && typeof body.url === "string" ? body.url : void 0; const answer = await session.ask({ message: typeof body.message === "string" ? body.message : "The MCP server asks for input.", schema: isJsonSchema(body.requestedSchema) ? body.requestedSchema : void 0, ...url ? { url } : {}, live: { name: "mcp-elicitation", run } }); return url !== void 0 && !(isRecord(answer) && "action" in answer) ? { action: "accept" } : answer; } /** Maps an answer to the MCP result: decline, cancel, or accept. */ function elicitResult(answer) { 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" }; } function isElicitContent(value) { return isRecord(value); } function isRecord(value) { return typeof value === "object" && value !== null; } /** The run of a harness tool call, from its context: `ctx.context.run`. */ function runOf(context) { const run = isRecord(context) && isRecord(context.context) ? context.context.run : void 0; return isRunContext(run) ? run : void 0; } function isRunContext(value) { return isRecord(value) && typeof value.runId === "string" && value.signal instanceof AbortSignal; } function isJsonSchema(value) { return isRecord(value); } /** The SDK request options for `timeoutMs`. */ function requestOptionsOf(server) { return server.timeoutMs === void 0 ? void 0 : { timeout: server.timeoutMs }; } /** Marks the tools of a `codeMode: true` server for the `codeMode()` plugin. */ function forCodeMode(server, tools) { if (server.codeMode !== true) return tools; return tools.map((tool) => ({ ...tool, metadata: { ...tool.metadata, codeMode: true } })); } function statusLine(server) { 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) { if (!(error instanceof Error)) return String(error); const { cause } = error; return cause instanceof Error ? `${error.message}: ${cause.message}` : error.message; } //#endregion export { askForInput, elicitFor, harnessClientOptions, mcp, trackCaller }; //# sourceMappingURL=harness-plugin.js.map