@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.
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/dist/ads/ad-tracker.d.ts +63 -0
- package/dist/ads/ad-tracker.js +144 -0
- package/dist/ads/ad-tracker.js.map +1 -0
- package/dist/analytics/usage-tracker.d.ts +67 -0
- package/dist/analytics/usage-tracker.js +148 -0
- package/dist/analytics/usage-tracker.js.map +1 -0
- package/dist/audit/audit-log.d.ts +71 -0
- package/dist/audit/audit-log.js +72 -0
- package/dist/audit/audit-log.js.map +1 -0
- package/dist/config/gateway-config.d.ts +6 -0
- package/dist/config/gateway-config.js +135 -0
- package/dist/config/gateway-config.js.map +1 -0
- package/dist/config/groups.d.ts +29 -0
- package/dist/config/groups.js +102 -0
- package/dist/config/groups.js.map +1 -0
- package/dist/config/profiles.d.ts +42 -0
- package/dist/config/profiles.js +167 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/config/types.d.ts +76 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/connection/auto-installer.d.ts +13 -0
- package/dist/connection/auto-installer.js +61 -0
- package/dist/connection/auto-installer.js.map +1 -0
- package/dist/connection/connection-manager.d.ts +106 -0
- package/dist/connection/connection-manager.js +845 -0
- package/dist/connection/connection-manager.js.map +1 -0
- package/dist/connection/downstream-client.d.ts +50 -0
- package/dist/connection/downstream-client.js +170 -0
- package/dist/connection/downstream-client.js.map +1 -0
- package/dist/connection/http-client.d.ts +12 -0
- package/dist/connection/http-client.js +26 -0
- package/dist/connection/http-client.js.map +1 -0
- package/dist/connection/sse-client.d.ts +12 -0
- package/dist/connection/sse-client.js +26 -0
- package/dist/connection/sse-client.js.map +1 -0
- package/dist/connection/transport-factory.d.ts +31 -0
- package/dist/connection/transport-factory.js +67 -0
- package/dist/connection/transport-factory.js.map +1 -0
- package/dist/connection/types.d.ts +134 -0
- package/dist/connection/types.js +5 -0
- package/dist/connection/types.js.map +1 -0
- package/dist/connection/ws-client.d.ts +12 -0
- package/dist/connection/ws-client.js +26 -0
- package/dist/connection/ws-client.js.map +1 -0
- package/dist/discovery/auto-recommender.d.ts +51 -0
- package/dist/discovery/auto-recommender.js +176 -0
- package/dist/discovery/auto-recommender.js.map +1 -0
- package/dist/gateway-server.d.ts +42 -0
- package/dist/gateway-server.js +135 -0
- package/dist/gateway-server.js.map +1 -0
- package/dist/http-daemon.d.ts +26 -0
- package/dist/http-daemon.js +302 -0
- package/dist/http-daemon.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/dist/meta-tools/ad-status.d.ts +8 -0
- package/dist/meta-tools/ad-status.js +41 -0
- package/dist/meta-tools/ad-status.js.map +1 -0
- package/dist/meta-tools/audit.d.ts +12 -0
- package/dist/meta-tools/audit.js +98 -0
- package/dist/meta-tools/audit.js.map +1 -0
- package/dist/meta-tools/call-tool.d.ts +11 -0
- package/dist/meta-tools/call-tool.js +65 -0
- package/dist/meta-tools/call-tool.js.map +1 -0
- package/dist/meta-tools/connect.d.ts +3 -0
- package/dist/meta-tools/connect.js +105 -0
- package/dist/meta-tools/connect.js.map +1 -0
- package/dist/meta-tools/disconnect.d.ts +3 -0
- package/dist/meta-tools/disconnect.js +30 -0
- package/dist/meta-tools/disconnect.js.map +1 -0
- package/dist/meta-tools/discover.d.ts +3 -0
- package/dist/meta-tools/discover.js +97 -0
- package/dist/meta-tools/discover.js.map +1 -0
- package/dist/meta-tools/groups.d.ts +7 -0
- package/dist/meta-tools/groups.js +237 -0
- package/dist/meta-tools/groups.js.map +1 -0
- package/dist/meta-tools/health.d.ts +4 -0
- package/dist/meta-tools/health.js +76 -0
- package/dist/meta-tools/health.js.map +1 -0
- package/dist/meta-tools/index.d.ts +28 -0
- package/dist/meta-tools/index.js +56 -0
- package/dist/meta-tools/index.js.map +1 -0
- package/dist/meta-tools/list-active.d.ts +3 -0
- package/dist/meta-tools/list-active.js +50 -0
- package/dist/meta-tools/list-active.js.map +1 -0
- package/dist/meta-tools/profiles.d.ts +6 -0
- package/dist/meta-tools/profiles.js +197 -0
- package/dist/meta-tools/profiles.js.map +1 -0
- package/dist/meta-tools/recommend.d.ts +14 -0
- package/dist/meta-tools/recommend.js +74 -0
- package/dist/meta-tools/recommend.js.map +1 -0
- package/dist/meta-tools/sandbox.d.ts +12 -0
- package/dist/meta-tools/sandbox.js +125 -0
- package/dist/meta-tools/sandbox.js.map +1 -0
- package/dist/meta-tools/server-info.d.ts +4 -0
- package/dist/meta-tools/server-info.js +156 -0
- package/dist/meta-tools/server-info.js.map +1 -0
- package/dist/meta-tools/usage.d.ts +6 -0
- package/dist/meta-tools/usage.js +149 -0
- package/dist/meta-tools/usage.js.map +1 -0
- package/dist/permissions/permission-manager.d.ts +26 -0
- package/dist/permissions/permission-manager.js +73 -0
- package/dist/permissions/permission-manager.js.map +1 -0
- package/dist/permissions/trust-tiers.d.ts +18 -0
- package/dist/permissions/trust-tiers.js +42 -0
- package/dist/permissions/trust-tiers.js.map +1 -0
- package/dist/plugins/builtin/logging-plugin.d.ts +6 -0
- package/dist/plugins/builtin/logging-plugin.js +41 -0
- package/dist/plugins/builtin/logging-plugin.js.map +1 -0
- package/dist/plugins/plugin-manager.d.ts +65 -0
- package/dist/plugins/plugin-manager.js +137 -0
- package/dist/plugins/plugin-manager.js.map +1 -0
- package/dist/proxy/prime-handlers.d.ts +27 -0
- package/dist/proxy/prime-handlers.js +47 -0
- package/dist/proxy/prime-handlers.js.map +1 -0
- package/dist/proxy/progress-forwarder.d.ts +44 -0
- package/dist/proxy/progress-forwarder.js +40 -0
- package/dist/proxy/progress-forwarder.js.map +1 -0
- package/dist/proxy/prompt-router.d.ts +25 -0
- package/dist/proxy/prompt-router.js +110 -0
- package/dist/proxy/prompt-router.js.map +1 -0
- package/dist/proxy/resource-router.d.ts +25 -0
- package/dist/proxy/resource-router.js +91 -0
- package/dist/proxy/resource-router.js.map +1 -0
- package/dist/proxy/schema-adapter.d.ts +11 -0
- package/dist/proxy/schema-adapter.js +36 -0
- package/dist/proxy/schema-adapter.js.map +1 -0
- package/dist/proxy/task-proxy.d.ts +28 -0
- package/dist/proxy/task-proxy.js +186 -0
- package/dist/proxy/task-proxy.js.map +1 -0
- package/dist/proxy/tool-router.d.ts +38 -0
- package/dist/proxy/tool-router.js +229 -0
- package/dist/proxy/tool-router.js.map +1 -0
- package/dist/proxy/ui-detect.d.ts +22 -0
- package/dist/proxy/ui-detect.js +30 -0
- package/dist/proxy/ui-detect.js.map +1 -0
- package/dist/registry/registry-client.d.ts +55 -0
- package/dist/registry/registry-client.js +211 -0
- package/dist/registry/registry-client.js.map +1 -0
- package/dist/registry/types.d.ts +69 -0
- package/dist/registry/types.js +2 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/sandbox/container-runtime.d.ts +91 -0
- package/dist/sandbox/container-runtime.js +178 -0
- package/dist/sandbox/container-runtime.js.map +1 -0
- package/dist/sandbox/egress-proxy.d.ts +51 -0
- package/dist/sandbox/egress-proxy.js +146 -0
- package/dist/sandbox/egress-proxy.js.map +1 -0
- package/dist/sandbox/env-scoper.d.ts +29 -0
- package/dist/sandbox/env-scoper.js +60 -0
- package/dist/sandbox/env-scoper.js.map +1 -0
- package/dist/sandbox/manifest-resolver.d.ts +40 -0
- package/dist/sandbox/manifest-resolver.js +185 -0
- package/dist/sandbox/manifest-resolver.js.map +1 -0
- package/dist/sandbox/types.d.ts +88 -0
- package/dist/sandbox/types.js +21 -0
- package/dist/sandbox/types.js.map +1 -0
- package/dist/utils/errors.d.ts +39 -0
- package/dist/utils/errors.js +67 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/logger.d.ts +16 -0
- package/dist/utils/logger.js +51 -0
- package/dist/utils/logger.js.map +1 -0
- 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
|