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.

140 lines (131 loc) 4.79 kB
import { compile } from 'json-schema-to-typescript' import type { ServerSurface } from './introspect' /** * Convert an arbitrary server/tool name into a valid PascalCase identifier. * Falls back to `Generated` for names with no alphanumeric characters and * prefixes an underscore when the result would start with a digit. */ function pascal(name: string): string { const base = name .replace(/[^a-zA-Z0-9]+/g, ' ') .split(' ') .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join('') const safe = base || 'Generated' return /^[A-Za-z_]/.test(safe) ? safe : `_${safe}` } /** Escape a value as a TypeScript string literal (handles quotes/newlines). */ function tsString(value: string): string { return JSON.stringify(value) } /** * Compile a JSON Schema into a TypeScript type via `json-schema-to-typescript`. * Returns `'unknown'` for absent/non-object schemas. The compiled output is an * `export interface <typeName> { ... }` declaration which callers inline via * {@link inlineBody}. */ async function schemaToType( schema: unknown, typeName: string, ): Promise<string> { if (!schema || typeof schema !== 'object') return 'unknown' const compiled = await compile(schema, typeName, { bannerComment: '', additionalProperties: false, declareExternallyReferenced: true, }) return compiled.trim() } export interface EmitInput { [serverName: string]: { prefix?: string; surface: ServerSurface } } export async function emitDescriptors(input: EmitInput): Promise<string> { const blocks: Array<string> = [ '// AUTO-GENERATED by `npx @tanstack/ai-mcp generate`. Do not edit.', "import type { ServerDescriptor } from '@tanstack/ai-mcp'", '', ] // Track config-key -> interface-name so we can emit the combined pool map. const mapEntries: Array<[string, string]> = [] // Distinct server keys can pascal-case to the same identifier // (`foo-bar` vs `foo_bar`) — fail loudly rather than emit duplicate // interface declarations. const seenIfaces = new Set<string>() for (const [serverName, { prefix, surface }] of Object.entries(input)) { const iface = `${pascal(serverName)}Server` if (seenIfaces.has(iface)) { throw new Error( `Interface name collision for server key "${serverName}" -> ${iface}. ` + `Rename one of the colliding servers in your codegen config.`, ) } seenIfaces.add(iface) mapEntries.push([serverName, iface]) const toolEntries: Array<string> = [] for (const tool of surface.tools) { const key = prefix ? `${prefix}_${tool.name}` : tool.name const inputType = await schemaToType( tool.inputSchema, `${pascal(key)}Input`, ) const outputType = tool.outputSchema ? await schemaToType(tool.outputSchema, `${pascal(key)}Output`) : 'unknown' // Inline the compiled interface bodies as anonymous object types. toolEntries.push( ` ${tsString(key)}: { input: ${inlineBody(inputType)}; output: ${inlineBody(outputType)} }`, ) } blocks.push( `export interface ${iface} extends ServerDescriptor {`, ` tools: {`, toolEntries.join('\n'), ` }`, ` resources: ${emitResources(surface)}`, ` prompts: ${emitPrompts(surface)}`, ` capabilities: ${JSON.stringify(surface.capabilities)} & Record<string, unknown>`, `}`, '', ) } // Combined map for createMCPClients<MCPServers>(...). Keys = config keys // verbatim (NOT pascal-cased) so they match the runtime config object and // pool.clients access. blocks.push( 'export interface MCPServers extends Record<string, ServerDescriptor> {', ...mapEntries.map(([key, iface]) => ` ${tsString(key)}: ${iface}`), '}', '', ) return blocks.join('\n') } /** * Extract the `{ ... }` body from a compiled `export interface X { ... }` * declaration so it can be inlined as an anonymous object type. Collapses * newlines onto a single line. Falls back to `unknown` when no brace is found * (e.g. the compiled type is itself `unknown`). */ function inlineBody(compiled: string): string { const brace = compiled.indexOf('{') return brace >= 0 ? compiled.slice(brace).replace(/\n/g, ' ').trim() : 'unknown' } function emitResources(s: ServerSurface): string { if (!s.resources.length) return '{}' return `{ ${s.resources .map( (r) => `${tsString(r.uri)}: { uri: ${tsString(r.uri)}; data: unknown }`, ) .join('; ')} }` } function emitPrompts(s: ServerSurface): string { if (!s.prompts.length) return '{}' return `{ ${s.prompts .map( (p) => `${tsString(p.name)}: { args: Record<string, string>; messages: unknown }`, ) .join('; ')} }` }