+1077
-181
@@ -26,3 +26,3 @@ "use strict"; | ||
| // src/cli.ts | ||
| var import_node_process6 = __toESM(require("process"), 1); | ||
| var import_node_process7 = __toESM(require("process"), 1); | ||
| var import_commander = require("commander"); | ||
@@ -33,3 +33,3 @@ | ||
| name: "dynmcp", | ||
| version: "0.2.0", | ||
| version: "0.3.0", | ||
| description: "Dynamic MCP context management tool for AI MCP-enabled agents and clients.", | ||
@@ -114,2 +114,3 @@ author: "Brandon Burrus <brandon@burrus.io>", | ||
| "@types/node": "^25.9.0", | ||
| "@vitest/coverage-v8": "^4.1.6", | ||
| husky: "^9.1.7", | ||
@@ -128,4 +129,4 @@ tsup: "^8.5.1", | ||
| // src/proxy/index.ts | ||
| var import_node_process5 = __toESM(require("process"), 1); | ||
| var import_stdio2 = require("@modelcontextprotocol/sdk/client/stdio.js"); | ||
| var import_node_process6 = __toESM(require("process"), 1); | ||
| var import_stdio3 = require("@modelcontextprotocol/sdk/client/stdio.js"); | ||
@@ -392,29 +393,35 @@ // src/config/schema.ts | ||
| // src/proxy/transport-factory.ts | ||
| var import_stdio = require("@modelcontextprotocol/sdk/client/stdio.js"); | ||
| var import_streamableHttp = require("@modelcontextprotocol/sdk/client/streamableHttp.js"); | ||
| var import_sse = require("@modelcontextprotocol/sdk/client/sse.js"); | ||
| function createTransport(config) { | ||
| switch (config.transport) { | ||
| case "stdio": | ||
| return new import_stdio.StdioClientTransport({ | ||
| command: config.command, | ||
| args: config.args, | ||
| env: config.env | ||
| }); | ||
| case "streamable-http": | ||
| return new import_streamableHttp.StreamableHTTPClientTransport( | ||
| new URL(config.url), | ||
| config.headers ? { requestInit: { headers: config.headers } } : void 0 | ||
| ); | ||
| case "sse": | ||
| return new import_sse.SSEClientTransport( | ||
| new URL(config.url), | ||
| config.headers ? { requestInit: { headers: config.headers } } : void 0 | ||
| ); | ||
| default: { | ||
| const _exhaustive = config; | ||
| return _exhaustive; | ||
| // src/proxy/orchestrator.ts | ||
| var import_node_process4 = __toESM(require("process"), 1); | ||
| // src/proxy/capability-aggregator.ts | ||
| function aggregateCapabilities(upstreams) { | ||
| const aggregated = { | ||
| tools: { listChanged: true } | ||
| }; | ||
| for (const caps of upstreams) { | ||
| if (caps === void 0) continue; | ||
| if (caps.resources !== void 0) { | ||
| aggregated.resources ??= {}; | ||
| if (caps.resources.subscribe === true) { | ||
| aggregated.resources.subscribe = true; | ||
| } | ||
| if (caps.resources.listChanged === true) { | ||
| aggregated.resources.listChanged = true; | ||
| } | ||
| } | ||
| if (caps.prompts !== void 0) { | ||
| aggregated.prompts ??= {}; | ||
| if (caps.prompts.listChanged === true) { | ||
| aggregated.prompts.listChanged = true; | ||
| } | ||
| } | ||
| if (caps.logging !== void 0) { | ||
| aggregated.logging ??= {}; | ||
| } | ||
| if (caps.completions !== void 0) { | ||
| aggregated.completions ??= {}; | ||
| } | ||
| } | ||
| return aggregated; | ||
| } | ||
@@ -525,11 +532,247 @@ | ||
| // src/proxy/notification-forwarder.ts | ||
| var NotificationForwarder = class { | ||
| constructor(registry, resourceRouter, promptRouter, toolsByMcp, setToolCatalog, namespaced) { | ||
| this.registry = registry; | ||
| this.resourceRouter = resourceRouter; | ||
| this.promptRouter = promptRouter; | ||
| this.toolsByMcp = toolsByMcp; | ||
| this.setToolCatalog = setToolCatalog; | ||
| this.namespaced = namespaced; | ||
| } | ||
| registry; | ||
| resourceRouter; | ||
| promptRouter; | ||
| toolsByMcp; | ||
| setToolCatalog; | ||
| namespaced; | ||
| hostHandlers = {}; | ||
| setHostHandlers(handlers) { | ||
| this.hostHandlers = handlers; | ||
| } | ||
| async handleToolsListChanged(mcpName2) { | ||
| const client = this.registry.get(mcpName2); | ||
| if (client === void 0) return; | ||
| const tools = await client.listTools().catch(() => []); | ||
| this.toolsByMcp.set(mcpName2, tools); | ||
| const rebuilt = this.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []); | ||
| this.setToolCatalog(rebuilt); | ||
| await this.hostHandlers.onToolsListChanged?.(); | ||
| } | ||
| async handleResourcesListChanged(mcpName2) { | ||
| const router = this.resourceRouter(); | ||
| const client = this.registry.get(mcpName2); | ||
| if (router === null || client === void 0) return; | ||
| const [resources, templates] = await Promise.all([ | ||
| client.listResources().catch(() => []), | ||
| client.listResourceTemplates().catch(() => []) | ||
| ]); | ||
| router.setResources(mcpName2, resources); | ||
| router.setTemplates(mcpName2, templates); | ||
| await this.hostHandlers.onResourcesListChanged?.(); | ||
| } | ||
| async handleResourceUpdated(params) { | ||
| await this.hostHandlers.onResourceUpdated?.(params); | ||
| } | ||
| async handlePromptsListChanged(mcpName2) { | ||
| const router = this.promptRouter(); | ||
| const client = this.registry.get(mcpName2); | ||
| if (router === null || client === void 0) return; | ||
| const prompts = await client.listPrompts().catch(() => []); | ||
| router.setPrompts(mcpName2, prompts); | ||
| await this.hostHandlers.onPromptsListChanged?.(); | ||
| } | ||
| /** | ||
| * Rewrites the upstream's `logger` field with the originating MCP's name as a | ||
| * prefix so the host can attribute log lines, then forwards the message to the | ||
| * host's log message handler. | ||
| */ | ||
| async handleLogMessage(mcpName2, params) { | ||
| const handler = this.hostHandlers.onLogMessage; | ||
| if (handler === void 0) return; | ||
| if (!this.namespaced) { | ||
| await handler(params); | ||
| return; | ||
| } | ||
| const prefixed = { | ||
| ...params, | ||
| logger: params.logger === void 0 ? mcpName2 : `${mcpName2}/${params.logger}` | ||
| }; | ||
| await handler(prefixed); | ||
| } | ||
| }; | ||
| // src/proxy/prompt-router.ts | ||
| var PromptRouter = class { | ||
| mcpOrder; | ||
| perMcp; | ||
| nameOwners = /* @__PURE__ */ new Map(); | ||
| detectedCollisions = []; | ||
| constructor(mcpOrder) { | ||
| this.mcpOrder = [...mcpOrder]; | ||
| this.perMcp = new Map(this.mcpOrder.map((name) => [name, []])); | ||
| } | ||
| setPrompts(mcpName2, prompts) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry === void 0) { | ||
| throw new Error(`PromptRouter: unknown mcp "${mcpName2}"`); | ||
| } | ||
| this.perMcp.set(mcpName2, [...prompts]); | ||
| this.rebuild(); | ||
| } | ||
| aggregatedPrompts() { | ||
| const result = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry !== void 0) { | ||
| result.push(...entry); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| ownerOf(promptName) { | ||
| return this.nameOwners.get(promptName); | ||
| } | ||
| collisions() { | ||
| return this.detectedCollisions; | ||
| } | ||
| rebuild() { | ||
| this.nameOwners = /* @__PURE__ */ new Map(); | ||
| const collisions = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const prompts = this.perMcp.get(mcpName2); | ||
| if (prompts === void 0) continue; | ||
| for (const prompt of prompts) { | ||
| const existing = this.nameOwners.get(prompt.name); | ||
| if (existing === void 0) { | ||
| this.nameOwners.set(prompt.name, mcpName2); | ||
| } else { | ||
| collisions.push({ name: prompt.name, chosen: existing, shadowed: mcpName2 }); | ||
| } | ||
| } | ||
| } | ||
| this.detectedCollisions = collisions; | ||
| } | ||
| }; | ||
| // src/proxy/resource-router.ts | ||
| var ResourceRouter = class { | ||
| mcpOrder; | ||
| perMcp; | ||
| uriOwners = /* @__PURE__ */ new Map(); | ||
| templateOwners = []; | ||
| detectedCollisions = []; | ||
| constructor(mcpOrder) { | ||
| this.mcpOrder = [...mcpOrder]; | ||
| this.perMcp = new Map( | ||
| this.mcpOrder.map((name) => [name, { resources: [], templates: [] }]) | ||
| ); | ||
| } | ||
| setResources(mcpName2, resources) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry === void 0) { | ||
| throw new Error(`ResourceRouter: unknown mcp "${mcpName2}"`); | ||
| } | ||
| entry.resources = [...resources]; | ||
| this.rebuild(); | ||
| } | ||
| setTemplates(mcpName2, templates) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry === void 0) { | ||
| throw new Error(`ResourceRouter: unknown mcp "${mcpName2}"`); | ||
| } | ||
| entry.templates = [...templates]; | ||
| this.rebuild(); | ||
| } | ||
| aggregatedResources() { | ||
| const result = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry !== void 0) { | ||
| result.push(...entry.resources); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| aggregatedTemplates() { | ||
| const result = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry !== void 0) { | ||
| result.push(...entry.templates); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| /** | ||
| * Returns the mcpName that owns the given URI, or undefined if no upstream advertises it. | ||
| * Concrete URI matches take precedence over template prefix matches; templates are tried | ||
| * in config-file order (first-wins). | ||
| */ | ||
| ownerOf(uri) { | ||
| const concrete = this.uriOwners.get(uri); | ||
| if (concrete !== void 0) { | ||
| return concrete; | ||
| } | ||
| for (const { prefix, mcpName: mcpName2 } of this.templateOwners) { | ||
| if (prefix.length > 0 && uri.startsWith(prefix)) { | ||
| return mcpName2; | ||
| } | ||
| } | ||
| return void 0; | ||
| } | ||
| collisions() { | ||
| return this.detectedCollisions; | ||
| } | ||
| rebuild() { | ||
| this.uriOwners = /* @__PURE__ */ new Map(); | ||
| this.templateOwners = []; | ||
| const collisions = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry === void 0) continue; | ||
| for (const resource of entry.resources) { | ||
| const existing = this.uriOwners.get(resource.uri); | ||
| if (existing === void 0) { | ||
| this.uriOwners.set(resource.uri, mcpName2); | ||
| } else { | ||
| collisions.push({ uri: resource.uri, chosen: existing, shadowed: mcpName2 }); | ||
| } | ||
| } | ||
| for (const template of entry.templates) { | ||
| this.templateOwners.push({ | ||
| prefix: literalPrefixOf(template.uriTemplate), | ||
| template, | ||
| mcpName: mcpName2 | ||
| }); | ||
| } | ||
| } | ||
| this.detectedCollisions = collisions; | ||
| } | ||
| }; | ||
| function literalPrefixOf(uriTemplate) { | ||
| const idx = uriTemplate.indexOf("{"); | ||
| return idx === -1 ? uriTemplate : uriTemplate.slice(0, idx); | ||
| } | ||
| // src/proxy/upstream-client.ts | ||
| var import_node_process3 = __toESM(require("process"), 1); | ||
| var import_client = require("@modelcontextprotocol/sdk/client/index.js"); | ||
| var import_types = require("@modelcontextprotocol/sdk/types.js"); | ||
| var UpstreamClient = class { | ||
| transport; | ||
| onTransportError; | ||
| notificationHandlers; | ||
| serverRequestHandlers; | ||
| client = null; | ||
| constructor({ name, transport, onTransportError }) { | ||
| constructor({ | ||
| name, | ||
| transport, | ||
| onTransportError, | ||
| notifications, | ||
| serverRequests | ||
| }) { | ||
| this.transport = transport; | ||
| this.notificationHandlers = notifications ?? {}; | ||
| this.serverRequestHandlers = serverRequests ?? {}; | ||
| this.onTransportError = onTransportError ?? ((error) => { | ||
@@ -542,10 +785,76 @@ import_node_process3.default.stderr.write(`[${name}] Upstream MCP transport error: ${error.message} | ||
| this.transport.onerror = this.onTransportError; | ||
| this.client = new import_client.Client({ name: "dynamic-discovery-mcp", version: "1.0.0" }); | ||
| this.client = new import_client.Client( | ||
| { name: "dynamic-discovery-mcp", version: "1.0.0" }, | ||
| { | ||
| capabilities: { | ||
| // Declare every client-side capability the proxy may relay on behalf of the host. | ||
| // Actual reachability of each feature depends on what the host supports — if the | ||
| // host does not support sampling, for instance, the host call returns an error | ||
| // which we forward back to the upstream verbatim. | ||
| sampling: {}, | ||
| elicitation: {}, | ||
| roots: { listChanged: true } | ||
| } | ||
| } | ||
| ); | ||
| this.registerServerRequestHandlers(this.client); | ||
| if (this.notificationHandlers.onToolsListChanged !== void 0) { | ||
| this.client.setNotificationHandler(import_types.ToolListChangedNotificationSchema, async () => { | ||
| await this.notificationHandlers.onToolsListChanged?.(); | ||
| }); | ||
| } | ||
| if (this.notificationHandlers.onResourcesListChanged !== void 0) { | ||
| this.client.setNotificationHandler(import_types.ResourceListChangedNotificationSchema, async () => { | ||
| await this.notificationHandlers.onResourcesListChanged?.(); | ||
| }); | ||
| } | ||
| if (this.notificationHandlers.onResourceUpdated !== void 0) { | ||
| this.client.setNotificationHandler(import_types.ResourceUpdatedNotificationSchema, async (notification) => { | ||
| await this.notificationHandlers.onResourceUpdated?.({ uri: notification.params.uri }); | ||
| }); | ||
| } | ||
| if (this.notificationHandlers.onPromptsListChanged !== void 0) { | ||
| this.client.setNotificationHandler(import_types.PromptListChangedNotificationSchema, async () => { | ||
| await this.notificationHandlers.onPromptsListChanged?.(); | ||
| }); | ||
| } | ||
| if (this.notificationHandlers.onLogMessage !== void 0) { | ||
| this.client.setNotificationHandler(import_types.LoggingMessageNotificationSchema, async (notification) => { | ||
| await this.notificationHandlers.onLogMessage?.(notification.params); | ||
| }); | ||
| } | ||
| await this.client.connect(this.transport); | ||
| } | ||
| async listTools() { | ||
| if (this.client === null) { | ||
| throw new Error("Client is not connected. Call connect() first."); | ||
| async setLoggingLevel(level, options) { | ||
| const client = this.requireClient(); | ||
| await client.setLoggingLevel(level, options); | ||
| } | ||
| async listPrompts(options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.listPrompts(void 0, options); | ||
| return result.prompts; | ||
| } | ||
| async getPrompt(name, args, options) { | ||
| const client = this.requireClient(); | ||
| const params = { name }; | ||
| if (args !== void 0) { | ||
| params.arguments = args; | ||
| } | ||
| const result = await this.client.listTools(); | ||
| return client.getPrompt(params, options); | ||
| } | ||
| async complete(params, options) { | ||
| const client = this.requireClient(); | ||
| return client.complete(params, options); | ||
| } | ||
| /** | ||
| * Returns the capabilities advertised by the upstream server during initialize. | ||
| * Returns `undefined` if the client is not connected, or if the SDK has not yet | ||
| * recorded the server's capabilities (e.g. during a partially-completed handshake). | ||
| */ | ||
| getCapabilities() { | ||
| return this.client?.getServerCapabilities(); | ||
| } | ||
| async listTools(options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.listTools(void 0, options); | ||
| return result.tools.map((tool) => { | ||
@@ -572,9 +881,29 @@ const upstreamTool = { | ||
| } | ||
| async callTool(name, input) { | ||
| if (this.client === null) { | ||
| throw new Error("Client is not connected. Call connect() first."); | ||
| } | ||
| const result = await this.client.callTool({ name, arguments: input }); | ||
| async callTool(name, input, options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.callTool({ name, arguments: input }, void 0, options); | ||
| return result; | ||
| } | ||
| async listResources(options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.listResources(void 0, options); | ||
| return result.resources; | ||
| } | ||
| async listResourceTemplates(options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.listResourceTemplates(void 0, options); | ||
| return result.resourceTemplates; | ||
| } | ||
| async readResource(uri, options) { | ||
| const client = this.requireClient(); | ||
| return client.readResource({ uri }, options); | ||
| } | ||
| async subscribeResource(uri, options) { | ||
| const client = this.requireClient(); | ||
| await client.subscribeResource({ uri }, options); | ||
| } | ||
| async unsubscribeResource(uri, options) { | ||
| const client = this.requireClient(); | ||
| await client.unsubscribeResource({ uri }, options); | ||
| } | ||
| async disconnect() { | ||
@@ -587,27 +916,65 @@ if (this.client === null) { | ||
| } | ||
| /** | ||
| * Sends `notifications/roots/list_changed` to the upstream, letting it know that | ||
| * the host's set of filesystem roots has changed. | ||
| */ | ||
| async sendRootsListChanged() { | ||
| const client = this.requireClient(); | ||
| await client.sendRootsListChanged(); | ||
| } | ||
| registerServerRequestHandlers(client) { | ||
| if (this.serverRequestHandlers.onCreateMessage !== void 0) { | ||
| client.setRequestHandler( | ||
| import_types.CreateMessageRequestSchema, | ||
| async (request, extra) => { | ||
| return this.serverRequestHandlers.onCreateMessage(request.params, { | ||
| signal: extra.signal | ||
| }); | ||
| } | ||
| ); | ||
| } | ||
| if (this.serverRequestHandlers.onElicitInput !== void 0) { | ||
| client.setRequestHandler( | ||
| import_types.ElicitRequestSchema, | ||
| async (request, extra) => { | ||
| return this.serverRequestHandlers.onElicitInput(request.params, { | ||
| signal: extra.signal | ||
| }); | ||
| } | ||
| ); | ||
| } | ||
| if (this.serverRequestHandlers.onListRoots !== void 0) { | ||
| client.setRequestHandler( | ||
| import_types.ListRootsRequestSchema, | ||
| async (request, extra) => { | ||
| return this.serverRequestHandlers.onListRoots(request.params, { | ||
| signal: extra.signal | ||
| }); | ||
| } | ||
| ); | ||
| } | ||
| } | ||
| requireClient() { | ||
| if (this.client === null) { | ||
| throw new Error("Client is not connected. Call connect() first."); | ||
| } | ||
| return this.client; | ||
| } | ||
| }; | ||
| // src/proxy/orchestrator.ts | ||
| var Orchestrator = class { | ||
| config; | ||
| // src/proxy/upstream-registry.ts | ||
| var UpstreamRegistry = class { | ||
| clients = /* @__PURE__ */ new Map(); | ||
| toolCatalog = null; | ||
| constructor(config) { | ||
| this.config = config; | ||
| } | ||
| async connect() { | ||
| const groups = /* @__PURE__ */ new Map(); | ||
| async connectAll(entries) { | ||
| try { | ||
| for (const [mcpName2, { transport }] of this.config.mcps) { | ||
| for (const [mcpName2, config] of entries) { | ||
| const client = new UpstreamClient({ | ||
| name: mcpName2, | ||
| transport, | ||
| onTransportError: (error) => { | ||
| this.config.onTransportError?.(mcpName2, error); | ||
| } | ||
| transport: config.transport, | ||
| onTransportError: config.onTransportError, | ||
| notifications: config.notifications, | ||
| serverRequests: config.serverRequests | ||
| }); | ||
| await client.connect(); | ||
| const tools = await client.listTools(); | ||
| this.clients.set(mcpName2, client); | ||
| groups.set(mcpName2, tools); | ||
| } | ||
@@ -618,4 +985,127 @@ } catch (error) { | ||
| } | ||
| this.toolCatalog = ToolCatalog.fromGrouped(groups); | ||
| } | ||
| get(mcpName2) { | ||
| return this.clients.get(mcpName2); | ||
| } | ||
| /** | ||
| * Returns the sole connected client. Used by single-MCP (`--`) mode where the | ||
| * Orchestrator guarantees there is exactly one upstream. Returns undefined when | ||
| * zero or more than one client is connected. | ||
| */ | ||
| sole() { | ||
| if (this.clients.size !== 1) return void 0; | ||
| return this.clients.values().next().value; | ||
| } | ||
| names() { | ||
| return [...this.clients.keys()]; | ||
| } | ||
| entries() { | ||
| return this.clients.entries(); | ||
| } | ||
| size() { | ||
| return this.clients.size; | ||
| } | ||
| async disconnectAll() { | ||
| const disconnections = [...this.clients.values()].map((client) => client.disconnect()); | ||
| await Promise.all(disconnections); | ||
| this.clients.clear(); | ||
| } | ||
| }; | ||
| // src/proxy/orchestrator.ts | ||
| var Orchestrator = class { | ||
| config; | ||
| registry = new UpstreamRegistry(); | ||
| toolsByMcp = /* @__PURE__ */ new Map(); | ||
| resourceRouter = null; | ||
| promptRouter = null; | ||
| toolCatalog = null; | ||
| aggregatedCapabilities = null; | ||
| serverRequestForwarders = {}; | ||
| forwarder; | ||
| constructor(config) { | ||
| if (!config.namespaced && config.mcps.size !== 1) { | ||
| throw new Error( | ||
| `Single-MCP (non-namespaced) mode requires exactly one upstream; got ${config.mcps.size}.` | ||
| ); | ||
| } | ||
| this.config = config; | ||
| this.forwarder = new NotificationForwarder( | ||
| this.registry, | ||
| () => this.resourceRouter, | ||
| () => this.promptRouter, | ||
| this.toolsByMcp, | ||
| (catalog) => { | ||
| this.toolCatalog = catalog; | ||
| }, | ||
| this.config.namespaced | ||
| ); | ||
| } | ||
| setNotificationHandlers(handlers) { | ||
| this.forwarder.setHostHandlers(handlers); | ||
| } | ||
| setServerRequestForwarders(forwarders) { | ||
| this.serverRequestForwarders = forwarders; | ||
| } | ||
| async connect() { | ||
| const resourceRouter = new ResourceRouter([...this.config.mcps.keys()]); | ||
| const promptRouter = new PromptRouter([...this.config.mcps.keys()]); | ||
| const upstreamEntries = [ | ||
| ...this.config.mcps | ||
| ].map(([mcpName2, { transport }]) => [ | ||
| mcpName2, | ||
| { | ||
| transport, | ||
| onTransportError: (error) => { | ||
| this.config.onTransportError?.(mcpName2, error); | ||
| }, | ||
| notifications: { | ||
| onToolsListChanged: () => this.forwarder.handleToolsListChanged(mcpName2), | ||
| onResourcesListChanged: () => this.forwarder.handleResourcesListChanged(mcpName2), | ||
| onResourceUpdated: (params) => this.forwarder.handleResourceUpdated(params), | ||
| onPromptsListChanged: () => this.forwarder.handlePromptsListChanged(mcpName2), | ||
| onLogMessage: (params) => this.forwarder.handleLogMessage(mcpName2, params) | ||
| }, | ||
| serverRequests: { | ||
| onCreateMessage: (params, opts) => this.forwardCreateMessage(params, opts), | ||
| onElicitInput: (params, opts) => this.forwardElicitInput(params, opts), | ||
| onListRoots: (params, opts) => this.forwardListRoots(params, opts) | ||
| } | ||
| } | ||
| ]); | ||
| await this.registry.connectAll(upstreamEntries); | ||
| const capabilityList = []; | ||
| this.toolsByMcp.clear(); | ||
| for (const [mcpName2, client] of this.registry.entries()) { | ||
| const caps = client.getCapabilities(); | ||
| capabilityList.push(caps); | ||
| const tools = await client.listTools(); | ||
| this.toolsByMcp.set(mcpName2, tools); | ||
| if (caps?.resources !== void 0) { | ||
| const [resources, templates] = await Promise.all([ | ||
| client.listResources().catch(() => []), | ||
| client.listResourceTemplates().catch(() => []) | ||
| ]); | ||
| resourceRouter.setResources(mcpName2, resources); | ||
| resourceRouter.setTemplates(mcpName2, templates); | ||
| } | ||
| if (caps?.prompts !== void 0) { | ||
| const prompts = await client.listPrompts().catch(() => []); | ||
| promptRouter.setPrompts(mcpName2, prompts); | ||
| } | ||
| } | ||
| this.toolCatalog = this.config.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []); | ||
| this.aggregatedCapabilities = aggregateCapabilities(capabilityList); | ||
| this.resourceRouter = resourceRouter; | ||
| this.promptRouter = promptRouter; | ||
| logCollisions(resourceRouter, promptRouter); | ||
| } | ||
| async disconnectAll() { | ||
| await this.registry.disconnectAll(); | ||
| this.toolsByMcp.clear(); | ||
| this.toolCatalog = null; | ||
| this.aggregatedCapabilities = null; | ||
| this.resourceRouter = null; | ||
| this.promptRouter = null; | ||
| } | ||
| get catalog() { | ||
@@ -627,126 +1117,491 @@ if (this.toolCatalog === null) { | ||
| } | ||
| async callTool(namespacedName, input) { | ||
| const separatorIndex = namespacedName.indexOf("/"); | ||
| if (separatorIndex === -1) { | ||
| throw new Error( | ||
| `Invalid namespaced tool name: "${namespacedName}". Expected format: "mcpName/toolName".` | ||
| ); | ||
| get capabilities() { | ||
| if (this.aggregatedCapabilities === null) { | ||
| throw new Error("Orchestrator is not connected. Call connect() first."); | ||
| } | ||
| const mcpName2 = namespacedName.slice(0, separatorIndex); | ||
| const toolName = namespacedName.slice(separatorIndex + 1); | ||
| const client = this.clients.get(mcpName2); | ||
| return this.aggregatedCapabilities; | ||
| } | ||
| // === Forward-direction request routing === | ||
| async callTool(displayName, input, options) { | ||
| if (this.config.namespaced) { | ||
| const { mcpName: mcpName2, toolName } = splitNamespacedName(displayName, this.registry.names()); | ||
| return this.requireClient(mcpName2, "tool").callTool(toolName, input, options); | ||
| } | ||
| const sole = this.registry.sole(); | ||
| if (sole === void 0) { | ||
| throw new Error("Orchestrator is not connected. Call connect() first."); | ||
| } | ||
| return sole.callTool(displayName, input, options); | ||
| } | ||
| listResources() { | ||
| return this.requireResourceRouter().aggregatedResources(); | ||
| } | ||
| listResourceTemplates() { | ||
| return this.requireResourceRouter().aggregatedTemplates(); | ||
| } | ||
| async readResource(uri, options) { | ||
| return this.resolveResourceOwner(uri).readResource(uri, options); | ||
| } | ||
| async subscribeResource(uri, options) { | ||
| await this.resolveResourceOwner(uri).subscribeResource(uri, options); | ||
| } | ||
| async unsubscribeResource(uri, options) { | ||
| await this.resolveResourceOwner(uri).unsubscribeResource(uri, options); | ||
| } | ||
| listPrompts() { | ||
| return this.requirePromptRouter().aggregatedPrompts(); | ||
| } | ||
| async getPrompt(name, args, options) { | ||
| return this.resolvePromptOwner(name).getPrompt(name, args, options); | ||
| } | ||
| async complete(params, options) { | ||
| const client = this.resolveCompletionTarget(params.ref); | ||
| return client.complete(params, options); | ||
| } | ||
| // === Broadcasts === | ||
| /** | ||
| * Broadcasts a `logging/setLevel` request to every upstream advertising the logging | ||
| * capability. Errors from individual upstreams are swallowed so a single misbehaving | ||
| * upstream cannot break the broadcast for others; failures are written to stderr. | ||
| */ | ||
| async setLoggingLevel(level, options) { | ||
| await this.broadcastAsync( | ||
| (client) => client.getCapabilities()?.logging !== void 0 ? client.setLoggingLevel(level, options) : Promise.resolve(), | ||
| "setLoggingLevel" | ||
| ); | ||
| } | ||
| /** | ||
| * Broadcasts `notifications/roots/list_changed` to every connected upstream — the | ||
| * proxy declares roots capability uniformly to all upstreams so every one of them | ||
| * may have requested roots and needs to know the list changed. | ||
| */ | ||
| async broadcastRootsListChanged() { | ||
| await this.broadcastAsync((client) => client.sendRootsListChanged(), "sendRootsListChanged"); | ||
| } | ||
| // === Server-initiated request forwarders (upstream → host) === | ||
| async forwardCreateMessage(params, options) { | ||
| const handler = this.serverRequestForwarders.onCreateMessage; | ||
| if (handler === void 0) { | ||
| throw new Error("Proxy does not support sampling: host has not registered a handler."); | ||
| } | ||
| return handler(params, options); | ||
| } | ||
| async forwardElicitInput(params, options) { | ||
| const handler = this.serverRequestForwarders.onElicitInput; | ||
| if (handler === void 0) { | ||
| throw new Error("Proxy does not support elicitation: host has not registered a handler."); | ||
| } | ||
| return handler(params, options); | ||
| } | ||
| async forwardListRoots(params, options) { | ||
| const handler = this.serverRequestForwarders.onListRoots; | ||
| if (handler === void 0) { | ||
| throw new Error("Proxy does not support roots: host has not registered a handler."); | ||
| } | ||
| return handler(params, options); | ||
| } | ||
| // === Internal helpers === | ||
| requireResourceRouter() { | ||
| if (this.resourceRouter === null) { | ||
| throw new Error("Orchestrator is not connected. Call connect() first."); | ||
| } | ||
| return this.resourceRouter; | ||
| } | ||
| requirePromptRouter() { | ||
| if (this.promptRouter === null) { | ||
| throw new Error("Orchestrator is not connected. Call connect() first."); | ||
| } | ||
| return this.promptRouter; | ||
| } | ||
| resolveResourceOwner(uri) { | ||
| const owner = this.requireResourceRouter().ownerOf(uri); | ||
| if (owner === void 0) { | ||
| throw new Error(`Unknown resource URI: "${uri}". No upstream MCP advertises it.`); | ||
| } | ||
| return this.requireClient(owner, "resource"); | ||
| } | ||
| resolvePromptOwner(name) { | ||
| const owner = this.requirePromptRouter().ownerOf(name); | ||
| if (owner === void 0) { | ||
| throw new Error(`Unknown prompt: "${name}". No upstream MCP advertises it.`); | ||
| } | ||
| return this.requireClient(owner, "prompt"); | ||
| } | ||
| resolveCompletionTarget(ref) { | ||
| if (ref.type === "ref/prompt") { | ||
| return this.resolvePromptOwner(ref.name); | ||
| } | ||
| if (ref.type === "ref/resource") { | ||
| return this.resolveResourceOwner(ref.uri); | ||
| } | ||
| const unknownRef = ref; | ||
| throw new Error(`Unsupported completion ref type: "${unknownRef.type}"`); | ||
| } | ||
| requireClient(mcpName2, role) { | ||
| const client = this.registry.get(mcpName2); | ||
| if (client === void 0) { | ||
| const available = [...this.clients.keys()].sort().join(", "); | ||
| throw new Error(`Unknown MCP: "${mcpName2}". Available MCPs: ${available}`); | ||
| throw new Error(`Internal error: ${role} owner "${mcpName2}" has no connected client.`); | ||
| } | ||
| return client.callTool(toolName, input); | ||
| return client; | ||
| } | ||
| async disconnectAll() { | ||
| const disconnections = [...this.clients.values()].map((client) => client.disconnect()); | ||
| await Promise.all(disconnections); | ||
| this.clients.clear(); | ||
| this.toolCatalog = null; | ||
| async broadcastAsync(action, label) { | ||
| const targets = []; | ||
| for (const [mcpName2, client] of this.registry.entries()) { | ||
| targets.push( | ||
| action(client).catch((error) => { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| import_node_process4.default.stderr.write(`dynmcp: ${label} failed for "${mcpName2}": ${message} | ||
| `); | ||
| }) | ||
| ); | ||
| } | ||
| await Promise.all(targets); | ||
| } | ||
| }; | ||
| function splitNamespacedName(namespacedName, knownMcpNames) { | ||
| const separatorIndex = namespacedName.indexOf("/"); | ||
| if (separatorIndex === -1) { | ||
| throw new Error( | ||
| `Invalid namespaced tool name: "${namespacedName}". Expected format: "mcpName/toolName".` | ||
| ); | ||
| } | ||
| const mcpName2 = namespacedName.slice(0, separatorIndex); | ||
| const toolName = namespacedName.slice(separatorIndex + 1); | ||
| if (!knownMcpNames.includes(mcpName2)) { | ||
| const available = [...knownMcpNames].sort().join(", "); | ||
| throw new Error(`Unknown MCP: "${mcpName2}". Available MCPs: ${available}`); | ||
| } | ||
| return { mcpName: mcpName2, toolName }; | ||
| } | ||
| function logCollisions(resourceRouter, promptRouter) { | ||
| for (const collision of resourceRouter.collisions()) { | ||
| import_node_process4.default.stderr.write( | ||
| `dynmcp: resource URI collision: "${collision.uri}" is provided by "${collision.chosen}" and "${collision.shadowed}"; routing to "${collision.chosen}". | ||
| ` | ||
| ); | ||
| } | ||
| for (const collision of promptRouter.collisions()) { | ||
| import_node_process4.default.stderr.write( | ||
| `dynmcp: prompt name collision: "${collision.name}" is provided by "${collision.chosen}" and "${collision.shadowed}"; routing to "${collision.chosen}". | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| // src/proxy/server.ts | ||
| var import_node_process4 = __toESM(require("process"), 1); | ||
| var import_fastmcp = require("fastmcp"); | ||
| var import_node_process5 = __toESM(require("process"), 1); | ||
| var import_server = require("@modelcontextprotocol/sdk/server/index.js"); | ||
| var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js"); | ||
| var import_types2 = require("@modelcontextprotocol/sdk/types.js"); | ||
| var import_zod3 = require("zod"); | ||
| var DISCOVER_TOOL_NAME = "discover_tool"; | ||
| var USE_TOOL_NAME = "use_tool"; | ||
| var USE_TOOL_DESCRIPTION = "Use a tool that was previously discovered with the discover_tool tool."; | ||
| var DISCOVER_TOOL_INPUT_SCHEMA = { | ||
| type: "object", | ||
| properties: { | ||
| tool_name: { type: "string" } | ||
| }, | ||
| required: ["tool_name"] | ||
| }; | ||
| var USE_TOOL_INPUT_SCHEMA = { | ||
| type: "object", | ||
| properties: { | ||
| tool_name: { type: "string" }, | ||
| tool_input: { type: "object", additionalProperties: true, default: {} } | ||
| }, | ||
| required: ["tool_name"] | ||
| }; | ||
| var DiscoverToolArgsSchema = import_zod3.z.object({ tool_name: import_zod3.z.string() }); | ||
| var UseToolArgsSchema = import_zod3.z.object({ | ||
| tool_name: import_zod3.z.string(), | ||
| tool_input: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).default({}) | ||
| }); | ||
| var ProxyServer = class { | ||
| catalog; | ||
| callTool; | ||
| constructor({ catalog, callTool }) { | ||
| capabilities; | ||
| resources; | ||
| prompts; | ||
| complete; | ||
| setLoggingLevelCallback; | ||
| onRootsListChangedCallback; | ||
| sdkServer = null; | ||
| constructor({ | ||
| catalog, | ||
| callTool, | ||
| capabilities, | ||
| resources, | ||
| prompts, | ||
| complete, | ||
| setLoggingLevel, | ||
| onRootsListChanged | ||
| }) { | ||
| this.catalog = catalog; | ||
| this.callTool = callTool; | ||
| this.capabilities = capabilities; | ||
| this.resources = resources; | ||
| this.prompts = prompts; | ||
| this.complete = complete; | ||
| this.setLoggingLevelCallback = setLoggingLevel; | ||
| this.onRootsListChangedCallback = onRootsListChanged; | ||
| } | ||
| buildServer() { | ||
| const server = new import_server.Server( | ||
| { | ||
| name: "dynamic-discovery-mcp", | ||
| version: package_default.version | ||
| }, | ||
| { | ||
| capabilities: this.capabilities | ||
| } | ||
| ); | ||
| this.registerToolHandlers(server); | ||
| if (this.capabilities.resources !== void 0 && this.resources !== void 0) { | ||
| this.registerResourceHandlers(server, this.resources); | ||
| } | ||
| if (this.capabilities.prompts !== void 0 && this.prompts !== void 0) { | ||
| this.registerPromptHandlers(server, this.prompts); | ||
| } | ||
| if (this.capabilities.completions !== void 0 && this.complete !== void 0) { | ||
| this.registerCompletionHandler(server, this.complete); | ||
| } | ||
| if (this.capabilities.logging !== void 0 && this.setLoggingLevelCallback !== void 0) { | ||
| this.registerLoggingHandler(server, this.setLoggingLevelCallback); | ||
| } | ||
| if (this.onRootsListChangedCallback !== void 0) { | ||
| const callback = this.onRootsListChangedCallback; | ||
| server.setNotificationHandler(import_types2.RootsListChangedNotificationSchema, async () => { | ||
| await callback(); | ||
| }); | ||
| } | ||
| this.sdkServer = server; | ||
| return server; | ||
| } | ||
| /** | ||
| * Forwards an upstream-initiated `sampling/createMessage` request to the host. The | ||
| * upstream's abort signal is threaded through so cancellation by the upstream | ||
| * propagates to the host. | ||
| */ | ||
| async forwardCreateMessage(params, options) { | ||
| const server = this.requireSdkServer(); | ||
| return server.createMessage(params, options); | ||
| } | ||
| /** | ||
| * Forwards an upstream-initiated `elicitation/create` request to the host. | ||
| */ | ||
| async forwardElicitInput(params, options) { | ||
| const server = this.requireSdkServer(); | ||
| return server.elicitInput(params, options); | ||
| } | ||
| /** | ||
| * Forwards an upstream-initiated `roots/list` request to the host. | ||
| */ | ||
| async forwardListRoots(params, options) { | ||
| const server = this.requireSdkServer(); | ||
| return server.listRoots(params, options); | ||
| } | ||
| requireSdkServer() { | ||
| if (this.sdkServer === null) { | ||
| throw new Error("ProxyServer is not built. Call buildServer() before forwarding requests."); | ||
| } | ||
| return this.sdkServer; | ||
| } | ||
| async start() { | ||
| const server = new import_fastmcp.FastMCP({ | ||
| name: "dynamic-discovery-mcp", | ||
| version: package_default.version | ||
| }); | ||
| server.addTool({ | ||
| name: "discover_tool", | ||
| description: this.catalog.discoverToolDescription, | ||
| parameters: import_zod3.z.object({ tool_name: import_zod3.z.string() }), | ||
| execute: async ({ tool_name }) => { | ||
| return this.catalog.getToolDetails(tool_name); | ||
| const server = this.buildServer(); | ||
| const transport = new import_stdio.StdioServerTransport(); | ||
| import_node_process5.default.stderr.write("Starting dynamic-discovery-mcp server over stdio\n"); | ||
| await server.connect(transport); | ||
| } | ||
| /** | ||
| * Notifies the host that the discover_tool description has changed because an upstream | ||
| * emitted `notifications/tools/list_changed`. The host should re-fetch the tools list | ||
| * to pick up the regenerated catalog. Silently no-ops if `buildServer()` has not been | ||
| * called yet. | ||
| */ | ||
| async sendToolListChanged() { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendToolListChanged(); | ||
| } | ||
| } | ||
| /** | ||
| * Notifies the host that the proxy's aggregated resource list has changed. Silently | ||
| * no-ops if `buildServer()` has not been called yet. Errors propagate. | ||
| */ | ||
| async sendResourceListChanged() { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendResourceListChanged(); | ||
| } | ||
| } | ||
| /** | ||
| * Notifies the host that a specific subscribed resource has changed. Silently no-ops | ||
| * if `buildServer()` has not been called yet. | ||
| */ | ||
| async sendResourceUpdated(params) { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendResourceUpdated(params); | ||
| } | ||
| } | ||
| /** | ||
| * Notifies the host that the proxy's aggregated prompt list has changed. Silently | ||
| * no-ops if `buildServer()` has not been called yet. | ||
| */ | ||
| async sendPromptListChanged() { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendPromptListChanged(); | ||
| } | ||
| } | ||
| registerToolHandlers(server) { | ||
| server.setRequestHandler(import_types2.ListToolsRequestSchema, async () => ({ | ||
| tools: [ | ||
| { | ||
| name: DISCOVER_TOOL_NAME, | ||
| description: this.catalog().discoverToolDescription, | ||
| inputSchema: DISCOVER_TOOL_INPUT_SCHEMA | ||
| }, | ||
| { | ||
| name: USE_TOOL_NAME, | ||
| description: USE_TOOL_DESCRIPTION, | ||
| inputSchema: USE_TOOL_INPUT_SCHEMA | ||
| } | ||
| ] | ||
| })); | ||
| server.setRequestHandler( | ||
| import_types2.CallToolRequestSchema, | ||
| async (request, extra) => { | ||
| const { name, arguments: rawArgs } = request.params; | ||
| const catalog = this.catalog(); | ||
| if (name === DISCOVER_TOOL_NAME) { | ||
| const args = DiscoverToolArgsSchema.parse(rawArgs ?? {}); | ||
| return { | ||
| content: [{ type: "text", text: catalog.getToolDetails(args.tool_name) }] | ||
| }; | ||
| } | ||
| if (name === USE_TOOL_NAME) { | ||
| const args = UseToolArgsSchema.parse(rawArgs ?? {}); | ||
| if (!catalog.tools.has(args.tool_name)) { | ||
| return { | ||
| content: [{ type: "text", text: catalog.getToolDetails(args.tool_name) }] | ||
| }; | ||
| } | ||
| return await this.callTool(args.tool_name, args.tool_input, { signal: extra.signal }); | ||
| } | ||
| return { | ||
| isError: true, | ||
| content: [{ type: "text", text: `Unknown tool: "${name}"` }] | ||
| }; | ||
| } | ||
| ); | ||
| } | ||
| registerResourceHandlers(server, callbacks) { | ||
| server.setRequestHandler( | ||
| import_types2.ListResourcesRequestSchema, | ||
| async () => ({ | ||
| resources: callbacks.listResources() | ||
| }) | ||
| ); | ||
| server.setRequestHandler( | ||
| import_types2.ListResourceTemplatesRequestSchema, | ||
| async () => ({ | ||
| resourceTemplates: callbacks.listResourceTemplates() | ||
| }) | ||
| ); | ||
| server.setRequestHandler( | ||
| import_types2.ReadResourceRequestSchema, | ||
| async (request, extra) => { | ||
| return callbacks.readResource(request.params.uri, { signal: extra.signal }); | ||
| } | ||
| ); | ||
| server.setRequestHandler(import_types2.SubscribeRequestSchema, async (request, extra) => { | ||
| await callbacks.subscribeResource(request.params.uri, { signal: extra.signal }); | ||
| return {}; | ||
| }); | ||
| server.addTool({ | ||
| name: "use_tool", | ||
| description: "Use a tool that was previously discovered with the discover_tool tool.", | ||
| parameters: import_zod3.z.object({ | ||
| tool_name: import_zod3.z.string(), | ||
| tool_input: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).default({}) | ||
| }), | ||
| execute: async ({ tool_name, tool_input }) => { | ||
| if (!this.catalog.tools.has(tool_name)) { | ||
| return this.catalog.getToolDetails(tool_name); | ||
| } | ||
| const result = await this.callTool(tool_name, tool_input); | ||
| return result; | ||
| server.setRequestHandler(import_types2.UnsubscribeRequestSchema, async (request, extra) => { | ||
| await callbacks.unsubscribeResource(request.params.uri, { signal: extra.signal }); | ||
| return {}; | ||
| }); | ||
| } | ||
| registerPromptHandlers(server, callbacks) { | ||
| server.setRequestHandler( | ||
| import_types2.ListPromptsRequestSchema, | ||
| async () => ({ | ||
| prompts: callbacks.listPrompts() | ||
| }) | ||
| ); | ||
| server.setRequestHandler( | ||
| import_types2.GetPromptRequestSchema, | ||
| async (request, extra) => { | ||
| return callbacks.getPrompt(request.params.name, request.params.arguments, { | ||
| signal: extra.signal | ||
| }); | ||
| } | ||
| ); | ||
| } | ||
| registerCompletionHandler(server, callback) { | ||
| server.setRequestHandler( | ||
| import_types2.CompleteRequestSchema, | ||
| async (request, extra) => { | ||
| return callback(request.params, { signal: extra.signal }); | ||
| } | ||
| ); | ||
| } | ||
| registerLoggingHandler(server, callback) { | ||
| server.setRequestHandler(import_types2.SetLevelRequestSchema, async (request, extra) => { | ||
| await callback(request.params.level, { signal: extra.signal }); | ||
| return {}; | ||
| }); | ||
| import_node_process4.default.stderr.write("Starting dynamic-discovery-mcp server over stdio\n"); | ||
| await server.start({ transportType: "stdio" }); | ||
| } | ||
| /** | ||
| * Forwards a log message from an upstream MCP to the host. Silently no-ops if | ||
| * `buildServer()` has not been called yet. | ||
| */ | ||
| async sendLoggingMessage(params) { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendLoggingMessage(params); | ||
| } | ||
| } | ||
| }; | ||
| // src/proxy/transport-factory.ts | ||
| var import_stdio2 = require("@modelcontextprotocol/sdk/client/stdio.js"); | ||
| var import_streamableHttp = require("@modelcontextprotocol/sdk/client/streamableHttp.js"); | ||
| var import_sse = require("@modelcontextprotocol/sdk/client/sse.js"); | ||
| function createTransport(config) { | ||
| switch (config.transport) { | ||
| case "stdio": | ||
| return new import_stdio2.StdioClientTransport({ | ||
| command: config.command, | ||
| args: config.args, | ||
| env: config.env | ||
| }); | ||
| case "streamable-http": | ||
| return new import_streamableHttp.StreamableHTTPClientTransport( | ||
| new URL(config.url), | ||
| config.headers ? { requestInit: { headers: config.headers } } : void 0 | ||
| ); | ||
| case "sse": | ||
| return new import_sse.SSEClientTransport( | ||
| new URL(config.url), | ||
| config.headers ? { requestInit: { headers: config.headers } } : void 0 | ||
| ); | ||
| default: { | ||
| const _exhaustive = config; | ||
| return _exhaustive; | ||
| } | ||
| } | ||
| } | ||
| // src/proxy/index.ts | ||
| var SINGLE_MCP_NAME = "__default__"; | ||
| async function startProxy(command, args) { | ||
| let isShuttingDown = false; | ||
| const transport = new import_stdio2.StdioClientTransport({ command, args }); | ||
| const upstreamClient = new UpstreamClient({ | ||
| name: command, | ||
| transport, | ||
| onTransportError: (error) => { | ||
| import_node_process5.default.stderr.write(`Upstream MCP transport error: ${error.message} | ||
| `); | ||
| shutdown(1); | ||
| } | ||
| const transport = new import_stdio3.StdioClientTransport({ command, args }); | ||
| const mcps = /* @__PURE__ */ new Map([[SINGLE_MCP_NAME, { transport }]]); | ||
| const orchestrator = buildOrchestrator({ | ||
| mcps, | ||
| namespaced: false, | ||
| transportErrorPrefix: () => "Upstream MCP" | ||
| }); | ||
| const shutdown = (exitCode) => { | ||
| if (isShuttingDown) return; | ||
| isShuttingDown = true; | ||
| upstreamClient.disconnect().catch((error) => { | ||
| import_node_process5.default.stderr.write( | ||
| `dynmcp: error during disconnect: ${error instanceof Error ? error.message : String(error)} | ||
| ` | ||
| ); | ||
| }).finally(() => import_node_process5.default.exit(exitCode)); | ||
| }; | ||
| try { | ||
| await upstreamClient.connect(); | ||
| } catch (error) { | ||
| import_node_process5.default.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| import_node_process5.default.exit(1); | ||
| } | ||
| let tools; | ||
| try { | ||
| tools = await upstreamClient.listTools(); | ||
| } catch (error) { | ||
| import_node_process5.default.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| import_node_process5.default.exit(1); | ||
| } | ||
| const catalog = ToolCatalog.fromFlat(tools); | ||
| const proxyServer = new ProxyServer({ | ||
| catalog, | ||
| callTool: (name, input) => upstreamClient.callTool(name, input) | ||
| }); | ||
| import_node_process5.default.on("SIGINT", () => shutdown(0)); | ||
| import_node_process5.default.on("SIGTERM", () => shutdown(0)); | ||
| import_node_process5.default.stdin.on("end", () => shutdown(0)); | ||
| import_node_process5.default.stdin.on("close", () => shutdown(0)); | ||
| try { | ||
| await proxyServer.start(); | ||
| } catch (error) { | ||
| shutdown(1); | ||
| throw error; | ||
| } | ||
| await runProxy(orchestrator); | ||
| } | ||
| async function startProxyFromConfig(options = {}) { | ||
| let isShuttingDown = false; | ||
| const config = loadConfig(options); | ||
@@ -757,10 +1612,25 @@ const mcps = /* @__PURE__ */ new Map(); | ||
| } | ||
| const orchestrator = new Orchestrator({ | ||
| const orchestrator = buildOrchestrator({ | ||
| mcps, | ||
| namespaced: true, | ||
| transportErrorPrefix: (mcpName2) => `Upstream MCP "${mcpName2}"` | ||
| }); | ||
| await runProxy(orchestrator); | ||
| } | ||
| var activeShutdown = { shutdown: null }; | ||
| function buildOrchestrator(params) { | ||
| return new Orchestrator({ | ||
| mcps: params.mcps, | ||
| namespaced: params.namespaced, | ||
| onTransportError: (mcpName2, error) => { | ||
| import_node_process5.default.stderr.write(`Upstream MCP "${mcpName2}" transport error: ${error.message} | ||
| `); | ||
| shutdown(1); | ||
| import_node_process6.default.stderr.write( | ||
| `${params.transportErrorPrefix(mcpName2)} transport error: ${error.message} | ||
| ` | ||
| ); | ||
| activeShutdown.shutdown?.(1); | ||
| } | ||
| }); | ||
| } | ||
| async function runProxy(orchestrator) { | ||
| let isShuttingDown = false; | ||
| const shutdown = (exitCode) => { | ||
@@ -770,23 +1640,52 @@ if (isShuttingDown) return; | ||
| orchestrator.disconnectAll().catch((error) => { | ||
| import_node_process5.default.stderr.write( | ||
| import_node_process6.default.stderr.write( | ||
| `dynmcp: error during disconnect: ${error instanceof Error ? error.message : String(error)} | ||
| ` | ||
| ); | ||
| }).finally(() => import_node_process5.default.exit(exitCode)); | ||
| }).finally(() => import_node_process6.default.exit(exitCode)); | ||
| }; | ||
| activeShutdown.shutdown = shutdown; | ||
| try { | ||
| await orchestrator.connect(); | ||
| } catch (error) { | ||
| import_node_process5.default.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| import_node_process6.default.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| import_node_process5.default.exit(1); | ||
| import_node_process6.default.exit(1); | ||
| return; | ||
| } | ||
| const proxyServer = new ProxyServer({ | ||
| catalog: orchestrator.catalog, | ||
| callTool: (name, input) => orchestrator.callTool(name, input) | ||
| catalog: () => orchestrator.catalog, | ||
| capabilities: orchestrator.capabilities, | ||
| callTool: (name, input, options) => orchestrator.callTool(name, input, options), | ||
| resources: orchestrator.capabilities.resources !== void 0 ? { | ||
| listResources: () => orchestrator.listResources(), | ||
| listResourceTemplates: () => orchestrator.listResourceTemplates(), | ||
| readResource: (uri, options) => orchestrator.readResource(uri, options), | ||
| subscribeResource: (uri, options) => orchestrator.subscribeResource(uri, options), | ||
| unsubscribeResource: (uri, options) => orchestrator.unsubscribeResource(uri, options) | ||
| } : void 0, | ||
| prompts: orchestrator.capabilities.prompts !== void 0 ? { | ||
| listPrompts: () => orchestrator.listPrompts(), | ||
| getPrompt: (name, args, options) => orchestrator.getPrompt(name, args, options) | ||
| } : void 0, | ||
| complete: orchestrator.capabilities.completions !== void 0 ? (params, options) => orchestrator.complete(params, options) : void 0, | ||
| setLoggingLevel: orchestrator.capabilities.logging !== void 0 ? (level, options) => orchestrator.setLoggingLevel(level, options) : void 0, | ||
| onRootsListChanged: () => orchestrator.broadcastRootsListChanged() | ||
| }); | ||
| import_node_process5.default.on("SIGINT", () => shutdown(0)); | ||
| import_node_process5.default.on("SIGTERM", () => shutdown(0)); | ||
| import_node_process5.default.stdin.on("end", () => shutdown(0)); | ||
| import_node_process5.default.stdin.on("close", () => shutdown(0)); | ||
| orchestrator.setNotificationHandlers({ | ||
| onToolsListChanged: () => proxyServer.sendToolListChanged(), | ||
| onResourcesListChanged: () => proxyServer.sendResourceListChanged(), | ||
| onResourceUpdated: (params) => proxyServer.sendResourceUpdated(params), | ||
| onPromptsListChanged: () => proxyServer.sendPromptListChanged(), | ||
| onLogMessage: (params) => proxyServer.sendLoggingMessage(params) | ||
| }); | ||
| orchestrator.setServerRequestForwarders({ | ||
| onCreateMessage: (params, options) => proxyServer.forwardCreateMessage(params, options), | ||
| onElicitInput: (params, options) => proxyServer.forwardElicitInput(params, options), | ||
| onListRoots: (params, options) => proxyServer.forwardListRoots(params, options) | ||
| }); | ||
| import_node_process6.default.on("SIGINT", () => shutdown(0)); | ||
| import_node_process6.default.on("SIGTERM", () => shutdown(0)); | ||
| import_node_process6.default.stdin.on("end", () => shutdown(0)); | ||
| import_node_process6.default.stdin.on("close", () => shutdown(0)); | ||
| try { | ||
@@ -811,16 +1710,13 @@ await proxyServer.start(); | ||
| "\nExamples:\n dynmcp -- npx -y chrome-devtools-mcp@latest\n dynmcp --config ./mcp.json\n" | ||
| ).option("-c, --config <path>", "Path to config file (JSON or YAML)").option( | ||
| "-e, --env <path>", | ||
| "Path to a .env file for environment variable interpolation" | ||
| ).allowExcessArguments(true).passThroughOptions(true).action(async (_options, cmd) => { | ||
| const separatorIndex = import_node_process6.default.argv.indexOf("--"); | ||
| ).option("-c, --config <path>", "Path to config file (JSON or YAML)").option("-e, --env <path>", "Path to a .env file for environment variable interpolation").allowExcessArguments(true).passThroughOptions(true).action(async (_options, cmd) => { | ||
| const separatorIndex = import_node_process7.default.argv.indexOf("--"); | ||
| const configPath = cmd.opts().config; | ||
| const envFilePath = cmd.opts().env; | ||
| if (separatorIndex !== -1) { | ||
| const [command, ...args] = import_node_process6.default.argv.slice(separatorIndex + 1); | ||
| const [command, ...args] = import_node_process7.default.argv.slice(separatorIndex + 1); | ||
| if (command === void 0) { | ||
| import_node_process6.default.stderr.write( | ||
| import_node_process7.default.stderr.write( | ||
| "dynmcp: no upstream command provided after --.\nUsage: dynmcp -- <command> [args...]\n" | ||
| ); | ||
| import_node_process6.default.exit(1); | ||
| import_node_process7.default.exit(1); | ||
| } | ||
@@ -830,5 +1726,5 @@ try { | ||
| } catch (error) { | ||
| import_node_process6.default.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| import_node_process7.default.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| import_node_process6.default.exit(1); | ||
| import_node_process7.default.exit(1); | ||
| } | ||
@@ -840,5 +1736,5 @@ return; | ||
| } catch (error) { | ||
| import_node_process6.default.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| import_node_process7.default.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| import_node_process6.default.exit(1); | ||
| import_node_process7.default.exit(1); | ||
| } | ||
@@ -848,7 +1744,7 @@ }); | ||
| // src/index.ts | ||
| var import_node_process7 = __toESM(require("process"), 1); | ||
| var import_node_process8 = __toESM(require("process"), 1); | ||
| async function main() { | ||
| cli.parse(import_node_process7.default.argv); | ||
| cli.parse(import_node_process8.default.argv); | ||
| } | ||
| main(); | ||
| //# sourceMappingURL=index.cjs.map |
+1097
-179
| #!/usr/bin/env node | ||
| // src/cli.ts | ||
| import process6 from "process"; | ||
| import process7 from "process"; | ||
| import { Command } from "commander"; | ||
@@ -10,3 +10,3 @@ | ||
| name: "dynmcp", | ||
| version: "0.2.0", | ||
| version: "0.3.0", | ||
| description: "Dynamic MCP context management tool for AI MCP-enabled agents and clients.", | ||
@@ -91,2 +91,3 @@ author: "Brandon Burrus <brandon@burrus.io>", | ||
| "@types/node": "^25.9.0", | ||
| "@vitest/coverage-v8": "^4.1.6", | ||
| husky: "^9.1.7", | ||
@@ -105,3 +106,3 @@ tsup: "^8.5.1", | ||
| // src/proxy/index.ts | ||
| import process5 from "process"; | ||
| import process6 from "process"; | ||
| import { StdioClientTransport as StdioClientTransport2 } from "@modelcontextprotocol/sdk/client/stdio.js"; | ||
@@ -369,29 +370,35 @@ | ||
| // src/proxy/transport-factory.ts | ||
| import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | ||
| import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | ||
| function createTransport(config) { | ||
| switch (config.transport) { | ||
| case "stdio": | ||
| return new StdioClientTransport({ | ||
| command: config.command, | ||
| args: config.args, | ||
| env: config.env | ||
| }); | ||
| case "streamable-http": | ||
| return new StreamableHTTPClientTransport( | ||
| new URL(config.url), | ||
| config.headers ? { requestInit: { headers: config.headers } } : void 0 | ||
| ); | ||
| case "sse": | ||
| return new SSEClientTransport( | ||
| new URL(config.url), | ||
| config.headers ? { requestInit: { headers: config.headers } } : void 0 | ||
| ); | ||
| default: { | ||
| const _exhaustive = config; | ||
| return _exhaustive; | ||
| // src/proxy/orchestrator.ts | ||
| import process4 from "process"; | ||
| // src/proxy/capability-aggregator.ts | ||
| function aggregateCapabilities(upstreams) { | ||
| const aggregated = { | ||
| tools: { listChanged: true } | ||
| }; | ||
| for (const caps of upstreams) { | ||
| if (caps === void 0) continue; | ||
| if (caps.resources !== void 0) { | ||
| aggregated.resources ??= {}; | ||
| if (caps.resources.subscribe === true) { | ||
| aggregated.resources.subscribe = true; | ||
| } | ||
| if (caps.resources.listChanged === true) { | ||
| aggregated.resources.listChanged = true; | ||
| } | ||
| } | ||
| if (caps.prompts !== void 0) { | ||
| aggregated.prompts ??= {}; | ||
| if (caps.prompts.listChanged === true) { | ||
| aggregated.prompts.listChanged = true; | ||
| } | ||
| } | ||
| if (caps.logging !== void 0) { | ||
| aggregated.logging ??= {}; | ||
| } | ||
| if (caps.completions !== void 0) { | ||
| aggregated.completions ??= {}; | ||
| } | ||
| } | ||
| return aggregated; | ||
| } | ||
@@ -502,11 +509,256 @@ | ||
| // src/proxy/notification-forwarder.ts | ||
| var NotificationForwarder = class { | ||
| constructor(registry, resourceRouter, promptRouter, toolsByMcp, setToolCatalog, namespaced) { | ||
| this.registry = registry; | ||
| this.resourceRouter = resourceRouter; | ||
| this.promptRouter = promptRouter; | ||
| this.toolsByMcp = toolsByMcp; | ||
| this.setToolCatalog = setToolCatalog; | ||
| this.namespaced = namespaced; | ||
| } | ||
| registry; | ||
| resourceRouter; | ||
| promptRouter; | ||
| toolsByMcp; | ||
| setToolCatalog; | ||
| namespaced; | ||
| hostHandlers = {}; | ||
| setHostHandlers(handlers) { | ||
| this.hostHandlers = handlers; | ||
| } | ||
| async handleToolsListChanged(mcpName2) { | ||
| const client = this.registry.get(mcpName2); | ||
| if (client === void 0) return; | ||
| const tools = await client.listTools().catch(() => []); | ||
| this.toolsByMcp.set(mcpName2, tools); | ||
| const rebuilt = this.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []); | ||
| this.setToolCatalog(rebuilt); | ||
| await this.hostHandlers.onToolsListChanged?.(); | ||
| } | ||
| async handleResourcesListChanged(mcpName2) { | ||
| const router = this.resourceRouter(); | ||
| const client = this.registry.get(mcpName2); | ||
| if (router === null || client === void 0) return; | ||
| const [resources, templates] = await Promise.all([ | ||
| client.listResources().catch(() => []), | ||
| client.listResourceTemplates().catch(() => []) | ||
| ]); | ||
| router.setResources(mcpName2, resources); | ||
| router.setTemplates(mcpName2, templates); | ||
| await this.hostHandlers.onResourcesListChanged?.(); | ||
| } | ||
| async handleResourceUpdated(params) { | ||
| await this.hostHandlers.onResourceUpdated?.(params); | ||
| } | ||
| async handlePromptsListChanged(mcpName2) { | ||
| const router = this.promptRouter(); | ||
| const client = this.registry.get(mcpName2); | ||
| if (router === null || client === void 0) return; | ||
| const prompts = await client.listPrompts().catch(() => []); | ||
| router.setPrompts(mcpName2, prompts); | ||
| await this.hostHandlers.onPromptsListChanged?.(); | ||
| } | ||
| /** | ||
| * Rewrites the upstream's `logger` field with the originating MCP's name as a | ||
| * prefix so the host can attribute log lines, then forwards the message to the | ||
| * host's log message handler. | ||
| */ | ||
| async handleLogMessage(mcpName2, params) { | ||
| const handler = this.hostHandlers.onLogMessage; | ||
| if (handler === void 0) return; | ||
| if (!this.namespaced) { | ||
| await handler(params); | ||
| return; | ||
| } | ||
| const prefixed = { | ||
| ...params, | ||
| logger: params.logger === void 0 ? mcpName2 : `${mcpName2}/${params.logger}` | ||
| }; | ||
| await handler(prefixed); | ||
| } | ||
| }; | ||
| // src/proxy/prompt-router.ts | ||
| var PromptRouter = class { | ||
| mcpOrder; | ||
| perMcp; | ||
| nameOwners = /* @__PURE__ */ new Map(); | ||
| detectedCollisions = []; | ||
| constructor(mcpOrder) { | ||
| this.mcpOrder = [...mcpOrder]; | ||
| this.perMcp = new Map(this.mcpOrder.map((name) => [name, []])); | ||
| } | ||
| setPrompts(mcpName2, prompts) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry === void 0) { | ||
| throw new Error(`PromptRouter: unknown mcp "${mcpName2}"`); | ||
| } | ||
| this.perMcp.set(mcpName2, [...prompts]); | ||
| this.rebuild(); | ||
| } | ||
| aggregatedPrompts() { | ||
| const result = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry !== void 0) { | ||
| result.push(...entry); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| ownerOf(promptName) { | ||
| return this.nameOwners.get(promptName); | ||
| } | ||
| collisions() { | ||
| return this.detectedCollisions; | ||
| } | ||
| rebuild() { | ||
| this.nameOwners = /* @__PURE__ */ new Map(); | ||
| const collisions = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const prompts = this.perMcp.get(mcpName2); | ||
| if (prompts === void 0) continue; | ||
| for (const prompt of prompts) { | ||
| const existing = this.nameOwners.get(prompt.name); | ||
| if (existing === void 0) { | ||
| this.nameOwners.set(prompt.name, mcpName2); | ||
| } else { | ||
| collisions.push({ name: prompt.name, chosen: existing, shadowed: mcpName2 }); | ||
| } | ||
| } | ||
| } | ||
| this.detectedCollisions = collisions; | ||
| } | ||
| }; | ||
| // src/proxy/resource-router.ts | ||
| var ResourceRouter = class { | ||
| mcpOrder; | ||
| perMcp; | ||
| uriOwners = /* @__PURE__ */ new Map(); | ||
| templateOwners = []; | ||
| detectedCollisions = []; | ||
| constructor(mcpOrder) { | ||
| this.mcpOrder = [...mcpOrder]; | ||
| this.perMcp = new Map( | ||
| this.mcpOrder.map((name) => [name, { resources: [], templates: [] }]) | ||
| ); | ||
| } | ||
| setResources(mcpName2, resources) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry === void 0) { | ||
| throw new Error(`ResourceRouter: unknown mcp "${mcpName2}"`); | ||
| } | ||
| entry.resources = [...resources]; | ||
| this.rebuild(); | ||
| } | ||
| setTemplates(mcpName2, templates) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry === void 0) { | ||
| throw new Error(`ResourceRouter: unknown mcp "${mcpName2}"`); | ||
| } | ||
| entry.templates = [...templates]; | ||
| this.rebuild(); | ||
| } | ||
| aggregatedResources() { | ||
| const result = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry !== void 0) { | ||
| result.push(...entry.resources); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| aggregatedTemplates() { | ||
| const result = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry !== void 0) { | ||
| result.push(...entry.templates); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| /** | ||
| * Returns the mcpName that owns the given URI, or undefined if no upstream advertises it. | ||
| * Concrete URI matches take precedence over template prefix matches; templates are tried | ||
| * in config-file order (first-wins). | ||
| */ | ||
| ownerOf(uri) { | ||
| const concrete = this.uriOwners.get(uri); | ||
| if (concrete !== void 0) { | ||
| return concrete; | ||
| } | ||
| for (const { prefix, mcpName: mcpName2 } of this.templateOwners) { | ||
| if (prefix.length > 0 && uri.startsWith(prefix)) { | ||
| return mcpName2; | ||
| } | ||
| } | ||
| return void 0; | ||
| } | ||
| collisions() { | ||
| return this.detectedCollisions; | ||
| } | ||
| rebuild() { | ||
| this.uriOwners = /* @__PURE__ */ new Map(); | ||
| this.templateOwners = []; | ||
| const collisions = []; | ||
| for (const mcpName2 of this.mcpOrder) { | ||
| const entry = this.perMcp.get(mcpName2); | ||
| if (entry === void 0) continue; | ||
| for (const resource of entry.resources) { | ||
| const existing = this.uriOwners.get(resource.uri); | ||
| if (existing === void 0) { | ||
| this.uriOwners.set(resource.uri, mcpName2); | ||
| } else { | ||
| collisions.push({ uri: resource.uri, chosen: existing, shadowed: mcpName2 }); | ||
| } | ||
| } | ||
| for (const template of entry.templates) { | ||
| this.templateOwners.push({ | ||
| prefix: literalPrefixOf(template.uriTemplate), | ||
| template, | ||
| mcpName: mcpName2 | ||
| }); | ||
| } | ||
| } | ||
| this.detectedCollisions = collisions; | ||
| } | ||
| }; | ||
| function literalPrefixOf(uriTemplate) { | ||
| const idx = uriTemplate.indexOf("{"); | ||
| return idx === -1 ? uriTemplate : uriTemplate.slice(0, idx); | ||
| } | ||
| // src/proxy/upstream-client.ts | ||
| import process3 from "process"; | ||
| import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| import { | ||
| CreateMessageRequestSchema, | ||
| ElicitRequestSchema, | ||
| ListRootsRequestSchema, | ||
| LoggingMessageNotificationSchema, | ||
| PromptListChangedNotificationSchema, | ||
| ResourceListChangedNotificationSchema, | ||
| ResourceUpdatedNotificationSchema, | ||
| ToolListChangedNotificationSchema | ||
| } from "@modelcontextprotocol/sdk/types.js"; | ||
| var UpstreamClient = class { | ||
| transport; | ||
| onTransportError; | ||
| notificationHandlers; | ||
| serverRequestHandlers; | ||
| client = null; | ||
| constructor({ name, transport, onTransportError }) { | ||
| constructor({ | ||
| name, | ||
| transport, | ||
| onTransportError, | ||
| notifications, | ||
| serverRequests | ||
| }) { | ||
| this.transport = transport; | ||
| this.notificationHandlers = notifications ?? {}; | ||
| this.serverRequestHandlers = serverRequests ?? {}; | ||
| this.onTransportError = onTransportError ?? ((error) => { | ||
@@ -519,10 +771,76 @@ process3.stderr.write(`[${name}] Upstream MCP transport error: ${error.message} | ||
| this.transport.onerror = this.onTransportError; | ||
| this.client = new Client({ name: "dynamic-discovery-mcp", version: "1.0.0" }); | ||
| this.client = new Client( | ||
| { name: "dynamic-discovery-mcp", version: "1.0.0" }, | ||
| { | ||
| capabilities: { | ||
| // Declare every client-side capability the proxy may relay on behalf of the host. | ||
| // Actual reachability of each feature depends on what the host supports — if the | ||
| // host does not support sampling, for instance, the host call returns an error | ||
| // which we forward back to the upstream verbatim. | ||
| sampling: {}, | ||
| elicitation: {}, | ||
| roots: { listChanged: true } | ||
| } | ||
| } | ||
| ); | ||
| this.registerServerRequestHandlers(this.client); | ||
| if (this.notificationHandlers.onToolsListChanged !== void 0) { | ||
| this.client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { | ||
| await this.notificationHandlers.onToolsListChanged?.(); | ||
| }); | ||
| } | ||
| if (this.notificationHandlers.onResourcesListChanged !== void 0) { | ||
| this.client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => { | ||
| await this.notificationHandlers.onResourcesListChanged?.(); | ||
| }); | ||
| } | ||
| if (this.notificationHandlers.onResourceUpdated !== void 0) { | ||
| this.client.setNotificationHandler(ResourceUpdatedNotificationSchema, async (notification) => { | ||
| await this.notificationHandlers.onResourceUpdated?.({ uri: notification.params.uri }); | ||
| }); | ||
| } | ||
| if (this.notificationHandlers.onPromptsListChanged !== void 0) { | ||
| this.client.setNotificationHandler(PromptListChangedNotificationSchema, async () => { | ||
| await this.notificationHandlers.onPromptsListChanged?.(); | ||
| }); | ||
| } | ||
| if (this.notificationHandlers.onLogMessage !== void 0) { | ||
| this.client.setNotificationHandler(LoggingMessageNotificationSchema, async (notification) => { | ||
| await this.notificationHandlers.onLogMessage?.(notification.params); | ||
| }); | ||
| } | ||
| await this.client.connect(this.transport); | ||
| } | ||
| async listTools() { | ||
| if (this.client === null) { | ||
| throw new Error("Client is not connected. Call connect() first."); | ||
| async setLoggingLevel(level, options) { | ||
| const client = this.requireClient(); | ||
| await client.setLoggingLevel(level, options); | ||
| } | ||
| async listPrompts(options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.listPrompts(void 0, options); | ||
| return result.prompts; | ||
| } | ||
| async getPrompt(name, args, options) { | ||
| const client = this.requireClient(); | ||
| const params = { name }; | ||
| if (args !== void 0) { | ||
| params.arguments = args; | ||
| } | ||
| const result = await this.client.listTools(); | ||
| return client.getPrompt(params, options); | ||
| } | ||
| async complete(params, options) { | ||
| const client = this.requireClient(); | ||
| return client.complete(params, options); | ||
| } | ||
| /** | ||
| * Returns the capabilities advertised by the upstream server during initialize. | ||
| * Returns `undefined` if the client is not connected, or if the SDK has not yet | ||
| * recorded the server's capabilities (e.g. during a partially-completed handshake). | ||
| */ | ||
| getCapabilities() { | ||
| return this.client?.getServerCapabilities(); | ||
| } | ||
| async listTools(options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.listTools(void 0, options); | ||
| return result.tools.map((tool) => { | ||
@@ -549,9 +867,29 @@ const upstreamTool = { | ||
| } | ||
| async callTool(name, input) { | ||
| if (this.client === null) { | ||
| throw new Error("Client is not connected. Call connect() first."); | ||
| } | ||
| const result = await this.client.callTool({ name, arguments: input }); | ||
| async callTool(name, input, options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.callTool({ name, arguments: input }, void 0, options); | ||
| return result; | ||
| } | ||
| async listResources(options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.listResources(void 0, options); | ||
| return result.resources; | ||
| } | ||
| async listResourceTemplates(options) { | ||
| const client = this.requireClient(); | ||
| const result = await client.listResourceTemplates(void 0, options); | ||
| return result.resourceTemplates; | ||
| } | ||
| async readResource(uri, options) { | ||
| const client = this.requireClient(); | ||
| return client.readResource({ uri }, options); | ||
| } | ||
| async subscribeResource(uri, options) { | ||
| const client = this.requireClient(); | ||
| await client.subscribeResource({ uri }, options); | ||
| } | ||
| async unsubscribeResource(uri, options) { | ||
| const client = this.requireClient(); | ||
| await client.unsubscribeResource({ uri }, options); | ||
| } | ||
| async disconnect() { | ||
@@ -564,27 +902,65 @@ if (this.client === null) { | ||
| } | ||
| /** | ||
| * Sends `notifications/roots/list_changed` to the upstream, letting it know that | ||
| * the host's set of filesystem roots has changed. | ||
| */ | ||
| async sendRootsListChanged() { | ||
| const client = this.requireClient(); | ||
| await client.sendRootsListChanged(); | ||
| } | ||
| registerServerRequestHandlers(client) { | ||
| if (this.serverRequestHandlers.onCreateMessage !== void 0) { | ||
| client.setRequestHandler( | ||
| CreateMessageRequestSchema, | ||
| async (request, extra) => { | ||
| return this.serverRequestHandlers.onCreateMessage(request.params, { | ||
| signal: extra.signal | ||
| }); | ||
| } | ||
| ); | ||
| } | ||
| if (this.serverRequestHandlers.onElicitInput !== void 0) { | ||
| client.setRequestHandler( | ||
| ElicitRequestSchema, | ||
| async (request, extra) => { | ||
| return this.serverRequestHandlers.onElicitInput(request.params, { | ||
| signal: extra.signal | ||
| }); | ||
| } | ||
| ); | ||
| } | ||
| if (this.serverRequestHandlers.onListRoots !== void 0) { | ||
| client.setRequestHandler( | ||
| ListRootsRequestSchema, | ||
| async (request, extra) => { | ||
| return this.serverRequestHandlers.onListRoots(request.params, { | ||
| signal: extra.signal | ||
| }); | ||
| } | ||
| ); | ||
| } | ||
| } | ||
| requireClient() { | ||
| if (this.client === null) { | ||
| throw new Error("Client is not connected. Call connect() first."); | ||
| } | ||
| return this.client; | ||
| } | ||
| }; | ||
| // src/proxy/orchestrator.ts | ||
| var Orchestrator = class { | ||
| config; | ||
| // src/proxy/upstream-registry.ts | ||
| var UpstreamRegistry = class { | ||
| clients = /* @__PURE__ */ new Map(); | ||
| toolCatalog = null; | ||
| constructor(config) { | ||
| this.config = config; | ||
| } | ||
| async connect() { | ||
| const groups = /* @__PURE__ */ new Map(); | ||
| async connectAll(entries) { | ||
| try { | ||
| for (const [mcpName2, { transport }] of this.config.mcps) { | ||
| for (const [mcpName2, config] of entries) { | ||
| const client = new UpstreamClient({ | ||
| name: mcpName2, | ||
| transport, | ||
| onTransportError: (error) => { | ||
| this.config.onTransportError?.(mcpName2, error); | ||
| } | ||
| transport: config.transport, | ||
| onTransportError: config.onTransportError, | ||
| notifications: config.notifications, | ||
| serverRequests: config.serverRequests | ||
| }); | ||
| await client.connect(); | ||
| const tools = await client.listTools(); | ||
| this.clients.set(mcpName2, client); | ||
| groups.set(mcpName2, tools); | ||
| } | ||
@@ -595,4 +971,127 @@ } catch (error) { | ||
| } | ||
| this.toolCatalog = ToolCatalog.fromGrouped(groups); | ||
| } | ||
| get(mcpName2) { | ||
| return this.clients.get(mcpName2); | ||
| } | ||
| /** | ||
| * Returns the sole connected client. Used by single-MCP (`--`) mode where the | ||
| * Orchestrator guarantees there is exactly one upstream. Returns undefined when | ||
| * zero or more than one client is connected. | ||
| */ | ||
| sole() { | ||
| if (this.clients.size !== 1) return void 0; | ||
| return this.clients.values().next().value; | ||
| } | ||
| names() { | ||
| return [...this.clients.keys()]; | ||
| } | ||
| entries() { | ||
| return this.clients.entries(); | ||
| } | ||
| size() { | ||
| return this.clients.size; | ||
| } | ||
| async disconnectAll() { | ||
| const disconnections = [...this.clients.values()].map((client) => client.disconnect()); | ||
| await Promise.all(disconnections); | ||
| this.clients.clear(); | ||
| } | ||
| }; | ||
| // src/proxy/orchestrator.ts | ||
| var Orchestrator = class { | ||
| config; | ||
| registry = new UpstreamRegistry(); | ||
| toolsByMcp = /* @__PURE__ */ new Map(); | ||
| resourceRouter = null; | ||
| promptRouter = null; | ||
| toolCatalog = null; | ||
| aggregatedCapabilities = null; | ||
| serverRequestForwarders = {}; | ||
| forwarder; | ||
| constructor(config) { | ||
| if (!config.namespaced && config.mcps.size !== 1) { | ||
| throw new Error( | ||
| `Single-MCP (non-namespaced) mode requires exactly one upstream; got ${config.mcps.size}.` | ||
| ); | ||
| } | ||
| this.config = config; | ||
| this.forwarder = new NotificationForwarder( | ||
| this.registry, | ||
| () => this.resourceRouter, | ||
| () => this.promptRouter, | ||
| this.toolsByMcp, | ||
| (catalog) => { | ||
| this.toolCatalog = catalog; | ||
| }, | ||
| this.config.namespaced | ||
| ); | ||
| } | ||
| setNotificationHandlers(handlers) { | ||
| this.forwarder.setHostHandlers(handlers); | ||
| } | ||
| setServerRequestForwarders(forwarders) { | ||
| this.serverRequestForwarders = forwarders; | ||
| } | ||
| async connect() { | ||
| const resourceRouter = new ResourceRouter([...this.config.mcps.keys()]); | ||
| const promptRouter = new PromptRouter([...this.config.mcps.keys()]); | ||
| const upstreamEntries = [ | ||
| ...this.config.mcps | ||
| ].map(([mcpName2, { transport }]) => [ | ||
| mcpName2, | ||
| { | ||
| transport, | ||
| onTransportError: (error) => { | ||
| this.config.onTransportError?.(mcpName2, error); | ||
| }, | ||
| notifications: { | ||
| onToolsListChanged: () => this.forwarder.handleToolsListChanged(mcpName2), | ||
| onResourcesListChanged: () => this.forwarder.handleResourcesListChanged(mcpName2), | ||
| onResourceUpdated: (params) => this.forwarder.handleResourceUpdated(params), | ||
| onPromptsListChanged: () => this.forwarder.handlePromptsListChanged(mcpName2), | ||
| onLogMessage: (params) => this.forwarder.handleLogMessage(mcpName2, params) | ||
| }, | ||
| serverRequests: { | ||
| onCreateMessage: (params, opts) => this.forwardCreateMessage(params, opts), | ||
| onElicitInput: (params, opts) => this.forwardElicitInput(params, opts), | ||
| onListRoots: (params, opts) => this.forwardListRoots(params, opts) | ||
| } | ||
| } | ||
| ]); | ||
| await this.registry.connectAll(upstreamEntries); | ||
| const capabilityList = []; | ||
| this.toolsByMcp.clear(); | ||
| for (const [mcpName2, client] of this.registry.entries()) { | ||
| const caps = client.getCapabilities(); | ||
| capabilityList.push(caps); | ||
| const tools = await client.listTools(); | ||
| this.toolsByMcp.set(mcpName2, tools); | ||
| if (caps?.resources !== void 0) { | ||
| const [resources, templates] = await Promise.all([ | ||
| client.listResources().catch(() => []), | ||
| client.listResourceTemplates().catch(() => []) | ||
| ]); | ||
| resourceRouter.setResources(mcpName2, resources); | ||
| resourceRouter.setTemplates(mcpName2, templates); | ||
| } | ||
| if (caps?.prompts !== void 0) { | ||
| const prompts = await client.listPrompts().catch(() => []); | ||
| promptRouter.setPrompts(mcpName2, prompts); | ||
| } | ||
| } | ||
| this.toolCatalog = this.config.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []); | ||
| this.aggregatedCapabilities = aggregateCapabilities(capabilityList); | ||
| this.resourceRouter = resourceRouter; | ||
| this.promptRouter = promptRouter; | ||
| logCollisions(resourceRouter, promptRouter); | ||
| } | ||
| async disconnectAll() { | ||
| await this.registry.disconnectAll(); | ||
| this.toolsByMcp.clear(); | ||
| this.toolCatalog = null; | ||
| this.aggregatedCapabilities = null; | ||
| this.resourceRouter = null; | ||
| this.promptRouter = null; | ||
| } | ||
| get catalog() { | ||
@@ -604,126 +1103,504 @@ if (this.toolCatalog === null) { | ||
| } | ||
| async callTool(namespacedName, input) { | ||
| const separatorIndex = namespacedName.indexOf("/"); | ||
| if (separatorIndex === -1) { | ||
| throw new Error( | ||
| `Invalid namespaced tool name: "${namespacedName}". Expected format: "mcpName/toolName".` | ||
| ); | ||
| get capabilities() { | ||
| if (this.aggregatedCapabilities === null) { | ||
| throw new Error("Orchestrator is not connected. Call connect() first."); | ||
| } | ||
| const mcpName2 = namespacedName.slice(0, separatorIndex); | ||
| const toolName = namespacedName.slice(separatorIndex + 1); | ||
| const client = this.clients.get(mcpName2); | ||
| return this.aggregatedCapabilities; | ||
| } | ||
| // === Forward-direction request routing === | ||
| async callTool(displayName, input, options) { | ||
| if (this.config.namespaced) { | ||
| const { mcpName: mcpName2, toolName } = splitNamespacedName(displayName, this.registry.names()); | ||
| return this.requireClient(mcpName2, "tool").callTool(toolName, input, options); | ||
| } | ||
| const sole = this.registry.sole(); | ||
| if (sole === void 0) { | ||
| throw new Error("Orchestrator is not connected. Call connect() first."); | ||
| } | ||
| return sole.callTool(displayName, input, options); | ||
| } | ||
| listResources() { | ||
| return this.requireResourceRouter().aggregatedResources(); | ||
| } | ||
| listResourceTemplates() { | ||
| return this.requireResourceRouter().aggregatedTemplates(); | ||
| } | ||
| async readResource(uri, options) { | ||
| return this.resolveResourceOwner(uri).readResource(uri, options); | ||
| } | ||
| async subscribeResource(uri, options) { | ||
| await this.resolveResourceOwner(uri).subscribeResource(uri, options); | ||
| } | ||
| async unsubscribeResource(uri, options) { | ||
| await this.resolveResourceOwner(uri).unsubscribeResource(uri, options); | ||
| } | ||
| listPrompts() { | ||
| return this.requirePromptRouter().aggregatedPrompts(); | ||
| } | ||
| async getPrompt(name, args, options) { | ||
| return this.resolvePromptOwner(name).getPrompt(name, args, options); | ||
| } | ||
| async complete(params, options) { | ||
| const client = this.resolveCompletionTarget(params.ref); | ||
| return client.complete(params, options); | ||
| } | ||
| // === Broadcasts === | ||
| /** | ||
| * Broadcasts a `logging/setLevel` request to every upstream advertising the logging | ||
| * capability. Errors from individual upstreams are swallowed so a single misbehaving | ||
| * upstream cannot break the broadcast for others; failures are written to stderr. | ||
| */ | ||
| async setLoggingLevel(level, options) { | ||
| await this.broadcastAsync( | ||
| (client) => client.getCapabilities()?.logging !== void 0 ? client.setLoggingLevel(level, options) : Promise.resolve(), | ||
| "setLoggingLevel" | ||
| ); | ||
| } | ||
| /** | ||
| * Broadcasts `notifications/roots/list_changed` to every connected upstream — the | ||
| * proxy declares roots capability uniformly to all upstreams so every one of them | ||
| * may have requested roots and needs to know the list changed. | ||
| */ | ||
| async broadcastRootsListChanged() { | ||
| await this.broadcastAsync((client) => client.sendRootsListChanged(), "sendRootsListChanged"); | ||
| } | ||
| // === Server-initiated request forwarders (upstream → host) === | ||
| async forwardCreateMessage(params, options) { | ||
| const handler = this.serverRequestForwarders.onCreateMessage; | ||
| if (handler === void 0) { | ||
| throw new Error("Proxy does not support sampling: host has not registered a handler."); | ||
| } | ||
| return handler(params, options); | ||
| } | ||
| async forwardElicitInput(params, options) { | ||
| const handler = this.serverRequestForwarders.onElicitInput; | ||
| if (handler === void 0) { | ||
| throw new Error("Proxy does not support elicitation: host has not registered a handler."); | ||
| } | ||
| return handler(params, options); | ||
| } | ||
| async forwardListRoots(params, options) { | ||
| const handler = this.serverRequestForwarders.onListRoots; | ||
| if (handler === void 0) { | ||
| throw new Error("Proxy does not support roots: host has not registered a handler."); | ||
| } | ||
| return handler(params, options); | ||
| } | ||
| // === Internal helpers === | ||
| requireResourceRouter() { | ||
| if (this.resourceRouter === null) { | ||
| throw new Error("Orchestrator is not connected. Call connect() first."); | ||
| } | ||
| return this.resourceRouter; | ||
| } | ||
| requirePromptRouter() { | ||
| if (this.promptRouter === null) { | ||
| throw new Error("Orchestrator is not connected. Call connect() first."); | ||
| } | ||
| return this.promptRouter; | ||
| } | ||
| resolveResourceOwner(uri) { | ||
| const owner = this.requireResourceRouter().ownerOf(uri); | ||
| if (owner === void 0) { | ||
| throw new Error(`Unknown resource URI: "${uri}". No upstream MCP advertises it.`); | ||
| } | ||
| return this.requireClient(owner, "resource"); | ||
| } | ||
| resolvePromptOwner(name) { | ||
| const owner = this.requirePromptRouter().ownerOf(name); | ||
| if (owner === void 0) { | ||
| throw new Error(`Unknown prompt: "${name}". No upstream MCP advertises it.`); | ||
| } | ||
| return this.requireClient(owner, "prompt"); | ||
| } | ||
| resolveCompletionTarget(ref) { | ||
| if (ref.type === "ref/prompt") { | ||
| return this.resolvePromptOwner(ref.name); | ||
| } | ||
| if (ref.type === "ref/resource") { | ||
| return this.resolveResourceOwner(ref.uri); | ||
| } | ||
| const unknownRef = ref; | ||
| throw new Error(`Unsupported completion ref type: "${unknownRef.type}"`); | ||
| } | ||
| requireClient(mcpName2, role) { | ||
| const client = this.registry.get(mcpName2); | ||
| if (client === void 0) { | ||
| const available = [...this.clients.keys()].sort().join(", "); | ||
| throw new Error(`Unknown MCP: "${mcpName2}". Available MCPs: ${available}`); | ||
| throw new Error(`Internal error: ${role} owner "${mcpName2}" has no connected client.`); | ||
| } | ||
| return client.callTool(toolName, input); | ||
| return client; | ||
| } | ||
| async disconnectAll() { | ||
| const disconnections = [...this.clients.values()].map((client) => client.disconnect()); | ||
| await Promise.all(disconnections); | ||
| this.clients.clear(); | ||
| this.toolCatalog = null; | ||
| async broadcastAsync(action, label) { | ||
| const targets = []; | ||
| for (const [mcpName2, client] of this.registry.entries()) { | ||
| targets.push( | ||
| action(client).catch((error) => { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| process4.stderr.write(`dynmcp: ${label} failed for "${mcpName2}": ${message} | ||
| `); | ||
| }) | ||
| ); | ||
| } | ||
| await Promise.all(targets); | ||
| } | ||
| }; | ||
| function splitNamespacedName(namespacedName, knownMcpNames) { | ||
| const separatorIndex = namespacedName.indexOf("/"); | ||
| if (separatorIndex === -1) { | ||
| throw new Error( | ||
| `Invalid namespaced tool name: "${namespacedName}". Expected format: "mcpName/toolName".` | ||
| ); | ||
| } | ||
| const mcpName2 = namespacedName.slice(0, separatorIndex); | ||
| const toolName = namespacedName.slice(separatorIndex + 1); | ||
| if (!knownMcpNames.includes(mcpName2)) { | ||
| const available = [...knownMcpNames].sort().join(", "); | ||
| throw new Error(`Unknown MCP: "${mcpName2}". Available MCPs: ${available}`); | ||
| } | ||
| return { mcpName: mcpName2, toolName }; | ||
| } | ||
| function logCollisions(resourceRouter, promptRouter) { | ||
| for (const collision of resourceRouter.collisions()) { | ||
| process4.stderr.write( | ||
| `dynmcp: resource URI collision: "${collision.uri}" is provided by "${collision.chosen}" and "${collision.shadowed}"; routing to "${collision.chosen}". | ||
| ` | ||
| ); | ||
| } | ||
| for (const collision of promptRouter.collisions()) { | ||
| process4.stderr.write( | ||
| `dynmcp: prompt name collision: "${collision.name}" is provided by "${collision.chosen}" and "${collision.shadowed}"; routing to "${collision.chosen}". | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| // src/proxy/server.ts | ||
| import process4 from "process"; | ||
| import { FastMCP } from "fastmcp"; | ||
| import process5 from "process"; | ||
| import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { | ||
| CallToolRequestSchema, | ||
| CompleteRequestSchema, | ||
| GetPromptRequestSchema, | ||
| ListPromptsRequestSchema, | ||
| ListResourcesRequestSchema, | ||
| ListResourceTemplatesRequestSchema, | ||
| ListToolsRequestSchema, | ||
| ReadResourceRequestSchema, | ||
| RootsListChangedNotificationSchema, | ||
| SetLevelRequestSchema, | ||
| SubscribeRequestSchema, | ||
| UnsubscribeRequestSchema | ||
| } from "@modelcontextprotocol/sdk/types.js"; | ||
| import { z as z3 } from "zod"; | ||
| var DISCOVER_TOOL_NAME = "discover_tool"; | ||
| var USE_TOOL_NAME = "use_tool"; | ||
| var USE_TOOL_DESCRIPTION = "Use a tool that was previously discovered with the discover_tool tool."; | ||
| var DISCOVER_TOOL_INPUT_SCHEMA = { | ||
| type: "object", | ||
| properties: { | ||
| tool_name: { type: "string" } | ||
| }, | ||
| required: ["tool_name"] | ||
| }; | ||
| var USE_TOOL_INPUT_SCHEMA = { | ||
| type: "object", | ||
| properties: { | ||
| tool_name: { type: "string" }, | ||
| tool_input: { type: "object", additionalProperties: true, default: {} } | ||
| }, | ||
| required: ["tool_name"] | ||
| }; | ||
| var DiscoverToolArgsSchema = z3.object({ tool_name: z3.string() }); | ||
| var UseToolArgsSchema = z3.object({ | ||
| tool_name: z3.string(), | ||
| tool_input: z3.record(z3.string(), z3.unknown()).default({}) | ||
| }); | ||
| var ProxyServer = class { | ||
| catalog; | ||
| callTool; | ||
| constructor({ catalog, callTool }) { | ||
| capabilities; | ||
| resources; | ||
| prompts; | ||
| complete; | ||
| setLoggingLevelCallback; | ||
| onRootsListChangedCallback; | ||
| sdkServer = null; | ||
| constructor({ | ||
| catalog, | ||
| callTool, | ||
| capabilities, | ||
| resources, | ||
| prompts, | ||
| complete, | ||
| setLoggingLevel, | ||
| onRootsListChanged | ||
| }) { | ||
| this.catalog = catalog; | ||
| this.callTool = callTool; | ||
| this.capabilities = capabilities; | ||
| this.resources = resources; | ||
| this.prompts = prompts; | ||
| this.complete = complete; | ||
| this.setLoggingLevelCallback = setLoggingLevel; | ||
| this.onRootsListChangedCallback = onRootsListChanged; | ||
| } | ||
| buildServer() { | ||
| const server = new Server( | ||
| { | ||
| name: "dynamic-discovery-mcp", | ||
| version: package_default.version | ||
| }, | ||
| { | ||
| capabilities: this.capabilities | ||
| } | ||
| ); | ||
| this.registerToolHandlers(server); | ||
| if (this.capabilities.resources !== void 0 && this.resources !== void 0) { | ||
| this.registerResourceHandlers(server, this.resources); | ||
| } | ||
| if (this.capabilities.prompts !== void 0 && this.prompts !== void 0) { | ||
| this.registerPromptHandlers(server, this.prompts); | ||
| } | ||
| if (this.capabilities.completions !== void 0 && this.complete !== void 0) { | ||
| this.registerCompletionHandler(server, this.complete); | ||
| } | ||
| if (this.capabilities.logging !== void 0 && this.setLoggingLevelCallback !== void 0) { | ||
| this.registerLoggingHandler(server, this.setLoggingLevelCallback); | ||
| } | ||
| if (this.onRootsListChangedCallback !== void 0) { | ||
| const callback = this.onRootsListChangedCallback; | ||
| server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { | ||
| await callback(); | ||
| }); | ||
| } | ||
| this.sdkServer = server; | ||
| return server; | ||
| } | ||
| /** | ||
| * Forwards an upstream-initiated `sampling/createMessage` request to the host. The | ||
| * upstream's abort signal is threaded through so cancellation by the upstream | ||
| * propagates to the host. | ||
| */ | ||
| async forwardCreateMessage(params, options) { | ||
| const server = this.requireSdkServer(); | ||
| return server.createMessage(params, options); | ||
| } | ||
| /** | ||
| * Forwards an upstream-initiated `elicitation/create` request to the host. | ||
| */ | ||
| async forwardElicitInput(params, options) { | ||
| const server = this.requireSdkServer(); | ||
| return server.elicitInput(params, options); | ||
| } | ||
| /** | ||
| * Forwards an upstream-initiated `roots/list` request to the host. | ||
| */ | ||
| async forwardListRoots(params, options) { | ||
| const server = this.requireSdkServer(); | ||
| return server.listRoots(params, options); | ||
| } | ||
| requireSdkServer() { | ||
| if (this.sdkServer === null) { | ||
| throw new Error("ProxyServer is not built. Call buildServer() before forwarding requests."); | ||
| } | ||
| return this.sdkServer; | ||
| } | ||
| async start() { | ||
| const server = new FastMCP({ | ||
| name: "dynamic-discovery-mcp", | ||
| version: package_default.version | ||
| }); | ||
| server.addTool({ | ||
| name: "discover_tool", | ||
| description: this.catalog.discoverToolDescription, | ||
| parameters: z3.object({ tool_name: z3.string() }), | ||
| execute: async ({ tool_name }) => { | ||
| return this.catalog.getToolDetails(tool_name); | ||
| const server = this.buildServer(); | ||
| const transport = new StdioServerTransport(); | ||
| process5.stderr.write("Starting dynamic-discovery-mcp server over stdio\n"); | ||
| await server.connect(transport); | ||
| } | ||
| /** | ||
| * Notifies the host that the discover_tool description has changed because an upstream | ||
| * emitted `notifications/tools/list_changed`. The host should re-fetch the tools list | ||
| * to pick up the regenerated catalog. Silently no-ops if `buildServer()` has not been | ||
| * called yet. | ||
| */ | ||
| async sendToolListChanged() { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendToolListChanged(); | ||
| } | ||
| } | ||
| /** | ||
| * Notifies the host that the proxy's aggregated resource list has changed. Silently | ||
| * no-ops if `buildServer()` has not been called yet. Errors propagate. | ||
| */ | ||
| async sendResourceListChanged() { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendResourceListChanged(); | ||
| } | ||
| } | ||
| /** | ||
| * Notifies the host that a specific subscribed resource has changed. Silently no-ops | ||
| * if `buildServer()` has not been called yet. | ||
| */ | ||
| async sendResourceUpdated(params) { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendResourceUpdated(params); | ||
| } | ||
| } | ||
| /** | ||
| * Notifies the host that the proxy's aggregated prompt list has changed. Silently | ||
| * no-ops if `buildServer()` has not been called yet. | ||
| */ | ||
| async sendPromptListChanged() { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendPromptListChanged(); | ||
| } | ||
| } | ||
| registerToolHandlers(server) { | ||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ | ||
| tools: [ | ||
| { | ||
| name: DISCOVER_TOOL_NAME, | ||
| description: this.catalog().discoverToolDescription, | ||
| inputSchema: DISCOVER_TOOL_INPUT_SCHEMA | ||
| }, | ||
| { | ||
| name: USE_TOOL_NAME, | ||
| description: USE_TOOL_DESCRIPTION, | ||
| inputSchema: USE_TOOL_INPUT_SCHEMA | ||
| } | ||
| ] | ||
| })); | ||
| server.setRequestHandler( | ||
| CallToolRequestSchema, | ||
| async (request, extra) => { | ||
| const { name, arguments: rawArgs } = request.params; | ||
| const catalog = this.catalog(); | ||
| if (name === DISCOVER_TOOL_NAME) { | ||
| const args = DiscoverToolArgsSchema.parse(rawArgs ?? {}); | ||
| return { | ||
| content: [{ type: "text", text: catalog.getToolDetails(args.tool_name) }] | ||
| }; | ||
| } | ||
| if (name === USE_TOOL_NAME) { | ||
| const args = UseToolArgsSchema.parse(rawArgs ?? {}); | ||
| if (!catalog.tools.has(args.tool_name)) { | ||
| return { | ||
| content: [{ type: "text", text: catalog.getToolDetails(args.tool_name) }] | ||
| }; | ||
| } | ||
| return await this.callTool(args.tool_name, args.tool_input, { signal: extra.signal }); | ||
| } | ||
| return { | ||
| isError: true, | ||
| content: [{ type: "text", text: `Unknown tool: "${name}"` }] | ||
| }; | ||
| } | ||
| ); | ||
| } | ||
| registerResourceHandlers(server, callbacks) { | ||
| server.setRequestHandler( | ||
| ListResourcesRequestSchema, | ||
| async () => ({ | ||
| resources: callbacks.listResources() | ||
| }) | ||
| ); | ||
| server.setRequestHandler( | ||
| ListResourceTemplatesRequestSchema, | ||
| async () => ({ | ||
| resourceTemplates: callbacks.listResourceTemplates() | ||
| }) | ||
| ); | ||
| server.setRequestHandler( | ||
| ReadResourceRequestSchema, | ||
| async (request, extra) => { | ||
| return callbacks.readResource(request.params.uri, { signal: extra.signal }); | ||
| } | ||
| ); | ||
| server.setRequestHandler(SubscribeRequestSchema, async (request, extra) => { | ||
| await callbacks.subscribeResource(request.params.uri, { signal: extra.signal }); | ||
| return {}; | ||
| }); | ||
| server.addTool({ | ||
| name: "use_tool", | ||
| description: "Use a tool that was previously discovered with the discover_tool tool.", | ||
| parameters: z3.object({ | ||
| tool_name: z3.string(), | ||
| tool_input: z3.record(z3.string(), z3.unknown()).default({}) | ||
| }), | ||
| execute: async ({ tool_name, tool_input }) => { | ||
| if (!this.catalog.tools.has(tool_name)) { | ||
| return this.catalog.getToolDetails(tool_name); | ||
| } | ||
| const result = await this.callTool(tool_name, tool_input); | ||
| return result; | ||
| server.setRequestHandler(UnsubscribeRequestSchema, async (request, extra) => { | ||
| await callbacks.unsubscribeResource(request.params.uri, { signal: extra.signal }); | ||
| return {}; | ||
| }); | ||
| } | ||
| registerPromptHandlers(server, callbacks) { | ||
| server.setRequestHandler( | ||
| ListPromptsRequestSchema, | ||
| async () => ({ | ||
| prompts: callbacks.listPrompts() | ||
| }) | ||
| ); | ||
| server.setRequestHandler( | ||
| GetPromptRequestSchema, | ||
| async (request, extra) => { | ||
| return callbacks.getPrompt(request.params.name, request.params.arguments, { | ||
| signal: extra.signal | ||
| }); | ||
| } | ||
| ); | ||
| } | ||
| registerCompletionHandler(server, callback) { | ||
| server.setRequestHandler( | ||
| CompleteRequestSchema, | ||
| async (request, extra) => { | ||
| return callback(request.params, { signal: extra.signal }); | ||
| } | ||
| ); | ||
| } | ||
| registerLoggingHandler(server, callback) { | ||
| server.setRequestHandler(SetLevelRequestSchema, async (request, extra) => { | ||
| await callback(request.params.level, { signal: extra.signal }); | ||
| return {}; | ||
| }); | ||
| process4.stderr.write("Starting dynamic-discovery-mcp server over stdio\n"); | ||
| await server.start({ transportType: "stdio" }); | ||
| } | ||
| /** | ||
| * Forwards a log message from an upstream MCP to the host. Silently no-ops if | ||
| * `buildServer()` has not been called yet. | ||
| */ | ||
| async sendLoggingMessage(params) { | ||
| if (this.sdkServer !== null) { | ||
| await this.sdkServer.sendLoggingMessage(params); | ||
| } | ||
| } | ||
| }; | ||
| // src/proxy/transport-factory.ts | ||
| import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | ||
| import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | ||
| function createTransport(config) { | ||
| switch (config.transport) { | ||
| case "stdio": | ||
| return new StdioClientTransport({ | ||
| command: config.command, | ||
| args: config.args, | ||
| env: config.env | ||
| }); | ||
| case "streamable-http": | ||
| return new StreamableHTTPClientTransport( | ||
| new URL(config.url), | ||
| config.headers ? { requestInit: { headers: config.headers } } : void 0 | ||
| ); | ||
| case "sse": | ||
| return new SSEClientTransport( | ||
| new URL(config.url), | ||
| config.headers ? { requestInit: { headers: config.headers } } : void 0 | ||
| ); | ||
| default: { | ||
| const _exhaustive = config; | ||
| return _exhaustive; | ||
| } | ||
| } | ||
| } | ||
| // src/proxy/index.ts | ||
| var SINGLE_MCP_NAME = "__default__"; | ||
| async function startProxy(command, args) { | ||
| let isShuttingDown = false; | ||
| const transport = new StdioClientTransport2({ command, args }); | ||
| const upstreamClient = new UpstreamClient({ | ||
| name: command, | ||
| transport, | ||
| onTransportError: (error) => { | ||
| process5.stderr.write(`Upstream MCP transport error: ${error.message} | ||
| `); | ||
| shutdown(1); | ||
| } | ||
| const mcps = /* @__PURE__ */ new Map([[SINGLE_MCP_NAME, { transport }]]); | ||
| const orchestrator = buildOrchestrator({ | ||
| mcps, | ||
| namespaced: false, | ||
| transportErrorPrefix: () => "Upstream MCP" | ||
| }); | ||
| const shutdown = (exitCode) => { | ||
| if (isShuttingDown) return; | ||
| isShuttingDown = true; | ||
| upstreamClient.disconnect().catch((error) => { | ||
| process5.stderr.write( | ||
| `dynmcp: error during disconnect: ${error instanceof Error ? error.message : String(error)} | ||
| ` | ||
| ); | ||
| }).finally(() => process5.exit(exitCode)); | ||
| }; | ||
| try { | ||
| await upstreamClient.connect(); | ||
| } catch (error) { | ||
| process5.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| process5.exit(1); | ||
| } | ||
| let tools; | ||
| try { | ||
| tools = await upstreamClient.listTools(); | ||
| } catch (error) { | ||
| process5.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| process5.exit(1); | ||
| } | ||
| const catalog = ToolCatalog.fromFlat(tools); | ||
| const proxyServer = new ProxyServer({ | ||
| catalog, | ||
| callTool: (name, input) => upstreamClient.callTool(name, input) | ||
| }); | ||
| process5.on("SIGINT", () => shutdown(0)); | ||
| process5.on("SIGTERM", () => shutdown(0)); | ||
| process5.stdin.on("end", () => shutdown(0)); | ||
| process5.stdin.on("close", () => shutdown(0)); | ||
| try { | ||
| await proxyServer.start(); | ||
| } catch (error) { | ||
| shutdown(1); | ||
| throw error; | ||
| } | ||
| await runProxy(orchestrator); | ||
| } | ||
| async function startProxyFromConfig(options = {}) { | ||
| let isShuttingDown = false; | ||
| const config = loadConfig(options); | ||
@@ -734,10 +1611,25 @@ const mcps = /* @__PURE__ */ new Map(); | ||
| } | ||
| const orchestrator = new Orchestrator({ | ||
| const orchestrator = buildOrchestrator({ | ||
| mcps, | ||
| namespaced: true, | ||
| transportErrorPrefix: (mcpName2) => `Upstream MCP "${mcpName2}"` | ||
| }); | ||
| await runProxy(orchestrator); | ||
| } | ||
| var activeShutdown = { shutdown: null }; | ||
| function buildOrchestrator(params) { | ||
| return new Orchestrator({ | ||
| mcps: params.mcps, | ||
| namespaced: params.namespaced, | ||
| onTransportError: (mcpName2, error) => { | ||
| process5.stderr.write(`Upstream MCP "${mcpName2}" transport error: ${error.message} | ||
| `); | ||
| shutdown(1); | ||
| process6.stderr.write( | ||
| `${params.transportErrorPrefix(mcpName2)} transport error: ${error.message} | ||
| ` | ||
| ); | ||
| activeShutdown.shutdown?.(1); | ||
| } | ||
| }); | ||
| } | ||
| async function runProxy(orchestrator) { | ||
| let isShuttingDown = false; | ||
| const shutdown = (exitCode) => { | ||
@@ -747,23 +1639,52 @@ if (isShuttingDown) return; | ||
| orchestrator.disconnectAll().catch((error) => { | ||
| process5.stderr.write( | ||
| process6.stderr.write( | ||
| `dynmcp: error during disconnect: ${error instanceof Error ? error.message : String(error)} | ||
| ` | ||
| ); | ||
| }).finally(() => process5.exit(exitCode)); | ||
| }).finally(() => process6.exit(exitCode)); | ||
| }; | ||
| activeShutdown.shutdown = shutdown; | ||
| try { | ||
| await orchestrator.connect(); | ||
| } catch (error) { | ||
| process5.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| process6.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| process5.exit(1); | ||
| process6.exit(1); | ||
| return; | ||
| } | ||
| const proxyServer = new ProxyServer({ | ||
| catalog: orchestrator.catalog, | ||
| callTool: (name, input) => orchestrator.callTool(name, input) | ||
| catalog: () => orchestrator.catalog, | ||
| capabilities: orchestrator.capabilities, | ||
| callTool: (name, input, options) => orchestrator.callTool(name, input, options), | ||
| resources: orchestrator.capabilities.resources !== void 0 ? { | ||
| listResources: () => orchestrator.listResources(), | ||
| listResourceTemplates: () => orchestrator.listResourceTemplates(), | ||
| readResource: (uri, options) => orchestrator.readResource(uri, options), | ||
| subscribeResource: (uri, options) => orchestrator.subscribeResource(uri, options), | ||
| unsubscribeResource: (uri, options) => orchestrator.unsubscribeResource(uri, options) | ||
| } : void 0, | ||
| prompts: orchestrator.capabilities.prompts !== void 0 ? { | ||
| listPrompts: () => orchestrator.listPrompts(), | ||
| getPrompt: (name, args, options) => orchestrator.getPrompt(name, args, options) | ||
| } : void 0, | ||
| complete: orchestrator.capabilities.completions !== void 0 ? (params, options) => orchestrator.complete(params, options) : void 0, | ||
| setLoggingLevel: orchestrator.capabilities.logging !== void 0 ? (level, options) => orchestrator.setLoggingLevel(level, options) : void 0, | ||
| onRootsListChanged: () => orchestrator.broadcastRootsListChanged() | ||
| }); | ||
| process5.on("SIGINT", () => shutdown(0)); | ||
| process5.on("SIGTERM", () => shutdown(0)); | ||
| process5.stdin.on("end", () => shutdown(0)); | ||
| process5.stdin.on("close", () => shutdown(0)); | ||
| orchestrator.setNotificationHandlers({ | ||
| onToolsListChanged: () => proxyServer.sendToolListChanged(), | ||
| onResourcesListChanged: () => proxyServer.sendResourceListChanged(), | ||
| onResourceUpdated: (params) => proxyServer.sendResourceUpdated(params), | ||
| onPromptsListChanged: () => proxyServer.sendPromptListChanged(), | ||
| onLogMessage: (params) => proxyServer.sendLoggingMessage(params) | ||
| }); | ||
| orchestrator.setServerRequestForwarders({ | ||
| onCreateMessage: (params, options) => proxyServer.forwardCreateMessage(params, options), | ||
| onElicitInput: (params, options) => proxyServer.forwardElicitInput(params, options), | ||
| onListRoots: (params, options) => proxyServer.forwardListRoots(params, options) | ||
| }); | ||
| process6.on("SIGINT", () => shutdown(0)); | ||
| process6.on("SIGTERM", () => shutdown(0)); | ||
| process6.stdin.on("end", () => shutdown(0)); | ||
| process6.stdin.on("close", () => shutdown(0)); | ||
| try { | ||
@@ -788,16 +1709,13 @@ await proxyServer.start(); | ||
| "\nExamples:\n dynmcp -- npx -y chrome-devtools-mcp@latest\n dynmcp --config ./mcp.json\n" | ||
| ).option("-c, --config <path>", "Path to config file (JSON or YAML)").option( | ||
| "-e, --env <path>", | ||
| "Path to a .env file for environment variable interpolation" | ||
| ).allowExcessArguments(true).passThroughOptions(true).action(async (_options, cmd) => { | ||
| const separatorIndex = process6.argv.indexOf("--"); | ||
| ).option("-c, --config <path>", "Path to config file (JSON or YAML)").option("-e, --env <path>", "Path to a .env file for environment variable interpolation").allowExcessArguments(true).passThroughOptions(true).action(async (_options, cmd) => { | ||
| const separatorIndex = process7.argv.indexOf("--"); | ||
| const configPath = cmd.opts().config; | ||
| const envFilePath = cmd.opts().env; | ||
| if (separatorIndex !== -1) { | ||
| const [command, ...args] = process6.argv.slice(separatorIndex + 1); | ||
| const [command, ...args] = process7.argv.slice(separatorIndex + 1); | ||
| if (command === void 0) { | ||
| process6.stderr.write( | ||
| process7.stderr.write( | ||
| "dynmcp: no upstream command provided after --.\nUsage: dynmcp -- <command> [args...]\n" | ||
| ); | ||
| process6.exit(1); | ||
| process7.exit(1); | ||
| } | ||
@@ -807,5 +1725,5 @@ try { | ||
| } catch (error) { | ||
| process6.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| process7.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| process6.exit(1); | ||
| process7.exit(1); | ||
| } | ||
@@ -817,5 +1735,5 @@ return; | ||
| } catch (error) { | ||
| process6.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| process7.stderr.write(`dynmcp: ${error instanceof Error ? error.message : String(error)} | ||
| `); | ||
| process6.exit(1); | ||
| process7.exit(1); | ||
| } | ||
@@ -825,7 +1743,7 @@ }); | ||
| // src/index.ts | ||
| import process7 from "process"; | ||
| import process8 from "process"; | ||
| async function main() { | ||
| cli.parse(process7.argv); | ||
| cli.parse(process8.argv); | ||
| } | ||
| main(); | ||
| //# sourceMappingURL=index.js.map |
+2
-1
| { | ||
| "name": "dynmcp", | ||
| "version": "0.2.0", | ||
| "version": "0.3.0", | ||
| "description": "Dynamic MCP context management tool for AI MCP-enabled agents and clients.", | ||
@@ -83,2 +83,3 @@ "author": "Brandon Burrus <brandon@burrus.io>", | ||
| "@types/node": "^25.9.0", | ||
| "@vitest/coverage-v8": "^4.1.6", | ||
| "husky": "^9.1.7", | ||
@@ -85,0 +86,0 @@ "tsup": "^8.5.1", |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
387173
115.66%3539
103.74%10
11.11%