@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.
215 lines (205 loc) • 8.39 kB
text/typescript
import { createMCPClient } from './client'
import { DuplicateToolNameError, MCPConnectionError } from './errors'
import type { MCPClient } from './client'
import type {
MCPClientOptions,
McpServerTool,
ServerDescriptor,
ToolsOptions,
} from './types'
import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js'
import type { TransportConfig } from './transport'
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'
export type MCPClientsConfig = Record<string, MCPClientOptions>
export interface MCPClients<
TServers extends Record<string, ServerDescriptor> = Record<
string,
ServerDescriptor
>,
> {
/** Typed per-server access (typed defs, resources, prompts on one server). */
readonly clients: { [K in keyof TServers]: MCPClient<TServers[K]> }
/**
* All servers' tools, flattened and auto-prefixed by config key.
* `options` (including `lazy`) is forwarded to every client's `tools()`.
*/
tools: (options?: ToolsOptions) => Promise<Array<McpServerTool>>
/**
* Reads an MCP resource by URI, routing to the owning client. A `ui://`
* resource read must hit the server that owns it; since the pool does not
* track ownership, each underlying client is tried in turn and the first
* success is returned. If every client fails, the last error is thrown.
*
* Required so a pool source emits `ui-resource` events for MCP Apps widgets
* (the chat manager binds `readResource` only when the source exposes it).
*/
readResource: (uri: string) => Promise<ReadResourceResult>
/**
* The connection descriptors for every server in the pool, keyed by config
* key (the serverId / default prefix). Used by `createMcpAppCallHandler` to
* reconnect per-call (serverless-safe) without a separate transport-config
* map. Each value mirrors the owning client's `getInfo()`.
*/
getServers: () => Record<
string,
{
transport: TransportConfig | undefined
prefix: string | undefined
clientOptions?: ClientOptions
}
>
/** Close every client. */
close: () => Promise<void>
[Symbol.asyncDispose]: () => Promise<void>
}
export async function createMCPClients<
TServers extends Record<string, ServerDescriptor> = Record<
string,
ServerDescriptor
>,
>(
// When TServers is a generated `MCPServers` map, the config keys are
// constrained to the declared servers (missing/typo'd key → compile error).
config: { [K in keyof TServers]: MCPClientOptions } & MCPClientsConfig,
): Promise<MCPClients<TServers>> {
const names = Object.keys(config)
// Connect all in parallel; on any failure, close the successes and throw once.
const settled = await Promise.allSettled(
names.map(async (name) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const opts = config[name]!
// default prefix = config key; `prefix: ''` disables; explicit string wins
const prefix = opts.prefix === undefined ? name : opts.prefix || undefined
const client = await createMCPClient({ ...opts, prefix })
return [name, client] as const
}),
)
const ok = settled.filter(
(
r,
): r is PromiseFulfilledResult<
readonly [string, MCPClient<ServerDescriptor>]
> => r.status === 'fulfilled',
)
const failed = settled
.map((r, i) => (r.status === 'rejected' ? names[i] : null))
.filter((n): n is string => n !== null)
if (failed.length > 0) {
// Cleanup already-connected clients — no leaks.
await Promise.allSettled(ok.map((r) => r.value[1].close()))
// Attach the first rejection's reason as the cause so the underlying
// connect error isn't lost (mirrors the tools() path).
const firstRejection = settled.find(
(r): r is PromiseRejectedResult => r.status === 'rejected',
)
throw new MCPConnectionError(
`Failed to connect MCP server(s): ${failed.join(', ')}`,
firstRejection?.reason,
)
}
// Cast via `unknown`: the runtime map is descriptor-agnostic
// (`MCPClient<ServerDescriptor>` values), but per-key the public type is the
// narrowed `MCPClient<TServers[K]>`. Those no longer structurally overlap
// because `tools()` is now descriptor-typed (`DescriptorTools<TServer>`), yet
// the generated descriptor is a compile-time overlay only — the runtime
// values are identical, so the through-`unknown` cast is sound here.
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- descriptor is a compile-time overlay; runtime MCPClient values are identical regardless of TServer
const clients = Object.fromEntries(ok.map((r) => r.value)) as unknown as {
[K in keyof TServers]: MCPClient<TServers[K]>
}
const pool: MCPClients<TServers> = {
clients,
async tools(options?: ToolsOptions): Promise<Array<McpServerTool>> {
// Settle (like the connect path) so a single failing server is reported
// by config key instead of rejecting with an unattributed SDK error.
const entries = Object.entries(clients)
const results = await Promise.allSettled(
entries.map(([, c]) =>
(c as MCPClient<ServerDescriptor>).tools(options),
),
)
const failedNames = entries
.map(([key], i) => (results[i]?.status === 'rejected' ? key : null))
.filter((k): k is string => k !== null)
if (failedNames.length > 0) {
const firstFailure = results.find(
(r): r is PromiseRejectedResult => r.status === 'rejected',
)
throw new MCPConnectionError(
`Failed to list tools from MCP server(s): ${failedNames.join(', ')}`,
firstFailure?.reason,
)
}
const all = results.flatMap((r) =>
r.status === 'fulfilled' ? r.value : [],
)
const seen = new Set<string>()
for (const t of all) {
if (seen.has(t.name)) throw new DuplicateToolNameError(t.name)
seen.add(t.name)
}
return all
},
getServers(): Record<
string,
{
transport: TransportConfig | undefined
prefix: string | undefined
clientOptions?: ClientOptions
}
> {
// Keyed by config key (serverId / default prefix). Read each underlying
// client's original descriptor via getInfo().
return Object.fromEntries(
Object.entries(clients).map(([key, c]) => [key, c.getInfo()]),
)
},
async readResource(uri: string): Promise<ReadResourceResult> {
// Ownership isn't tracked, so try each client. A non-owning server may
// resolve an unrelated URI, so only accept a result whose `contents`
// actually include the requested `uri`; otherwise keep trying. A ui://
// read must reach the server that owns it.
const errors: Array<unknown> = []
const all = Object.values(clients)
for (const c of all) {
try {
const result = await (c as MCPClient<ServerDescriptor>).readResource(
uri,
)
if (result.contents.some((entry) => entry.uri === uri)) {
return result
}
} catch (err) {
errors.push(err)
}
}
// Distinguish the two failure modes and never leave `cause` undefined:
// - at least one client threw → attach EVERY thrown error as an
// AggregateError cause. Keeping all of them matters in a multi-server
// pool: if the owning server fails first and an unrelated server fails
// after, a "last error wins" cause would bury the error you actually need.
// - every client responded but none owned the uri → there is no thrown
// error to attach, so explain that the uri was not found on any server.
if (errors.length > 0) {
throw new Error(
`Failed to read MCP resource "${uri}": no client could resolve it (${errors.length} error(s) attached)`,
{ cause: new AggregateError(errors) },
)
}
throw new Error(
`Failed to read MCP resource "${uri}": no configured MCP server owns this uri`,
)
},
async close(): Promise<void> {
await Promise.all(
Object.values(clients).map((c) =>
(c as MCPClient<ServerDescriptor>).close(),
),
)
},
async [Symbol.asyncDispose](): Promise<void> {
await pool.close()
},
}
return pool
}