@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
text/typescript
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('; ')} }`
}