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[number]['name'] type ToolByName< TTools extends ReadonlyArray, TName extends string, > = Extract type ResourceUris> = Extract< TResources[number], { uri: string } >['uri'] type ResourceByUri< TResources extends ReadonlyArray<{ uri?: string }>, TUri extends string, > = Extract type PromptNames> = TPrompts[number]['name'] type PromptByName< TPrompts extends ReadonlyArray, TName extends string, > = Extract type ResourceContents = TResource extends { read: (...args: never) => infer TResult } ? Awaited : never type PromptArgs = TPrompt extends { render: (input: infer TArgs) => unknown } ? TArgs : never type PromptMessages = TPrompt extends { render: (input: never) => infer TResult } ? Awaited : 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({ * transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, * }) * await client.callTool('get_weather', { city: 'Paris' }) * ``` */ export type DescriptorFromServer = { tools: { [TTool in TServer['tools'][number] as TTool['name']]: { input: InferToolInput output: InferToolOutput } } resources: { [TResource in Extract< TServer['resources'][number], { uri: string } > as TResource['uri']]: { uri: TResource['uri'] data: ResourceContents } } prompts: { [TPrompt in TServer['prompts'][number] as TPrompt['name']]: { args: PromptArgs messages: PromptMessages } } capabilities: Record } // 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 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( server: TServer, ) { const tools = server.tools as ReadonlyArray const resources = server.resources as ReadonlyArray const prompts = server.prompts as ReadonlyArray return { server, async callTool>( name: TName, args: InferToolInput>, 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>, context?: DirectToolContext, ) => | InferToolOutput> | Promise>> // A schema that is not a Standard Schema passes `args` through. const input = parseWithStandardSchema< InferToolInput> >(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> }, /** * Reads a resource with a fixed `uri`. `context` reaches the resource * on `ctx.context`. It is empty when you leave it out. */ async readResource>( 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 ) => | ResourceContents> | Promise>> return read(new URL(uri), {}, { context }) }, async getPrompt>( name: TName, args: PromptArgs>, ) { 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>, ) => | PromptMessages> | Promise>> return render(args) }, } } export type DirectMCPClient = ReturnType< typeof directMCPClient > export type DirectClientOptions = { server: TServer }