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.

171 lines (156 loc) • 5.07 kB
import { SSEClientTransport, StreamableHTTPClientTransport, } from '@modelcontextprotocol/client' import type { OAuthClientProvider, Transport, } from '@modelcontextprotocol/client' import { isRecord } from './events/value' export interface HttpTransportConfig { type: 'http' url: string headers?: Record<string, string> fetch?: typeof fetch authProvider?: OAuthClientProvider } export interface SseTransportConfig { type: 'sse' url: string headers?: Record<string, string> fetch?: typeof fetch authProvider?: OAuthClientProvider } /** stdio is declared here for typing but constructed only via `@tanstack/ai-mcp/stdio`. */ export interface StdioTransportConfig { type: 'stdio' command: string args?: Array<string> env?: Record<string, string> cwd?: string } export type TransportConfig = | HttpTransportConfig | SseTransportConfig | StdioTransportConfig /** Either a built-in config or a ready-made Transport instance (escape hatch). */ export type TransportInput = TransportConfig | Transport /** * Return true when `input` is already a Transport, not a config object. */ export function isTransportInstance(input: TransportInput): input is Transport { return 'start' in input && typeof input.start === 'function' } /** * SDK 2.1.0 does not send `Mcp-Name` for custom `events/*` methods. * SEP-3415 requires it on Streamable HTTP for name-scoped events requests. */ async function mcpNameFromRpc(request: Request): Promise<string | undefined> { if (request.method !== 'POST') return undefined let json: unknown try { json = await request.clone().json() } catch { return undefined } if (!isRecord(json)) return undefined if ( json.method !== 'events/subscribe' && json.method !== 'events/unsubscribe' ) { return undefined } if (!isRecord(json.params) || typeof json.params.name !== 'string') { return undefined } return json.params.name } const MCP_NAME_SENTINEL_PREFIX = '=?base64?' const MCP_NAME_SENTINEL_SUFFIX = '?=' /** * Copy of SDK 2.1.0 `needsBase64` / `encodeMcpParamValue` (not exported). * Printable ASCII with no leading/trailing whitespace is sent as-is. * Anything else uses `=?base64?{b64-of-utf8}?=` so `Headers.set` stays ASCII. */ function needsBase64(value: string): boolean { if (value.length === 0) return true if ( value.startsWith(MCP_NAME_SENTINEL_PREFIX) && value.endsWith(MCP_NAME_SENTINEL_SUFFIX) ) { return true } if (value !== value.trim()) return true for (let i = 0; i < value.length; i++) { const code = value.codePointAt(i) if (code === 9 || (code !== undefined && code >= 32 && code <= 126)) { continue } return true } return false } function utf8ToBase64(value: string): string { const bytes = new TextEncoder().encode(value) let binary = '' for (const byte of bytes) binary += String.fromCodePoint(byte) return btoa(binary) } function encodeMcpName(value: string): string { return needsBase64(value) ? `${MCP_NAME_SENTINEL_PREFIX}${utf8ToBase64(value)}${MCP_NAME_SENTINEL_SUFFIX}` : value } function fetchWithMcpEventName(baseFetch: typeof fetch = fetch): typeof fetch { return async (input, init) => { const request = new Request(input, init) if (request.headers.has('mcp-name')) { return baseFetch(input, init) } const name = await mcpNameFromRpc(request) if (name === undefined) return baseFetch(input, init) const headers = new Headers(request.headers) headers.set('Mcp-Name', encodeMcpName(name)) return baseFetch(new Request(request, { headers })) } } /** * Build a Transport from HTTP config, SSE config, or an existing Transport. * * For stdio, build the Transport with `stdioTransport` and pass that instance. * Throws an Error when the config type is `stdio` or is not a known type. * With `events`, HTTP and SSE requests for `events/*` get the `Mcp-Name` header. */ export async function resolveTransport( input: TransportInput, options?: { events?: boolean }, ) { if (isTransportInstance(input)) return input const withEvents = (base?: typeof fetch) => options?.events ? fetchWithMcpEventName(base) : base switch (input.type) { case 'http': return new StreamableHTTPClientTransport(new URL(input.url), { requestInit: { headers: input.headers }, fetch: withEvents(input.fetch), authProvider: input.authProvider, }) case 'sse': return new SSEClientTransport(new URL(input.url), { requestInit: { headers: input.headers }, fetch: withEvents(input.fetch), authProvider: input.authProvider, }) case 'stdio': throw new Error( "stdio transport must be created via '@tanstack/ai-mcp/stdio': " + "import { stdioTransport } from '@tanstack/ai-mcp/stdio' and pass the result as `transport`.", ) default: { const unknownConfig: never = input throw new Error( `Unknown MCP transport config: ${JSON.stringify(unknownConfig)}`, ) } } }