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.

234 lines (210 loc) • 7.35 kB
import { parseWithStandardSchema } from '@tanstack/ai' import type { InferToolInput, InferToolOutput } from '@tanstack/ai' import { createServerToolContext } from './server/context' import { optionsOfServer } from './server/registry' import { parseToolOutput } from './server/output' import { isCallToolResult } from '@modelcontextprotocol/client' import type { MCPServer } from './server/create-server' import type { MCPResourceContext, MCPResourceRead } from './server/definitions' type Named = { name: string } type ToolNames<TTools extends ReadonlyArray<Named>> = TTools[number]['name'] type ToolByName< TTools extends ReadonlyArray<Named>, TName extends string, > = Extract<TTools[number], { name: TName }> type ResourceUris<TResources extends ReadonlyArray<{ uri?: string }>> = Extract< TResources[number], { uri: string } >['uri'] type ResourceByUri< TResources extends ReadonlyArray<{ uri?: string }>, TUri extends string, > = Extract<TResources[number], { uri: TUri }> type PromptNames<TPrompts extends ReadonlyArray<Named>> = TPrompts[number]['name'] type PromptByName< TPrompts extends ReadonlyArray<Named>, TName extends string, > = Extract<TPrompts[number], { name: TName }> type ResourceContents<TResource> = TResource extends { read: (...args: never) => infer TResult } ? Awaited<TResult> : never type PromptArgs<TPrompt> = TPrompt extends { render: (input: infer TArgs) => unknown } ? TArgs : never type PromptMessages<TPrompt> = TPrompt extends { render: (input: never) => infer TResult } ? Awaited<TResult> : never /** * The client types of a `createMCPServer` server, for a client that * connects over a transport. * * Pass `typeof server` to `createMCPClient` with a `transport`. * Import the server with `import type`, so its code stays out of the client. * * @example * ```ts * import type { server } from './mcp-server' * * const client = await createMCPClient<typeof server>({ * transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, * }) * await client.callTool('get_weather', { city: 'Paris' }) * ``` */ export type DescriptorFromServer<TServer extends MCPServer> = { tools: { [TTool in TServer['tools'][number] as TTool['name']]: { input: InferToolInput<TTool> output: InferToolOutput<TTool> } } resources: { [TResource in Extract< TServer['resources'][number], { uri: string } > as TResource['uri']]: { uri: TResource['uri'] data: ResourceContents<TResource> } } prompts: { [TPrompt in TServer['prompts'][number] as TPrompt['name']]: { args: PromptArgs<TPrompt> messages: PromptMessages<TPrompt> } } capabilities: Record<string, unknown> } // The same context shape that a spec 2026 call on the server gets. function directToolContext(server: object, signal: AbortSignal | undefined) { return { context: createServerToolContext({ era: '2026', sample: optionsOfServer(server)?.sample, }), abortSignal: signal ?? new AbortController().signal, emitCustomEvent() {}, } } type DirectToolContext = ReturnType<typeof directToolContext> type ListedTool = { name: string inputSchema?: unknown outputSchema?: unknown execute?: (input: never, context?: DirectToolContext) => unknown } type ListedResource = { uri?: string read: MCPResourceRead } type ListedPrompt = { name: string render: (input: never) => unknown } /** * Calls the tools, resources, and prompts on one TanStack MCP server. * * `server` is the object from `createMCPServer`. * The tool names, resource URIs, and prompt arguments stay typed. * This client does not open a network connection. * `callTool` checks `args` with the tool input schema and parses the output * with its output schema, like the HTTP server. * The tool gets the spec 2026 context: `ctx.context.requestInput` throws * `ToolInputRequiredError`, and `ctx.context.sample` uses the server * `sample` option. * * @param server - The server object to call * * @example * ```ts * const client = directMCPClient(server) * await client.callTool('get_weather', { city: 'Paris' }) * ``` */ export function directMCPClient<const TServer extends MCPServer>( server: TServer, ) { const tools = server.tools as ReadonlyArray<ListedTool> const resources = server.resources as ReadonlyArray<ListedResource> const prompts = server.prompts as ReadonlyArray<ListedPrompt> return { server, async callTool<const TName extends ToolNames<TServer['tools']>>( name: TName, args: InferToolInput<ToolByName<TServer['tools'], TName>>, options?: { signal?: AbortSignal }, ) { const tool = tools.find((item) => item.name === name) if (tool === undefined || tool.execute === undefined) { throw new Error(`The MCP server has no tool ${name}.`) } const execute = tool.execute as ( input: InferToolInput<ToolByName<TServer['tools'], TName>>, context?: DirectToolContext, ) => | InferToolOutput<ToolByName<TServer['tools'], TName>> | Promise<InferToolOutput<ToolByName<TServer['tools'], TName>>> // A schema that is not a Standard Schema passes `args` through. const input = parseWithStandardSchema< InferToolInput<ToolByName<TServer['tools'], TName>> >(tool.inputSchema, args) const output = await execute( input, directToolContext(server, options?.signal), ) // Parse like the HTTP server does, so both return the same value. return (await parseToolOutput( tool, output, isCallToolResult, )) as InferToolOutput<ToolByName<TServer['tools'], TName>> }, /** * Reads a resource with a fixed `uri`. `context` reaches the resource * on `ctx.context`. It is empty when you leave it out. */ async readResource<const TUri extends ResourceUris<TServer['resources']>>( uri: TUri, context: MCPResourceContext['context'] = {}, ) { const resource = resources.find((item) => item.uri === uri) if (resource === undefined) { throw new Error(`The MCP server has no resource ${uri}.`) } const read = resource.read as ( ...args: Parameters<ListedResource['read']> ) => | ResourceContents<ResourceByUri<TServer['resources'], TUri>> | Promise<ResourceContents<ResourceByUri<TServer['resources'], TUri>>> return read(new URL(uri), {}, { context }) }, async getPrompt<const TName extends PromptNames<TServer['prompts']>>( name: TName, args: PromptArgs<PromptByName<TServer['prompts'], TName>>, ) { const prompt = prompts.find((item) => item.name === name) if (prompt === undefined) { throw new Error(`The MCP server has no prompt ${name}.`) } const render = prompt.render as ( input: PromptArgs<PromptByName<TServer['prompts'], TName>>, ) => | PromptMessages<PromptByName<TServer['prompts'], TName>> | Promise<PromptMessages<PromptByName<TServer['prompts'], TName>>> return render(args) }, } } export type DirectMCPClient<TServer extends MCPServer> = ReturnType< typeof directMCPClient<TServer> > export type DirectClientOptions<TServer extends MCPServer = MCPServer> = { server: TServer }