/** * GatewayClient — Aggregates tools, resources, and prompts from multiple * MCP clients into a single unified Client. * * Key features: * - Lazy client resolution (factory functions called on first use, cached) * - Auto-pagination (fetches all pages from upstream clients) * - Tool/prompt namespacing via slugified client keys (e.g. "my-server_toolName") * - Per-client selection filtering (optional allowlist) * - Metadata tagging (_meta.gatewayClientId on every item) */ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { CallToolRequest, CallToolResult, ClientCapabilities, CompatibilityCallToolResult, GetPromptRequest, GetPromptResult, Implementation, ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult, ListToolsResult, Prompt, ReadResourceRequest, ReadResourceResult, Resource, ResourceTemplate, ServerCapabilities, Tool, } from "@modelcontextprotocol/sdk/types.js"; import type { IClient } from "../client-like.ts"; /** * A concrete IClient instance or a factory that produces one (sync or async). * Factories are invoked lazily on first use and the result is cached. */ export type ClientOrFactory = IClient | (() => IClient | Promise); /** * Per-client entry with optional selection filters. * When a selection array is provided, only items whose names appear in it * are included. An empty array blocks all items. Undefined means pass all. */ export interface ClientEntry { client: ClientOrFactory; tools?: string[]; resources?: string[]; prompts?: string[]; } /** * Slugify a string for use as a namespace prefix. * Produces lowercase alphanumeric + hyphens. */ export function slugify(input: string): string { return input .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } /** * Short, stable namespace code for a connection key. * * The aggregated tool name is `${namespaceCode(key)}_${toolName}`. Downstream * clients often add their OWN prefix on top (e.g. Hermes prepends * `mcp__`, ~21 chars), and tool names are capped at 64 chars by the * Anthropic API (`^[A-Za-z0-9_-]{1,64}$`). Using the full slugified connection * id (~26 chars) as the prefix blew the budget once a second prefix was added. * * This produces a 7-char code (`a` + 6 base36 chars of an FNV-1a hash) with NO * underscore, so `resolveToolTarget`'s split on the first `_` still cleanly * separates the prefix from the (possibly `_`-containing) tool name. Collisions * are astronomically unlikely for a handful of connections and, if they ever * happen, the GatewayClient constructor throws on a duplicate code. */ export function namespaceCode(input: string): string { let h = 0x811c9dc5; for (let i = 0; i < input.length; i++) { h = Math.imul(h ^ input.charCodeAt(i), 0x01000193); } return `a${(h >>> 0).toString(36).padStart(6, "0").slice(-6)}`; } /** * Extract `gatewayClientId` from an item's `_meta` object. * Returns `undefined` when the field is absent or not a string. */ export function getGatewayClientId(meta: unknown): string | undefined { if ( meta && typeof meta === "object" && "gatewayClientId" in meta && typeof (meta as Record).gatewayClientId === "string" ) { return (meta as Record).gatewayClientId as string; } return undefined; } /** * Strip the gateway namespace prefix from a tool/prompt name. * Requires `clientId` to compute the exact prefix to remove. * Returns the input unchanged when no `clientId` is provided or the prefix doesn't match. */ export function stripToolNamespace( namespacedName: string, clientId?: string, ): string { if (!clientId) return namespacedName; const prefix = `${namespaceCode(clientId)}_`; return namespacedName.startsWith(prefix) ? namespacedName.slice(prefix.length) : namespacedName; } /** * Strip namespace and normalize for display: removes the slug prefix, * replaces `_` and `-` with spaces, and lowercases the result. * Pair with CSS `capitalize` for Title Case rendering. */ export function displayToolName( namespacedName: string, clientId?: string, ): string { return stripToolNamespace(namespacedName, clientId) .replace(/[_-]/g, " ") .toLowerCase(); } /** * Convert a kebab/snake-case prompt name to a human-readable Title Case string. * e.g. "agents-create" → "Agents Create" */ function titleFromName(name: string): string { return name .split(/[-_]/) .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w)) .join(" "); } export interface GatewayClientOptions { clientInfo?: Implementation; capabilities?: ClientCapabilities; } export class GatewayClient extends Client { /** Hard cap on pages fetched per client per list call — see `paginate`. */ private static readonly MAX_PAGES_PER_CLIENT = 1000; private readonly clients: Record; private readonly slugToKey = new Map(); /** Cache of resolved client promises keyed by client key. */ private readonly resolvedClients = new Map>(); /** Cached list results — set to null to invalidate. */ private toolsCache: Promise | null = null; private resourcesCache: Promise | null = null; private resourceTemplatesCache: Promise | null = null; private promptsCache: Promise | null = null; /** Route map for resources (URIs aren't namespaced). */ private resourceRouteMap = new Map(); constructor( clients: Record, options?: GatewayClientOptions, ) { super(options?.clientInfo ?? { name: "gateway-client", version: "1.0.0" }, { capabilities: options?.capabilities, }); this.clients = clients; for (const key of Object.keys(clients)) { const slug = namespaceCode(key); if (this.slugToKey.has(slug)) { throw new Error( `GatewayClient: duplicate namespace code "${slug}" from keys "${this.slugToKey.get(slug)}" and "${key}"`, ); } this.slugToKey.set(slug, key); } } // --------------------------------------------------------------------------- // Namespacing // --------------------------------------------------------------------------- private namespace(clientKey: string, name: string): string { return `${namespaceCode(clientKey)}_${name}`; } /** * Resolve a tool name to [clientKey, originalName]. * Fast path: parse namespace prefix. Fallback: search aggregated tools * for an un-namespaced match (supports callers that don't know about * namespacing, e.g. workflow tool steps). */ private async resolveToolTarget( name: string, ): Promise<[clientKey: string, originalName: string]> { // Fast path: namespace prefix matches a known client const sep = name.indexOf("_"); if (sep !== -1) { const slug = name.slice(0, sep); const clientKey = this.slugToKey.get(slug); if (clientKey) { return [clientKey, name.slice(sep + 1)]; } } // Fallback: search aggregated tools by original (un-namespaced) name const { tools } = await this.listTools(); for (const tool of tools) { const clientId = getGatewayClientId(tool._meta); if (!clientId) continue; if (stripToolNamespace(tool.name, clientId) === name) { return [clientId, name]; } } // Nothing matched — throw the original-style error if (sep === -1) { throw new Error( `GatewayClient: could not resolve tool "${name}" — no namespace prefix and not found in any client`, ); } throw new Error( `GatewayClient: unknown namespace "${name.slice(0, sep)}" in "${name}" and not found by original name in any client`, ); } /** * Resolve a prompt name to [clientKey, originalName]. * Same logic as resolveToolTarget but searches prompts. */ private async resolvePromptTarget( name: string, ): Promise<[clientKey: string, originalName: string]> { // Fast path: namespace prefix matches a known client const sep = name.indexOf("_"); if (sep !== -1) { const slug = name.slice(0, sep); const clientKey = this.slugToKey.get(slug); if (clientKey) { return [clientKey, name.slice(sep + 1)]; } } // Fallback: search aggregated prompts by original (un-namespaced) name const { prompts } = await this.listPrompts(); for (const prompt of prompts) { const clientId = getGatewayClientId(prompt._meta); if (!clientId) continue; if (stripToolNamespace(prompt.name, clientId) === name) { return [clientId, name]; } } if (sep === -1) { throw new Error( `GatewayClient: could not resolve prompt "${name}" — no namespace prefix and not found in any client`, ); } throw new Error( `GatewayClient: unknown namespace "${name.slice(0, sep)}" in "${name}" and not found by original name in any client`, ); } // --------------------------------------------------------------------------- // Client resolution // --------------------------------------------------------------------------- /** * Resolve a ClientOrFactory to a concrete IClient. The resolved Promise is * cached so concurrent calls for the same key share a single resolution. * If the factory throws, the cached promise is removed so subsequent calls * retry the factory. */ private resolveClient(key: string): Promise { const existing = this.resolvedClients.get(key); if (existing) { return existing; } const entry = this.clients[key]; if (!entry) { return Promise.reject( new Error(`GatewayClient: unknown client key "${key}"`), ); } const clientOrFactory = entry.client; let promise: Promise; try { promise = typeof clientOrFactory === "function" ? Promise.resolve(clientOrFactory()) : Promise.resolve(clientOrFactory); } catch (err) { // A factory that throws synchronously (vs. returning a rejected // promise) must still surface as a rejection — resolveClient's // Promise contract otherwise breaks for sync callers like // getResolvedClient(), which would throw instead of returning a // rejected promise. return Promise.reject(err); } // Remove from cache on failure so subsequent calls retry const guarded = promise.catch((err) => { this.resolvedClients.delete(key); throw err; }); this.resolvedClients.set(key, guarded); return guarded; } /** * Public access to a resolved client by key. */ getResolvedClient(key: string): Promise { return this.resolveClient(key); } // --------------------------------------------------------------------------- // Auto-pagination helpers // --------------------------------------------------------------------------- /** * Resolve one upstream client and list its items, tolerating failure. A * single unreachable/unauthenticated connection (401, circuit-breaker-open, * transport error) must NOT abort the whole aggregation — that would take * down the entire agent run over one broken connection. Log and contribute * nothing instead; the caller degrades to the connections that did resolve. */ private async listFromClient( clientKey: string, fetchAll: (client: IClient) => Promise, kind: string, ): Promise { try { const client = await this.resolveClient(clientKey); return await fetchAll(client); } catch (err) { console.warn( `GatewayClient: skipping ${kind} from client "${clientKey}" — ${ (err as Error)?.message ?? err }`, ); return []; } } /** * Follow an MCP `nextCursor` pagination chain, capped at * MAX_PAGES_PER_CLIENT pages. Without a cap, a misbehaving or malicious * upstream MCP server that never returns a falsy `nextCursor` would hang * gateway aggregation forever — this is untrusted input from a * user-configured connection, not a value we control. */ private async paginate( clientKey: string, kind: string, fetchPage: ( cursor?: string, ) => Promise<{ items: T[]; nextCursor?: string }>, ): Promise { const items: T[] = []; let cursor: string | undefined; let pages = 0; do { const page = await fetchPage(cursor); items.push(...page.items); cursor = page.nextCursor; pages++; } while (cursor && pages < GatewayClient.MAX_PAGES_PER_CLIENT); if (cursor) { console.warn( `GatewayClient: client "${clientKey}" exceeded ${GatewayClient.MAX_PAGES_PER_CLIENT} pages listing ${kind} — truncating`, ); } return items; } private fetchAllTools(client: IClient, clientKey: string): Promise { return this.paginate(clientKey, "tools", async (cursor) => { const result = await client.listTools(cursor ? { cursor } : undefined); return { items: result.tools, nextCursor: result.nextCursor }; }); } private fetchAllResources( client: IClient, clientKey: string, ): Promise { return this.paginate(clientKey, "resources", async (cursor) => { const result = await client.listResources( cursor ? { cursor } : undefined, ); return { items: result.resources, nextCursor: result.nextCursor }; }); } private fetchAllResourceTemplates( client: IClient, clientKey: string, ): Promise { return this.paginate(clientKey, "resource templates", async (cursor) => { const result = await client.listResourceTemplates( cursor ? { cursor } : undefined, ); return { items: result.resourceTemplates, nextCursor: result.nextCursor, }; }); } private fetchAllPrompts( client: IClient, clientKey: string, ): Promise { return this.paginate(clientKey, "prompts", async (cursor) => { const result = await client.listPrompts(cursor ? { cursor } : undefined); return { items: result.prompts, nextCursor: result.nextCursor }; }); } // --------------------------------------------------------------------------- // List methods (cached, namespaced, filtered) // --------------------------------------------------------------------------- override listTools( _params?: unknown, _options?: RequestOptions, ): Promise { if (!this.toolsCache) { this.toolsCache = this.aggregateTools(); } return this.toolsCache; } private async aggregateTools(): Promise { const tools: Tool[] = []; for (const [clientKey, entry] of Object.entries(this.clients)) { const clientTools = await this.listFromClient( clientKey, (c) => this.fetchAllTools(c, clientKey), "tools", ); const selected = entry.tools; const selectedSet = selected ? new Set(selected) : null; for (const tool of clientTools) { if (selectedSet && !selectedSet.has(tool.name)) continue; tools.push({ ...tool, name: this.namespace(clientKey, tool.name), _meta: { ...(tool._meta ?? {}), gatewayClientId: clientKey, }, }); } } return { tools }; } override listResources( _params?: unknown, _options?: RequestOptions, ): Promise { if (!this.resourcesCache) { this.resourcesCache = this.aggregateResources(); } return this.resourcesCache; } private async aggregateResources(): Promise { const seen = new Set(); const resources: Resource[] = []; const routeMap = new Map(); for (const [clientKey, entry] of Object.entries(this.clients)) { const clientResources = await this.listFromClient( clientKey, (c) => this.fetchAllResources(c, clientKey), "resources", ); const selected = entry.resources; const selectedSet = selected ? new Set(selected) : null; for (const resource of clientResources) { if ( selectedSet && !selectedSet.has(resource.uri) && !(resource.name && selectedSet.has(resource.name)) ) continue; if (seen.has(resource.uri)) { console.warn( `GatewayClient: duplicate resource "${resource.uri}" from client "${clientKey}" — skipping`, ); continue; } seen.add(resource.uri); routeMap.set(resource.uri, clientKey); resources.push({ ...resource, _meta: { ...(resource._meta ?? {}), gatewayClientId: clientKey, }, }); } } this.resourceRouteMap = routeMap; return { resources }; } override listResourceTemplates( _params?: unknown, _options?: RequestOptions, ): Promise { if (!this.resourceTemplatesCache) { this.resourceTemplatesCache = this.aggregateResourceTemplates(); } return this.resourceTemplatesCache; } private async aggregateResourceTemplates(): Promise { const seen = new Set(); const resourceTemplates: ResourceTemplate[] = []; for (const [clientKey, _entry] of Object.entries(this.clients)) { const clientTemplates = await this.listFromClient( clientKey, (c) => this.fetchAllResourceTemplates(c, clientKey), "resource templates", ); for (const template of clientTemplates) { if (seen.has(template.uriTemplate)) { console.warn( `GatewayClient: duplicate resource template "${template.uriTemplate}" from client "${clientKey}" — skipping`, ); continue; } seen.add(template.uriTemplate); resourceTemplates.push({ ...template, _meta: { ...(template._meta ?? {}), gatewayClientId: clientKey, }, }); } } return { resourceTemplates }; } override listPrompts( _params?: unknown, _options?: RequestOptions, ): Promise { if (!this.promptsCache) { this.promptsCache = this.aggregatePrompts(); } return this.promptsCache; } private async aggregatePrompts(): Promise { const prompts: Prompt[] = []; for (const [clientKey, entry] of Object.entries(this.clients)) { const clientPrompts = await this.listFromClient( clientKey, (c) => this.fetchAllPrompts(c, clientKey), "prompts", ); const selected = entry.prompts; const selectedSet = selected ? new Set(selected) : null; for (const prompt of clientPrompts) { if (selectedSet && !selectedSet.has(prompt.name)) continue; prompts.push({ ...prompt, name: this.namespace(clientKey, prompt.name), title: prompt.title ?? titleFromName(prompt.name), _meta: { ...(prompt._meta ?? {}), gatewayClientId: clientKey, }, }); } } return { prompts }; } // --------------------------------------------------------------------------- // Routing: callTool / readResource / getPrompt // --------------------------------------------------------------------------- override async callTool( params: CallToolRequest["params"], resultSchema?: unknown, options?: RequestOptions, ): Promise { const [clientKey, originalName] = await this.resolveToolTarget(params.name); const client = await this.resolveClient(clientKey); return client.callTool( { ...params, name: originalName }, resultSchema, options, ); } override async readResource( params: ReadResourceRequest["params"], _options?: RequestOptions, ): Promise { const clientKey = await this.resolveResourceRoute(params.uri); const client = await this.resolveClient(clientKey); return client.readResource(params); } override async getPrompt( params: GetPromptRequest["params"], _options?: RequestOptions, ): Promise { const [clientKey, originalName] = await this.resolvePromptTarget( params.name, ); const client = await this.resolveClient(clientKey); return client.getPrompt({ ...params, name: originalName }); } /** * Look up a resource URI in the route map. If not found, refresh and retry. */ private async resolveResourceRoute(uri: string): Promise { let clientKey = this.resourceRouteMap.get(uri); if (clientKey) return clientKey; // Cache might be stale — refresh and retry this.resourcesCache = null; await this.listResources(); clientKey = this.resourceRouteMap.get(uri); if (clientKey) return clientKey; throw new Error( `GatewayClient: resource "${uri}" not found in any upstream client`, ); } // --------------------------------------------------------------------------- // Capabilities & instructions // --------------------------------------------------------------------------- override getServerCapabilities(): ServerCapabilities { return { tools: {}, resources: {}, prompts: {} }; } override getInstructions(): string | undefined { return undefined; } // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- /** * Invalidate all cached list results. The next call to any list method * will re-fetch from all upstream clients. */ refresh(): void { this.toolsCache = null; this.resourcesCache = null; this.resourceTemplatesCache = null; this.promptsCache = null; } /** * Close all resolved (materialized) clients. Uses Promise.allSettled so * a failure in one client does not prevent closing others. */ override async close(): Promise { const closePromises = [...this.resolvedClients.values()].map((p) => p .then((client) => client.close()) .catch(() => { // Intentionally ignored — partial close failures are acceptable }), ); await Promise.allSettled(closePromises); await super.close(); } }