UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

86 lines (79 loc) 3.11 kB
import type { ServerTool } from '../tools/tool-definition' import type { ChatMCPOptions, MCPToolSource } from './types' export class MCPDuplicateToolNameError extends Error { constructor(public readonly toolName: string) { super( `Duplicate MCP tool name "${toolName}" in chat({ mcp.clients }). ` + `Set a unique \`prefix\` on one of the MCP clients (or use a pool, ` + `which auto-prefixes) to disambiguate.`, ) this.name = 'MCPDuplicateToolNameError' } } /** * Encapsulates MCP tool discovery + connection lifecycle for chat(). * Built from chat()'s `mcp` option; runners only call `discover()` then * `dispose()`. A manager built from `undefined` is an inert no-op * (`discover()` → `[]`, `dispose()` → no-op), so runners need no branching. */ export class MCPManager { static from(options: ChatMCPOptions | undefined): MCPManager { return new MCPManager(options) } readonly #sources: ReadonlyArray<MCPToolSource> readonly #shouldClose: boolean readonly #lazyTools: boolean readonly #onDiscoveryError?: ( error: unknown, source: MCPToolSource, ) => void | Promise<void> private constructor(options: ChatMCPOptions | undefined) { this.#sources = options?.clients ?? [] // default 'close'; only 'keep-alive' disables closing this.#shouldClose = options ? options.connection !== 'keep-alive' : false this.#lazyTools = options?.lazyTools ?? false this.#onDiscoveryError = options?.onDiscoveryError } /** * Discover + merge tools from all sources. Throws on a fatal discovery error * (no `onDiscoveryError`, or it re-threw) or a duplicate tool name; in that * case it first closes any connected sources when the policy is 'close'. */ async discover(): Promise<Array<ServerTool>> { if (this.#sources.length === 0) return [] try { const settled = await Promise.allSettled( this.#sources.map((s) => s.tools({ lazy: this.#lazyTools })), ) const tools: Array<ServerTool> = [] const zipped = this.#sources.map( (source, i) => [source, settled[i]] as const, ) for (const [source, result] of zipped) { if (result === undefined) continue if (result.status === 'fulfilled') { tools.push(...result.value) } else if (this.#onDiscoveryError) { // throw/reject inside handler ⇒ propagate (fail-fast); return ⇒ skip await this.#onDiscoveryError(result.reason, source) } else { throw result.reason } } const seen = new Set<string>() for (const t of tools) { if (seen.has(t.name)) throw new MCPDuplicateToolNameError(t.name) seen.add(t.name) } return tools } catch (err) { await this.dispose() // cleanup-on-failure (no-op if keep-alive) throw err } } /** Close sources iff policy is 'close'. Idempotent; never throws. */ async dispose(): Promise<void> { if (!this.#shouldClose || this.#sources.length === 0) return await Promise.allSettled(this.#sources.map((s) => s.close())) } }