@mcp-rating/gateway 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +130 -0
  3. package/dist/ads/ad-tracker.d.ts +63 -0
  4. package/dist/ads/ad-tracker.js +144 -0
  5. package/dist/ads/ad-tracker.js.map +1 -0
  6. package/dist/analytics/usage-tracker.d.ts +67 -0
  7. package/dist/analytics/usage-tracker.js +148 -0
  8. package/dist/analytics/usage-tracker.js.map +1 -0
  9. package/dist/audit/audit-log.d.ts +71 -0
  10. package/dist/audit/audit-log.js +72 -0
  11. package/dist/audit/audit-log.js.map +1 -0
  12. package/dist/config/gateway-config.d.ts +6 -0
  13. package/dist/config/gateway-config.js +135 -0
  14. package/dist/config/gateway-config.js.map +1 -0
  15. package/dist/config/groups.d.ts +29 -0
  16. package/dist/config/groups.js +102 -0
  17. package/dist/config/groups.js.map +1 -0
  18. package/dist/config/profiles.d.ts +42 -0
  19. package/dist/config/profiles.js +167 -0
  20. package/dist/config/profiles.js.map +1 -0
  21. package/dist/config/types.d.ts +76 -0
  22. package/dist/config/types.js +2 -0
  23. package/dist/config/types.js.map +1 -0
  24. package/dist/connection/auto-installer.d.ts +13 -0
  25. package/dist/connection/auto-installer.js +61 -0
  26. package/dist/connection/auto-installer.js.map +1 -0
  27. package/dist/connection/connection-manager.d.ts +106 -0
  28. package/dist/connection/connection-manager.js +845 -0
  29. package/dist/connection/connection-manager.js.map +1 -0
  30. package/dist/connection/downstream-client.d.ts +50 -0
  31. package/dist/connection/downstream-client.js +170 -0
  32. package/dist/connection/downstream-client.js.map +1 -0
  33. package/dist/connection/http-client.d.ts +12 -0
  34. package/dist/connection/http-client.js +26 -0
  35. package/dist/connection/http-client.js.map +1 -0
  36. package/dist/connection/sse-client.d.ts +12 -0
  37. package/dist/connection/sse-client.js +26 -0
  38. package/dist/connection/sse-client.js.map +1 -0
  39. package/dist/connection/transport-factory.d.ts +31 -0
  40. package/dist/connection/transport-factory.js +67 -0
  41. package/dist/connection/transport-factory.js.map +1 -0
  42. package/dist/connection/types.d.ts +134 -0
  43. package/dist/connection/types.js +5 -0
  44. package/dist/connection/types.js.map +1 -0
  45. package/dist/connection/ws-client.d.ts +12 -0
  46. package/dist/connection/ws-client.js +26 -0
  47. package/dist/connection/ws-client.js.map +1 -0
  48. package/dist/discovery/auto-recommender.d.ts +51 -0
  49. package/dist/discovery/auto-recommender.js +176 -0
  50. package/dist/discovery/auto-recommender.js.map +1 -0
  51. package/dist/gateway-server.d.ts +42 -0
  52. package/dist/gateway-server.js +135 -0
  53. package/dist/gateway-server.js.map +1 -0
  54. package/dist/http-daemon.d.ts +26 -0
  55. package/dist/http-daemon.js +302 -0
  56. package/dist/http-daemon.js.map +1 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.js +83 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/meta-tools/ad-status.d.ts +8 -0
  61. package/dist/meta-tools/ad-status.js +41 -0
  62. package/dist/meta-tools/ad-status.js.map +1 -0
  63. package/dist/meta-tools/audit.d.ts +12 -0
  64. package/dist/meta-tools/audit.js +98 -0
  65. package/dist/meta-tools/audit.js.map +1 -0
  66. package/dist/meta-tools/call-tool.d.ts +11 -0
  67. package/dist/meta-tools/call-tool.js +65 -0
  68. package/dist/meta-tools/call-tool.js.map +1 -0
  69. package/dist/meta-tools/connect.d.ts +3 -0
  70. package/dist/meta-tools/connect.js +105 -0
  71. package/dist/meta-tools/connect.js.map +1 -0
  72. package/dist/meta-tools/disconnect.d.ts +3 -0
  73. package/dist/meta-tools/disconnect.js +30 -0
  74. package/dist/meta-tools/disconnect.js.map +1 -0
  75. package/dist/meta-tools/discover.d.ts +3 -0
  76. package/dist/meta-tools/discover.js +97 -0
  77. package/dist/meta-tools/discover.js.map +1 -0
  78. package/dist/meta-tools/groups.d.ts +7 -0
  79. package/dist/meta-tools/groups.js +237 -0
  80. package/dist/meta-tools/groups.js.map +1 -0
  81. package/dist/meta-tools/health.d.ts +4 -0
  82. package/dist/meta-tools/health.js +76 -0
  83. package/dist/meta-tools/health.js.map +1 -0
  84. package/dist/meta-tools/index.d.ts +28 -0
  85. package/dist/meta-tools/index.js +56 -0
  86. package/dist/meta-tools/index.js.map +1 -0
  87. package/dist/meta-tools/list-active.d.ts +3 -0
  88. package/dist/meta-tools/list-active.js +50 -0
  89. package/dist/meta-tools/list-active.js.map +1 -0
  90. package/dist/meta-tools/profiles.d.ts +6 -0
  91. package/dist/meta-tools/profiles.js +197 -0
  92. package/dist/meta-tools/profiles.js.map +1 -0
  93. package/dist/meta-tools/recommend.d.ts +14 -0
  94. package/dist/meta-tools/recommend.js +74 -0
  95. package/dist/meta-tools/recommend.js.map +1 -0
  96. package/dist/meta-tools/sandbox.d.ts +12 -0
  97. package/dist/meta-tools/sandbox.js +125 -0
  98. package/dist/meta-tools/sandbox.js.map +1 -0
  99. package/dist/meta-tools/server-info.d.ts +4 -0
  100. package/dist/meta-tools/server-info.js +156 -0
  101. package/dist/meta-tools/server-info.js.map +1 -0
  102. package/dist/meta-tools/usage.d.ts +6 -0
  103. package/dist/meta-tools/usage.js +149 -0
  104. package/dist/meta-tools/usage.js.map +1 -0
  105. package/dist/permissions/permission-manager.d.ts +26 -0
  106. package/dist/permissions/permission-manager.js +73 -0
  107. package/dist/permissions/permission-manager.js.map +1 -0
  108. package/dist/permissions/trust-tiers.d.ts +18 -0
  109. package/dist/permissions/trust-tiers.js +42 -0
  110. package/dist/permissions/trust-tiers.js.map +1 -0
  111. package/dist/plugins/builtin/logging-plugin.d.ts +6 -0
  112. package/dist/plugins/builtin/logging-plugin.js +41 -0
  113. package/dist/plugins/builtin/logging-plugin.js.map +1 -0
  114. package/dist/plugins/plugin-manager.d.ts +65 -0
  115. package/dist/plugins/plugin-manager.js +137 -0
  116. package/dist/plugins/plugin-manager.js.map +1 -0
  117. package/dist/proxy/prime-handlers.d.ts +27 -0
  118. package/dist/proxy/prime-handlers.js +47 -0
  119. package/dist/proxy/prime-handlers.js.map +1 -0
  120. package/dist/proxy/progress-forwarder.d.ts +44 -0
  121. package/dist/proxy/progress-forwarder.js +40 -0
  122. package/dist/proxy/progress-forwarder.js.map +1 -0
  123. package/dist/proxy/prompt-router.d.ts +25 -0
  124. package/dist/proxy/prompt-router.js +110 -0
  125. package/dist/proxy/prompt-router.js.map +1 -0
  126. package/dist/proxy/resource-router.d.ts +25 -0
  127. package/dist/proxy/resource-router.js +91 -0
  128. package/dist/proxy/resource-router.js.map +1 -0
  129. package/dist/proxy/schema-adapter.d.ts +11 -0
  130. package/dist/proxy/schema-adapter.js +36 -0
  131. package/dist/proxy/schema-adapter.js.map +1 -0
  132. package/dist/proxy/task-proxy.d.ts +28 -0
  133. package/dist/proxy/task-proxy.js +186 -0
  134. package/dist/proxy/task-proxy.js.map +1 -0
  135. package/dist/proxy/tool-router.d.ts +38 -0
  136. package/dist/proxy/tool-router.js +229 -0
  137. package/dist/proxy/tool-router.js.map +1 -0
  138. package/dist/proxy/ui-detect.d.ts +22 -0
  139. package/dist/proxy/ui-detect.js +30 -0
  140. package/dist/proxy/ui-detect.js.map +1 -0
  141. package/dist/registry/registry-client.d.ts +55 -0
  142. package/dist/registry/registry-client.js +211 -0
  143. package/dist/registry/registry-client.js.map +1 -0
  144. package/dist/registry/types.d.ts +69 -0
  145. package/dist/registry/types.js +2 -0
  146. package/dist/registry/types.js.map +1 -0
  147. package/dist/sandbox/container-runtime.d.ts +91 -0
  148. package/dist/sandbox/container-runtime.js +178 -0
  149. package/dist/sandbox/container-runtime.js.map +1 -0
  150. package/dist/sandbox/egress-proxy.d.ts +51 -0
  151. package/dist/sandbox/egress-proxy.js +146 -0
  152. package/dist/sandbox/egress-proxy.js.map +1 -0
  153. package/dist/sandbox/env-scoper.d.ts +29 -0
  154. package/dist/sandbox/env-scoper.js +60 -0
  155. package/dist/sandbox/env-scoper.js.map +1 -0
  156. package/dist/sandbox/manifest-resolver.d.ts +40 -0
  157. package/dist/sandbox/manifest-resolver.js +185 -0
  158. package/dist/sandbox/manifest-resolver.js.map +1 -0
  159. package/dist/sandbox/types.d.ts +88 -0
  160. package/dist/sandbox/types.js +21 -0
  161. package/dist/sandbox/types.js.map +1 -0
  162. package/dist/utils/errors.d.ts +39 -0
  163. package/dist/utils/errors.js +67 -0
  164. package/dist/utils/errors.js.map +1 -0
  165. package/dist/utils/logger.d.ts +16 -0
  166. package/dist/utils/logger.js +51 -0
  167. package/dist/utils/logger.js.map +1 -0
  168. package/package.json +72 -0
@@ -0,0 +1,845 @@
1
+ import { buildScopedEnvironment } from "../sandbox/env-scoper.js";
2
+ import { detectContainerEngine, buildContainerCommand, resolveBakedImage, } from "../sandbox/container-runtime.js";
3
+ import { EgressProxy } from "../sandbox/egress-proxy.js";
4
+ import { registerProxiedTools, removeProxiedTools, } from "../proxy/tool-router.js";
5
+ import { registerProxiedResources, removeProxiedResources, } from "../proxy/resource-router.js";
6
+ import { registerProxiedPrompts, removeProxiedPrompts, } from "../proxy/prompt-router.js";
7
+ import { createTransport } from "./transport-factory.js";
8
+ import { ConnectionError, DuplicateConnectionError, MaxConnectionsError, PermissionDeniedError, } from "../utils/errors.js";
9
+ import { getTrustPolicy } from "../permissions/trust-tiers.js";
10
+ import { log } from "../utils/logger.js";
11
+ import { loadProfiles, addProfile, removeProfile } from "../config/profiles.js";
12
+ import { ensureInstalled } from "./auto-installer.js";
13
+ /**
14
+ * Manages the lifecycle of all downstream MCP server connections.
15
+ *
16
+ * Responsibilities:
17
+ * - Connect to downstream servers via stdio, SSE, WebSocket, or Streamable HTTP
18
+ * - Register/remove proxied tools, resources, and prompts on the gateway
19
+ * - Handle process crashes with auto-reconnect
20
+ * - Periodic health monitoring via pings
21
+ * - Notify the host client when tool/resource/prompt lists change
22
+ */
23
+ export class ConnectionManager {
24
+ mcpServer;
25
+ registryClient;
26
+ permissionManager;
27
+ config;
28
+ adTracker;
29
+ connections = new Map();
30
+ /** Debounce timers for tool re-sync per slug to prevent notification spam */
31
+ resyncTimers = new Map();
32
+ /** Active reconnect timers per slug */
33
+ reconnectTimers = new Map();
34
+ /** Health check interval handle */
35
+ healthCheckInterval = null;
36
+ /** Gateway start time for uptime calculation */
37
+ startedAt = new Date();
38
+ /** Optional usage tracker for analytics */
39
+ usageTracker;
40
+ /** Optional plugin manager for middleware hooks */
41
+ pluginManager;
42
+ /** Optional sandbox manifest resolver (L1 capability enforcement) */
43
+ manifestResolver;
44
+ /** Optional safety audit trail (forensics log of sandboxed-server actions) */
45
+ auditLog;
46
+ constructor(mcpServer, registryClient, permissionManager, config, adTracker) {
47
+ this.mcpServer = mcpServer;
48
+ this.registryClient = registryClient;
49
+ this.permissionManager = permissionManager;
50
+ this.config = config;
51
+ this.adTracker = adTracker;
52
+ }
53
+ /**
54
+ * Connect to a downstream MCP server.
55
+ *
56
+ * Resolves the install command (from registry or explicit params),
57
+ * connects via the appropriate transport, performs MCP handshake,
58
+ * fetches tools/resources/prompts, and registers proxied capabilities.
59
+ */
60
+ /**
61
+ * Names of declared auth env vars the server needs that aren't available in
62
+ * the current environment or the explicitly-provided `env`. Used to warn the
63
+ * user before a server crashes on a missing API key.
64
+ */
65
+ missingAuthEnv(server, providedEnv) {
66
+ const names = (server.authDetails?.envVars || [])
67
+ .map((e) => e.name)
68
+ .filter(Boolean);
69
+ return names.filter((n) => !process.env[n] && !(providedEnv && providedEnv[n]));
70
+ }
71
+ async connect(params) {
72
+ // Resolve slug, command/url, and transport type
73
+ let slug;
74
+ let command;
75
+ let args;
76
+ let url;
77
+ let displayName;
78
+ let trustTier = "unknown";
79
+ let transportType = params.transportType || "stdio";
80
+ // Retained for sandbox manifest resolution (declared auth env vars, etc.)
81
+ let registryServer = null;
82
+ if (params.url) {
83
+ // URL-based connection (SSE, WebSocket, Streamable HTTP)
84
+ url = params.url;
85
+ slug = params.slug || this.slugFromUrl(url);
86
+ displayName = params.slug || slug;
87
+ // Auto-detect transport type from URL scheme if not specified
88
+ if (!params.transportType) {
89
+ if (url.startsWith("ws://") || url.startsWith("wss://")) {
90
+ transportType = "websocket";
91
+ }
92
+ else if (url.includes("/sse")) {
93
+ transportType = "sse";
94
+ }
95
+ else {
96
+ transportType = "streamable-http";
97
+ }
98
+ }
99
+ // Try to get trust info from registry if slug looks like a registry slug
100
+ if (params.slug) {
101
+ try {
102
+ const server = await this.registryClient.getServer(params.slug);
103
+ if (server) {
104
+ registryServer = server;
105
+ displayName = server.name || slug;
106
+ trustTier = (await import("../registry/registry-client.js")).RegistryClient.determineTrustTier(server);
107
+ }
108
+ }
109
+ catch {
110
+ // Not in registry — that's fine for URL-based connections
111
+ }
112
+ }
113
+ }
114
+ else if (params.slug) {
115
+ // Registry-based connection
116
+ slug = params.slug;
117
+ const server = await this.registryClient.getServer(slug);
118
+ if (!server) {
119
+ throw new ConnectionError(`Server "${slug}" not found in MCP-Rating registry.`, slug);
120
+ }
121
+ registryServer = server;
122
+ displayName = server.name || slug;
123
+ // Determine trust
124
+ trustTier = (await import("../registry/registry-client.js")).RegistryClient.determineTrustTier(server);
125
+ // Resolve install command
126
+ const install = this.registryClient.resolveInstallCommand(server);
127
+ if (!install) {
128
+ throw new ConnectionError(`No install command available for "${slug}". Try providing explicit command and args.`, slug);
129
+ }
130
+ command = install.command;
131
+ args = install.args;
132
+ }
133
+ else if (params.command) {
134
+ // Explicit command-based connection (stdio)
135
+ command = params.command;
136
+ args = params.args || [];
137
+ slug = params.command + (args.length > 0 ? `-${args[args.length - 1]}` : "");
138
+ // Sanitize slug: remove @ / . characters, replace with dashes
139
+ slug = slug.replace(/[@/.]/g, "-").replace(/--+/g, "-").replace(/^-|-$/g, "");
140
+ displayName = slug;
141
+ }
142
+ else {
143
+ throw new ConnectionError("Either 'slug', 'command', or 'url' must be provided.", "unknown");
144
+ }
145
+ // Docker MCP servers speak over stdio — ensure the container keeps stdin
146
+ // attached (-i) and is cleaned up (--rm), or the MCP handshake never happens
147
+ // and the server appears to "crash on start".
148
+ if (command === "docker" && args && args[0] === "run") {
149
+ const rest = args.slice(1);
150
+ const hasI = rest.some((a) => a === "-i" || a === "--interactive" || /^-[a-zA-Z]*i[a-zA-Z]*$/.test(a));
151
+ const inject = [];
152
+ if (!hasI)
153
+ inject.push("-i");
154
+ if (!rest.includes("--rm"))
155
+ inject.push("--rm");
156
+ if (inject.length > 0)
157
+ args = ["run", ...inject, ...rest];
158
+ }
159
+ // Check trust policy — require confirmation for community/unknown tier
160
+ const policy = getTrustPolicy(trustTier);
161
+ if (!policy.allowByDefault) {
162
+ throw new PermissionDeniedError(slug, `Connection to "${slug}" is blocked by trust policy (${trustTier}).`);
163
+ }
164
+ // Pre-connect checks: trust-tier confirmation + a missing-auth-env warning,
165
+ // surfaced together so the user can set keys before the server crashes on
166
+ // startup (the #1 real-world connect failure).
167
+ const missingEnv = registryServer
168
+ ? this.missingAuthEnv(registryServer, params.env)
169
+ : [];
170
+ const needTrustConfirm = policy.requiresConfirmation && !params.confirmed;
171
+ const needEnvWarn = missingEnv.length > 0 && !params.confirmed;
172
+ if (needTrustConfirm || needEnvWarn) {
173
+ log.info("Connection requires confirmation", { slug, trustTier, missingEnv });
174
+ const parts = [];
175
+ if (needTrustConfirm) {
176
+ parts.push(`⚠️ **${displayName}** has trust tier [${trustTier}]. ${policy.description}`);
177
+ }
178
+ if (needEnvWarn) {
179
+ parts.push(`🔑 **${displayName}** needs ${missingEnv.length} environment variable(s) that aren't set: ${missingEnv
180
+ .map((v) => "`" + v + "`")
181
+ .join(", ")}.\nSet them in your shell, or pass them via \`env: { ... }\`, then reconnect.`);
182
+ }
183
+ parts.push("To proceed anyway, call `mcp_connect` again with `confirmed: true`.");
184
+ const confirmation = {
185
+ needsConfirmation: true,
186
+ slug,
187
+ displayName,
188
+ trustTier,
189
+ warning: parts.join("\n\n"),
190
+ };
191
+ return confirmation;
192
+ }
193
+ // Check for duplicate connections (but allow reconnecting failed connections)
194
+ const existing = this.connections.get(slug);
195
+ if (existing && existing.state !== "failed") {
196
+ throw new DuplicateConnectionError(slug);
197
+ }
198
+ // Clean up failed connection if re-connecting
199
+ if (existing?.state === "failed") {
200
+ this.connections.delete(slug);
201
+ }
202
+ // Check max connections
203
+ if (this.connections.size >= this.config.maxConnections) {
204
+ throw new MaxConnectionsError(this.config.maxConnections);
205
+ }
206
+ // Create a placeholder connection entry (state: connecting)
207
+ const connection = {
208
+ slug,
209
+ displayName,
210
+ state: "connecting",
211
+ client: null,
212
+ transport: null,
213
+ transportType,
214
+ tools: new Map(),
215
+ resources: new Map(),
216
+ prompts: new Map(),
217
+ registeredProxies: new Map(),
218
+ registeredResourceProxies: new Map(),
219
+ registeredPromptProxies: new Map(),
220
+ connectedAt: new Date(),
221
+ trustTier,
222
+ originalParams: { ...params, slug: params.slug || slug },
223
+ reconnectAttempts: 0,
224
+ failedPings: 0,
225
+ };
226
+ this.connections.set(slug, connection);
227
+ try {
228
+ let finalCommand = command;
229
+ let finalArgs = args;
230
+ let preScopedEnv;
231
+ // Sandbox: resolve the capability manifest for stdio servers up front so
232
+ // we know the enforcement level before deciding how to spawn.
233
+ let manifest;
234
+ if (transportType === "stdio" && this.manifestResolver) {
235
+ manifest = this.manifestResolver.resolve(slug, trustTier, registryServer, params.env ? Object.keys(params.env) : []);
236
+ }
237
+ // Decide whether to run inside a container (L2). Requires an available
238
+ // engine; otherwise we transparently downgrade to L1 process scoping.
239
+ let useContainer = false;
240
+ if (manifest?.enforcement === "l2-container" && command) {
241
+ const engine = await detectContainerEngine();
242
+ if (engine) {
243
+ useContainer = true;
244
+ // For network:allowlist, start an egress proxy and route the
245
+ // container's HTTP(S) traffic through it for host filtering.
246
+ let egressProxyUrl;
247
+ if (manifest.network.mode === "allowlist") {
248
+ const proxy = new EgressProxy(manifest.network.allow, slug, this.auditLog);
249
+ await proxy.start();
250
+ connection.egressProxy = proxy;
251
+ egressProxyUrl = proxy.containerProxyUrl;
252
+ }
253
+ // Prefer a pre-baked image if one exists — it runs offline, so
254
+ // network:none works without an npx/uvx fetch at runtime.
255
+ const bakedImage = (await resolveBakedImage(engine, slug)) ?? undefined;
256
+ const wrapped = buildContainerCommand(engine, manifest, command, args || [], {
257
+ egressProxyUrl,
258
+ bakedImage,
259
+ });
260
+ finalCommand = wrapped.command;
261
+ finalArgs = wrapped.args;
262
+ log.info("Applied L2 container isolation", {
263
+ slug,
264
+ engine,
265
+ network: manifest.network.mode,
266
+ egressFiltered: Boolean(egressProxyUrl),
267
+ });
268
+ }
269
+ else {
270
+ // No engine — downgrade to L1 and record it on the manifest.
271
+ manifest = { ...manifest, enforcement: "l1-process" };
272
+ log.warn("Container isolation requested but no engine available; using L1", { slug });
273
+ }
274
+ }
275
+ // Auto-install on the HOST only when not containerized (the container
276
+ // resolves the package itself via npx/uvx inside the sandbox).
277
+ if (!useContainer &&
278
+ transportType === "stdio" &&
279
+ command &&
280
+ this.config.enableAutoInstall !== false) {
281
+ const installed = await ensureInstalled(command, args || []);
282
+ finalCommand = installed.command;
283
+ finalArgs = installed.args;
284
+ }
285
+ // Sandbox L1: build the scoped environment. Only manifest-allowed env
286
+ // vars (declared auth + user-supplied) plus safe defaults reach the
287
+ // child — the rest of process.env is withheld. For L2, the docker CLI
288
+ // process receives this env and `-e NAME` forwards the allowed vars in.
289
+ if (manifest) {
290
+ preScopedEnv = buildScopedEnvironment(manifest.env, params.env);
291
+ connection.manifest = manifest;
292
+ log.info("Applied sandbox env scoping", {
293
+ slug,
294
+ enforcement: manifest.enforcement,
295
+ allowedEnvCount: Object.keys(preScopedEnv).length,
296
+ });
297
+ }
298
+ // Connect via the appropriate transport
299
+ const result = await createTransport({
300
+ slug,
301
+ transportType,
302
+ command: finalCommand,
303
+ args: finalArgs,
304
+ env: params.env,
305
+ preScopedEnv,
306
+ url,
307
+ onClose: () => this.handleProcessCrash(slug),
308
+ onToolsChanged: () => this.handleToolsChanged(slug),
309
+ connectionTimeoutMs: this.config.proxyTimeoutMs,
310
+ });
311
+ // Update connection with real data
312
+ connection.client = result.client;
313
+ connection.transport = result.transport;
314
+ connection.serverInfo = result.serverInfo;
315
+ connection.state = "ready";
316
+ // Store downstream tools
317
+ for (const tool of result.tools) {
318
+ connection.tools.set(tool.name, tool);
319
+ }
320
+ // Store downstream resources
321
+ for (const resource of result.resources) {
322
+ connection.resources.set(resource.uri, resource);
323
+ }
324
+ // Store downstream prompts
325
+ for (const prompt of result.prompts) {
326
+ connection.prompts.set(prompt.name, prompt);
327
+ }
328
+ // Register proxied tools on the gateway (with optional permission filtering)
329
+ connection.registeredProxies = registerProxiedTools(this.mcpServer, connection, result.tools, this.config.proxyTimeoutMs, this.permissionManager, this.adTracker, this.usageTracker, this.pluginManager, this.auditLog);
330
+ // Register proxied resources on the gateway
331
+ if (result.resources.length > 0) {
332
+ connection.registeredResourceProxies = registerProxiedResources(this.mcpServer, connection, result.resources);
333
+ }
334
+ // Register proxied prompts on the gateway
335
+ if (result.prompts.length > 0) {
336
+ connection.registeredPromptProxies = registerProxiedPrompts(this.mcpServer, connection, result.prompts);
337
+ }
338
+ // Notify host client that tool list changed
339
+ this.mcpServer.sendToolListChanged();
340
+ if (result.resources.length > 0) {
341
+ this.mcpServer.sendResourceListChanged();
342
+ }
343
+ if (result.prompts.length > 0) {
344
+ this.mcpServer.sendPromptListChanged();
345
+ }
346
+ // Save profile if requested
347
+ if (params.saveProfile) {
348
+ addProfile(this.config.profilesPath, {
349
+ slug: params.slug,
350
+ command: params.command,
351
+ args: params.args,
352
+ env: params.env,
353
+ confirmed: true,
354
+ transportType: params.transportType,
355
+ url: params.url,
356
+ });
357
+ log.info("Saved connection profile", { slug });
358
+ }
359
+ // Track connect event for ad attribution (fire-and-forget)
360
+ if (this.adTracker) {
361
+ this.adTracker.trackConnect(slug).catch(() => { });
362
+ }
363
+ // Run plugin onConnect hooks
364
+ if (this.pluginManager) {
365
+ const summary = this.toSummary(connection);
366
+ this.pluginManager.runOnConnect(summary).catch(() => { });
367
+ }
368
+ // Safety audit: record the connect + the capability envelope granted.
369
+ this.auditLog?.record({
370
+ type: "connect",
371
+ slug,
372
+ enforcement: connection.manifest?.enforcement ?? "none",
373
+ reason: connection.manifest
374
+ ? `env:[${connection.manifest.env.allow.join(",")}] net:${connection.manifest.network.mode}`
375
+ : undefined,
376
+ });
377
+ log.info("Server connected successfully", {
378
+ slug,
379
+ displayName,
380
+ trustTier,
381
+ transportType,
382
+ toolCount: result.tools.length,
383
+ resourceCount: result.resources.length,
384
+ promptCount: result.prompts.length,
385
+ });
386
+ return this.toSummary(connection);
387
+ }
388
+ catch (err) {
389
+ // Cleanup on failure
390
+ this.connections.delete(slug);
391
+ const msg = err instanceof Error ? err.message : String(err);
392
+ throw new ConnectionError(`Failed to connect to "${slug}": ${msg}`, slug);
393
+ }
394
+ }
395
+ /**
396
+ * Disconnect from a downstream server.
397
+ */
398
+ async disconnect(slug, options) {
399
+ const connection = this.connections.get(slug);
400
+ if (!connection) {
401
+ throw new ConnectionError(`Server "${slug}" is not connected.`, slug);
402
+ }
403
+ connection.state = "disconnecting";
404
+ log.info("Disconnecting server", { slug });
405
+ // Cancel any pending reconnect
406
+ this.cancelReconnect(slug);
407
+ // Cancel any pending re-sync
408
+ const pendingResync = this.resyncTimers.get(slug);
409
+ if (pendingResync) {
410
+ clearTimeout(pendingResync);
411
+ this.resyncTimers.delete(slug);
412
+ }
413
+ // Remove proxied tools, resources, and prompts
414
+ removeProxiedTools(connection.registeredProxies);
415
+ removeProxiedResources(connection.registeredResourceProxies);
416
+ removeProxiedPrompts(connection.registeredPromptProxies);
417
+ // Close MCP client connection
418
+ try {
419
+ if (connection.transport) {
420
+ await connection.transport.close();
421
+ }
422
+ }
423
+ catch (err) {
424
+ log.warn("Error closing transport", {
425
+ slug,
426
+ error: String(err),
427
+ });
428
+ }
429
+ // Stop the egress allowlist proxy if one was started for this connection.
430
+ if (connection.egressProxy) {
431
+ await connection.egressProxy.stop().catch(() => { });
432
+ connection.egressProxy = undefined;
433
+ }
434
+ this.connections.delete(slug);
435
+ // Remove auto-connect profile if requested
436
+ if (options?.removeProfile !== false) {
437
+ removeProfile(this.config.profilesPath, slug);
438
+ }
439
+ // Notify host client
440
+ this.mcpServer.sendToolListChanged();
441
+ if (connection.resources.size > 0) {
442
+ this.mcpServer.sendResourceListChanged();
443
+ }
444
+ if (connection.prompts.size > 0) {
445
+ this.mcpServer.sendPromptListChanged();
446
+ }
447
+ // Run plugin onDisconnect hooks
448
+ if (this.pluginManager) {
449
+ this.pluginManager.runOnDisconnect(slug).catch(() => { });
450
+ }
451
+ this.auditLog?.record({ type: "disconnect", slug });
452
+ log.info("Server disconnected", { slug });
453
+ }
454
+ /**
455
+ * Disconnect all downstream servers (for graceful shutdown).
456
+ */
457
+ async disconnectAll() {
458
+ this.stopHealthChecks();
459
+ // Cancel all pending reconnects
460
+ for (const [slug, timer] of this.reconnectTimers) {
461
+ clearTimeout(timer);
462
+ this.reconnectTimers.delete(slug);
463
+ }
464
+ const slugs = [...this.connections.keys()];
465
+ log.info("Disconnecting all servers", { count: slugs.length });
466
+ for (const slug of slugs) {
467
+ try {
468
+ await this.disconnect(slug, { removeProfile: false });
469
+ }
470
+ catch (err) {
471
+ log.warn("Error during disconnect", {
472
+ slug,
473
+ error: String(err),
474
+ });
475
+ }
476
+ }
477
+ }
478
+ getConnection(slug) {
479
+ return this.connections.get(slug);
480
+ }
481
+ listConnections() {
482
+ return [...this.connections.values()].map((c) => this.toSummary(c));
483
+ }
484
+ get connectionCount() {
485
+ return this.connections.size;
486
+ }
487
+ // ── Auto-connect from saved profiles ──────────────────────────
488
+ /**
489
+ * Load saved connection profiles and auto-connect each one.
490
+ * Called once during gateway startup.
491
+ */
492
+ async autoConnectFromProfiles() {
493
+ const store = loadProfiles(this.config.profilesPath);
494
+ if (store.connections.length === 0)
495
+ return;
496
+ log.info("Auto-connecting from saved profiles", {
497
+ count: store.connections.length,
498
+ });
499
+ for (const profile of store.connections) {
500
+ try {
501
+ await this.connect({ ...profile, confirmed: true });
502
+ }
503
+ catch (err) {
504
+ log.warn("Auto-connect failed for profile", {
505
+ slug: profile.slug || profile.command,
506
+ error: err instanceof Error ? err.message : String(err),
507
+ });
508
+ }
509
+ }
510
+ }
511
+ // ── Health monitoring ─────────────────────────────────────────
512
+ /**
513
+ * Start periodic health checks on all ready connections.
514
+ */
515
+ startHealthChecks() {
516
+ if (this.healthCheckInterval)
517
+ return;
518
+ this.healthCheckInterval = setInterval(() => {
519
+ this.runHealthChecks().catch((err) => {
520
+ log.error("Health check cycle failed", { error: String(err) });
521
+ });
522
+ }, this.config.healthCheckIntervalMs);
523
+ log.info("Health checks started", {
524
+ intervalMs: this.config.healthCheckIntervalMs,
525
+ });
526
+ }
527
+ /**
528
+ * Stop the health check interval.
529
+ */
530
+ stopHealthChecks() {
531
+ if (this.healthCheckInterval) {
532
+ clearInterval(this.healthCheckInterval);
533
+ this.healthCheckInterval = null;
534
+ log.info("Health checks stopped");
535
+ }
536
+ }
537
+ async runHealthChecks() {
538
+ const readyConnections = [...this.connections.values()].filter((c) => c.state === "ready");
539
+ for (const connection of readyConnections) {
540
+ try {
541
+ await connection.client.ping({ signal: AbortSignal.timeout(5_000) });
542
+ connection.failedPings = 0;
543
+ connection.lastPingAt = new Date();
544
+ log.debug("Health check passed", { slug: connection.slug });
545
+ }
546
+ catch (err) {
547
+ connection.failedPings++;
548
+ log.warn("Health check failed", {
549
+ slug: connection.slug,
550
+ failedPings: connection.failedPings,
551
+ error: String(err),
552
+ });
553
+ if (connection.failedPings >= 3) {
554
+ log.warn("Too many failed pings, treating as crash", {
555
+ slug: connection.slug,
556
+ });
557
+ this.handleProcessCrash(connection.slug);
558
+ }
559
+ }
560
+ }
561
+ }
562
+ // ── Crash handling + auto-reconnect ────────────────────────────
563
+ /**
564
+ * Handle a downstream process crash.
565
+ * Removes proxied tools/resources/prompts and attempts auto-reconnect.
566
+ */
567
+ handleProcessCrash(slug) {
568
+ const connection = this.connections.get(slug);
569
+ if (!connection || connection.state === "disconnecting")
570
+ return;
571
+ log.warn("Downstream server process crashed", { slug });
572
+ // Cancel any pending re-sync
573
+ const pendingResync = this.resyncTimers.get(slug);
574
+ if (pendingResync) {
575
+ clearTimeout(pendingResync);
576
+ this.resyncTimers.delete(slug);
577
+ }
578
+ // Remove proxied tools, resources, and prompts
579
+ removeProxiedTools(connection.registeredProxies);
580
+ removeProxiedResources(connection.registeredResourceProxies);
581
+ removeProxiedPrompts(connection.registeredPromptProxies);
582
+ connection.registeredProxies = new Map();
583
+ connection.registeredResourceProxies = new Map();
584
+ connection.registeredPromptProxies = new Map();
585
+ connection.tools.clear();
586
+ connection.resources.clear();
587
+ connection.prompts.clear();
588
+ // Notify host client that tools were removed
589
+ this.mcpServer.sendToolListChanged();
590
+ // Attempt reconnect if we have the original params
591
+ if (connection.originalParams) {
592
+ connection.state = "reconnecting";
593
+ connection.lastError = "Process exited unexpectedly";
594
+ this.scheduleReconnect(slug);
595
+ }
596
+ else {
597
+ connection.state = "failed";
598
+ connection.lastError = "Process exited unexpectedly (no reconnect params)";
599
+ log.error("Cannot auto-reconnect — no original params stored", { slug });
600
+ }
601
+ }
602
+ scheduleReconnect(slug) {
603
+ const connection = this.connections.get(slug);
604
+ if (!connection || connection.state !== "reconnecting")
605
+ return;
606
+ const maxAttempts = this.config.reconnectMaxAttempts;
607
+ if (connection.reconnectAttempts >= maxAttempts) {
608
+ connection.state = "failed";
609
+ connection.lastError = `All ${maxAttempts} reconnect attempts exhausted`;
610
+ log.error("Reconnect attempts exhausted", { slug, maxAttempts });
611
+ return;
612
+ }
613
+ // Exponential backoff: base * 2^attempt, capped at 30s
614
+ const delay = Math.min(this.config.reconnectBaseDelayMs * Math.pow(2, connection.reconnectAttempts), 30_000);
615
+ connection.reconnectAttempts++;
616
+ log.info("Scheduling reconnect", {
617
+ slug,
618
+ attempt: connection.reconnectAttempts,
619
+ maxAttempts,
620
+ delayMs: delay,
621
+ });
622
+ const timer = setTimeout(() => {
623
+ this.reconnectTimers.delete(slug);
624
+ this.attemptReconnect(slug).catch((err) => {
625
+ log.error("Reconnect attempt failed", {
626
+ slug,
627
+ error: String(err),
628
+ });
629
+ });
630
+ }, delay);
631
+ this.reconnectTimers.set(slug, timer);
632
+ }
633
+ async attemptReconnect(slug) {
634
+ const connection = this.connections.get(slug);
635
+ if (!connection || connection.state !== "reconnecting")
636
+ return;
637
+ if (!connection.originalParams)
638
+ return;
639
+ const params = connection.originalParams;
640
+ const transportType = params.transportType || connection.transportType || "stdio";
641
+ log.info("Attempting reconnect", {
642
+ slug,
643
+ attempt: connection.reconnectAttempts,
644
+ transportType,
645
+ });
646
+ try {
647
+ const result = await createTransport({
648
+ slug,
649
+ transportType,
650
+ command: params.command || "npx",
651
+ args: params.args || [],
652
+ env: params.env,
653
+ url: params.url,
654
+ onClose: () => this.handleProcessCrash(slug),
655
+ onToolsChanged: () => this.handleToolsChanged(slug),
656
+ connectionTimeoutMs: this.config.proxyTimeoutMs,
657
+ });
658
+ // Reconnect succeeded
659
+ connection.client = result.client;
660
+ connection.transport = result.transport;
661
+ connection.serverInfo = result.serverInfo;
662
+ connection.state = "ready";
663
+ connection.reconnectAttempts = 0;
664
+ connection.failedPings = 0;
665
+ connection.lastError = undefined;
666
+ connection.connectedAt = new Date();
667
+ // Re-register tools
668
+ for (const tool of result.tools) {
669
+ connection.tools.set(tool.name, tool);
670
+ }
671
+ connection.registeredProxies = registerProxiedTools(this.mcpServer, connection, result.tools, this.config.proxyTimeoutMs, this.permissionManager, this.adTracker, this.usageTracker, this.pluginManager, this.auditLog);
672
+ // Re-register resources
673
+ for (const resource of result.resources) {
674
+ connection.resources.set(resource.uri, resource);
675
+ }
676
+ if (result.resources.length > 0) {
677
+ connection.registeredResourceProxies = registerProxiedResources(this.mcpServer, connection, result.resources);
678
+ }
679
+ // Re-register prompts
680
+ for (const prompt of result.prompts) {
681
+ connection.prompts.set(prompt.name, prompt);
682
+ }
683
+ if (result.prompts.length > 0) {
684
+ connection.registeredPromptProxies = registerProxiedPrompts(this.mcpServer, connection, result.prompts);
685
+ }
686
+ this.mcpServer.sendToolListChanged();
687
+ log.info("Reconnect succeeded", {
688
+ slug,
689
+ toolCount: result.tools.length,
690
+ resourceCount: result.resources.length,
691
+ promptCount: result.prompts.length,
692
+ });
693
+ }
694
+ catch (err) {
695
+ log.warn("Reconnect attempt failed", {
696
+ slug,
697
+ attempt: connection.reconnectAttempts,
698
+ error: err instanceof Error ? err.message : String(err),
699
+ });
700
+ // Schedule next attempt
701
+ this.scheduleReconnect(slug);
702
+ }
703
+ }
704
+ cancelReconnect(slug) {
705
+ const timer = this.reconnectTimers.get(slug);
706
+ if (timer) {
707
+ clearTimeout(timer);
708
+ this.reconnectTimers.delete(slug);
709
+ }
710
+ }
711
+ // ── Tool list changes ─────────────────────────────────────────
712
+ /**
713
+ * Handle a downstream server's tool list changing.
714
+ * Debounced to 500ms to prevent notification spam from chatty servers.
715
+ */
716
+ handleToolsChanged(slug) {
717
+ // Clear any pending re-sync for this slug
718
+ const existing = this.resyncTimers.get(slug);
719
+ if (existing)
720
+ clearTimeout(existing);
721
+ // Schedule a debounced re-sync
722
+ const timer = setTimeout(() => {
723
+ this.resyncTimers.delete(slug);
724
+ this.doCapabilityResync(slug).catch((err) => {
725
+ log.error("Failed to re-sync capabilities", {
726
+ slug,
727
+ error: String(err),
728
+ });
729
+ });
730
+ }, 500);
731
+ this.resyncTimers.set(slug, timer);
732
+ }
733
+ /**
734
+ * Re-sync all proxied capabilities (tools, resources, prompts) for a downstream connection.
735
+ */
736
+ async doCapabilityResync(slug) {
737
+ const connection = this.connections.get(slug);
738
+ if (!connection || connection.state !== "ready")
739
+ return;
740
+ log.info("Re-syncing capabilities for server", { slug });
741
+ // Fetch updated tool list
742
+ const response = await connection.client.listTools();
743
+ const newTools = response.tools.map((t) => ({
744
+ name: t.name,
745
+ description: t.description,
746
+ inputSchema: t.inputSchema,
747
+ annotations: t.annotations,
748
+ execution: t
749
+ .execution,
750
+ }));
751
+ // Remove old proxied tools
752
+ removeProxiedTools(connection.registeredProxies);
753
+ // Update stored tools
754
+ connection.tools.clear();
755
+ for (const tool of newTools) {
756
+ connection.tools.set(tool.name, tool);
757
+ }
758
+ // Register new proxied tools (with optional permission filtering)
759
+ connection.registeredProxies = registerProxiedTools(this.mcpServer, connection, newTools, this.config.proxyTimeoutMs, this.permissionManager, this.adTracker, this.usageTracker, this.pluginManager, this.auditLog);
760
+ // Re-sync resources
761
+ try {
762
+ const resourcesResponse = await connection.client.listResources();
763
+ const newResources = resourcesResponse.resources.map((r) => ({
764
+ uri: r.uri,
765
+ name: r.name,
766
+ description: r.description,
767
+ mimeType: r.mimeType,
768
+ }));
769
+ removeProxiedResources(connection.registeredResourceProxies);
770
+ connection.resources.clear();
771
+ for (const resource of newResources) {
772
+ connection.resources.set(resource.uri, resource);
773
+ }
774
+ if (newResources.length > 0) {
775
+ connection.registeredResourceProxies = registerProxiedResources(this.mcpServer, connection, newResources);
776
+ this.mcpServer.sendResourceListChanged();
777
+ }
778
+ }
779
+ catch {
780
+ // Server doesn't support resources — that's fine
781
+ }
782
+ // Re-sync prompts
783
+ try {
784
+ const promptsResponse = await connection.client.listPrompts();
785
+ const newPrompts = promptsResponse.prompts.map((p) => ({
786
+ name: p.name,
787
+ description: p.description,
788
+ arguments: p.arguments?.map((a) => ({
789
+ name: a.name,
790
+ description: a.description,
791
+ required: a.required,
792
+ })),
793
+ }));
794
+ removeProxiedPrompts(connection.registeredPromptProxies);
795
+ connection.prompts.clear();
796
+ for (const prompt of newPrompts) {
797
+ connection.prompts.set(prompt.name, prompt);
798
+ }
799
+ if (newPrompts.length > 0) {
800
+ connection.registeredPromptProxies = registerProxiedPrompts(this.mcpServer, connection, newPrompts);
801
+ this.mcpServer.sendPromptListChanged();
802
+ }
803
+ }
804
+ catch {
805
+ // Server doesn't support prompts — that's fine
806
+ }
807
+ // Notify host client
808
+ this.mcpServer.sendToolListChanged();
809
+ log.info("Capability re-sync complete", {
810
+ slug,
811
+ toolCount: newTools.length,
812
+ resourceCount: connection.resources.size,
813
+ promptCount: connection.prompts.size,
814
+ });
815
+ }
816
+ // ── Helpers ────────────────────────────────────────────────────
817
+ toSummary(connection) {
818
+ return {
819
+ slug: connection.slug,
820
+ displayName: connection.displayName,
821
+ state: connection.state,
822
+ trustTier: connection.trustTier,
823
+ transportType: connection.transportType,
824
+ toolCount: connection.tools.size,
825
+ resourceCount: connection.resources.size,
826
+ promptCount: connection.prompts.size,
827
+ connectedAt: connection.connectedAt,
828
+ serverInfo: connection.serverInfo,
829
+ reconnectAttempts: connection.reconnectAttempts || undefined,
830
+ failedPings: connection.failedPings || undefined,
831
+ lastPingAt: connection.lastPingAt,
832
+ };
833
+ }
834
+ /** Derive a slug from a URL */
835
+ slugFromUrl(url) {
836
+ try {
837
+ const parsed = new URL(url);
838
+ return parsed.hostname.replace(/\./g, "-") + (parsed.port ? `-${parsed.port}` : "");
839
+ }
840
+ catch {
841
+ return url.replace(/[^a-zA-Z0-9]/g, "-").replace(/--+/g, "-").replace(/^-|-$/g, "");
842
+ }
843
+ }
844
+ }
845
+ //# sourceMappingURL=connection-manager.js.map