@samanhappy/mcphub 1.0.23 → 1.0.25
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/dist/controllers/serverController.js +48 -0
- package/dist/controllers/serverController.js.map +1 -1
- package/dist/dao/ServerDaoDbImpl.js +3 -0
- package/dist/dao/ServerDaoDbImpl.js.map +1 -1
- package/dist/db/connection.js +96 -60
- package/dist/db/connection.js.map +1 -1
- package/dist/db/entities/Server.js +4 -0
- package/dist/db/entities/Server.js.map +1 -1
- package/dist/db/repositories/SystemConfigRepository.js +37 -15
- package/dist/db/repositories/SystemConfigRepository.js.map +1 -1
- package/dist/routes/index.js +2 -1
- package/dist/routes/index.js.map +1 -1
- package/dist/services/mcpService.js +324 -63
- package/dist/services/mcpService.js.map +1 -1
- package/dist/services/oauthClientRegistration.js +1 -0
- package/dist/services/oauthClientRegistration.js.map +1 -1
- package/dist/services/oauthSettingsStore.js +3 -0
- package/dist/services/oauthSettingsStore.js.map +1 -1
- package/dist/services/upstreamOAuthDisconnectService.js +160 -0
- package/dist/services/upstreamOAuthDisconnectService.js.map +1 -0
- package/dist/utils/dbRetry.js +1 -0
- package/dist/utils/dbRetry.js.map +1 -1
- package/dist/utils/migration.js +1 -0
- package/dist/utils/migration.js.map +1 -1
- package/dist/utils/serialization.js +2 -1
- package/dist/utils/serialization.js.map +1 -1
- package/dist/utils/serverConfigPersistence.js +1 -0
- package/dist/utils/serverConfigPersistence.js.map +1 -1
- package/frontend/dist/assets/{ActivityPage-BdZq-UUz.js → ActivityPage-BuY3HRab.js} +2 -2
- package/frontend/dist/assets/{ActivityPage-BdZq-UUz.js.map → ActivityPage-BuY3HRab.js.map} +1 -1
- package/frontend/dist/assets/{Dashboard-B9NgJISN.js → Dashboard-bO5Xtzsq.js} +2 -2
- package/frontend/dist/assets/{Dashboard-B9NgJISN.js.map → Dashboard-bO5Xtzsq.js.map} +1 -1
- package/frontend/dist/assets/{EndpointCopy-CxQGXtue.js → EndpointCopy-5nz6zs3k.js} +2 -2
- package/frontend/dist/assets/{EndpointCopy-CxQGXtue.js.map → EndpointCopy-5nz6zs3k.js.map} +1 -1
- package/frontend/dist/assets/{GroupsPage-DvkKDHf7.js → GroupsPage-cW-uxB8G.js} +2 -2
- package/frontend/dist/assets/{GroupsPage-DvkKDHf7.js.map → GroupsPage-cW-uxB8G.js.map} +1 -1
- package/frontend/dist/assets/{LoginPage-BJKZSGKK.js → LoginPage-E5RJsYsm.js} +2 -2
- package/frontend/dist/assets/{LoginPage-BJKZSGKK.js.map → LoginPage-E5RJsYsm.js.map} +1 -1
- package/frontend/dist/assets/{LogsPage-Dd0rwLjA.js → LogsPage-B7NIiglx.js} +2 -2
- package/frontend/dist/assets/{LogsPage-Dd0rwLjA.js.map → LogsPage-B7NIiglx.js.map} +1 -1
- package/frontend/dist/assets/{MarketPage-upzNkwbt.js → MarketPage-B_DUnxPX.js} +2 -2
- package/frontend/dist/assets/{MarketPage-upzNkwbt.js.map → MarketPage-B_DUnxPX.js.map} +1 -1
- package/frontend/dist/assets/{PromptsPage-BuLeSc0F.js → PromptsPage-DRsO8XiQ.js} +2 -2
- package/frontend/dist/assets/{PromptsPage-BuLeSc0F.js.map → PromptsPage-DRsO8XiQ.js.map} +1 -1
- package/frontend/dist/assets/{ResourcesPage-DrZ_QvOI.js → ResourcesPage-bVfPTdff.js} +2 -2
- package/frontend/dist/assets/{ResourcesPage-DrZ_QvOI.js.map → ResourcesPage-bVfPTdff.js.map} +1 -1
- package/frontend/dist/assets/ServersPage-V8rY98M6.js +37 -0
- package/frontend/dist/assets/ServersPage-V8rY98M6.js.map +1 -0
- package/frontend/dist/assets/{SettingsPage-lYf7PdC3.js → SettingsPage-Cq_1HeWE.js} +2 -2
- package/frontend/dist/assets/{SettingsPage-lYf7PdC3.js.map → SettingsPage-Cq_1HeWE.js.map} +1 -1
- package/frontend/dist/assets/{StatusDot-DoWgjhoz.js → StatusDot-BoDsWVzY.js} +2 -2
- package/frontend/dist/assets/{StatusDot-DoWgjhoz.js.map → StatusDot-BoDsWVzY.js.map} +1 -1
- package/frontend/dist/assets/{ToggleGroup-D5ZAjFw-.js → ToggleGroup-D8PkTqMI.js} +2 -2
- package/frontend/dist/assets/{ToggleGroup-D5ZAjFw-.js.map → ToggleGroup-D8PkTqMI.js.map} +1 -1
- package/frontend/dist/assets/{UsersPage-4N_KUrap.js → UsersPage-135nXZTG.js} +2 -2
- package/frontend/dist/assets/{UsersPage-4N_KUrap.js.map → UsersPage-135nXZTG.js.map} +1 -1
- package/frontend/dist/assets/{contextCost-DKF-jtb4.js → contextCost-4glX9T0N.js} +2 -2
- package/frontend/dist/assets/{contextCost-DKF-jtb4.js.map → contextCost-4glX9T0N.js.map} +1 -1
- package/frontend/dist/assets/index-CXPQETnb.js +3 -0
- package/frontend/dist/assets/index-CXPQETnb.js.map +1 -0
- package/frontend/dist/assets/{resourceService-Cu9CYEjx.js → resourceService-DsVPipmK.js} +2 -2
- package/frontend/dist/assets/{resourceService-Cu9CYEjx.js.map → resourceService-DsVPipmK.js.map} +1 -1
- package/frontend/dist/assets/useSettingsData-BHaYBWNy.js +2 -0
- package/frontend/dist/assets/{useSettingsData-CiFNo8wN.js.map → useSettingsData-BHaYBWNy.js.map} +1 -1
- package/frontend/dist/assets/{variableDetection-DChuQNs-.js → variableDetection-D6KIhNDI.js} +3 -3
- package/frontend/dist/assets/variableDetection-D6KIhNDI.js.map +1 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +4 -4
- package/frontend/dist/assets/ServersPage-hag0KLrj.js +0 -37
- package/frontend/dist/assets/ServersPage-hag0KLrj.js.map +0 -1
- package/frontend/dist/assets/index-CSXs6vVg.js +0 -3
- package/frontend/dist/assets/index-CSXs6vVg.js.map +0 -1
- package/frontend/dist/assets/useSettingsData-CiFNo8wN.js +0 -2
- package/frontend/dist/assets/variableDetection-DChuQNs-.js.map +0 -1
|
@@ -114,6 +114,44 @@ ${proxyLine}
|
|
|
114
114
|
console.log(`[${serverName}] Generated proxychains4 config: ${configPath}`);
|
|
115
115
|
return configPath;
|
|
116
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* Validate that a command is safe to execute.
|
|
119
|
+
* Blocks shell builtins and common injection patterns.
|
|
120
|
+
*/
|
|
121
|
+
const isSafeCommand = (command) => {
|
|
122
|
+
// Block shell builtins that could be used for command injection
|
|
123
|
+
const blockedCommands = new Set([
|
|
124
|
+
'sh', 'bash', 'zsh', 'fish', 'csh', 'ksh', 'tcsh',
|
|
125
|
+
'cmd', 'powershell', 'pwsh',
|
|
126
|
+
'eval', 'exec', 'source', '.',
|
|
127
|
+
]);
|
|
128
|
+
const basename = command.split('/').pop()?.split('\\').pop()?.toLowerCase() || '';
|
|
129
|
+
if (blockedCommands.has(basename)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
// Block commands with shell metacharacters
|
|
133
|
+
if (/[;&|`$(){}[\]!]/.test(command)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return true;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Sanitize arguments to prevent command injection.
|
|
140
|
+
* Removes shell metacharacters and dangerous patterns.
|
|
141
|
+
*/
|
|
142
|
+
const sanitizeArgs = (args) => {
|
|
143
|
+
return args.map(arg => {
|
|
144
|
+
// Remove shell metacharacters that could be used for injection
|
|
145
|
+
// Allow common flags (-f, --flag, -vvv) and paths
|
|
146
|
+
if (/^[a-zA-Z0-9._/\\:@=+,-]+$/.test(arg)) {
|
|
147
|
+
return arg;
|
|
148
|
+
}
|
|
149
|
+
// For arguments with special characters, log a warning
|
|
150
|
+
// Also block redirection operators (>, <, >>) and newlines
|
|
151
|
+
console.warn(`[proxychains] Potentially unsafe argument blocked: ${arg}`);
|
|
152
|
+
return arg.replace(/[;&|`$(){}[\]!><\n\r]/g, '');
|
|
153
|
+
});
|
|
154
|
+
};
|
|
117
155
|
/**
|
|
118
156
|
* Wrap a command with proxychains4 if proxy is configured and available.
|
|
119
157
|
* Returns modified command and args if proxychains4 is used, original values otherwise.
|
|
@@ -128,6 +166,11 @@ const wrapWithProxychains = (serverName, command, args, proxyConfig) => {
|
|
|
128
166
|
console.warn(`[${serverName}] proxychains4 proxy is not supported on Windows, ignoring proxy configuration`);
|
|
129
167
|
return { command, args };
|
|
130
168
|
}
|
|
169
|
+
// SECURITY: Validate command is safe
|
|
170
|
+
if (!isSafeCommand(command)) {
|
|
171
|
+
console.error(`[${serverName}] Blocked unsafe command for proxychains4 wrapping: ${command}`);
|
|
172
|
+
throw new Error(`[${serverName}] Unsafe command blocked: ${command}. Shell builtins and metacharacters are not allowed.`);
|
|
173
|
+
}
|
|
131
174
|
// Find proxychains4 binary
|
|
132
175
|
const proxychains4Path = findProxychains4();
|
|
133
176
|
if (!proxychains4Path) {
|
|
@@ -140,11 +183,13 @@ const wrapWithProxychains = (serverName, command, args, proxyConfig) => {
|
|
|
140
183
|
console.warn(`[${serverName}] Failed to setup proxychains4 configuration, skipping proxy`);
|
|
141
184
|
return { command, args };
|
|
142
185
|
}
|
|
186
|
+
// SECURITY: Sanitize arguments
|
|
187
|
+
const sanitizedArgs = sanitizeArgs(args);
|
|
143
188
|
// Wrap command with proxychains4
|
|
144
189
|
console.log(`[${serverName}] Using proxychains4 proxy: ${proxyConfig.type || 'socks5'}://${proxyConfig.host}:${proxyConfig.port}`);
|
|
145
190
|
return {
|
|
146
191
|
command: proxychains4Path,
|
|
147
|
-
args: ['-f', configPath, command, ...
|
|
192
|
+
args: ['-f', configPath, command, ...sanitizedArgs],
|
|
148
193
|
};
|
|
149
194
|
};
|
|
150
195
|
export const initUpstreamServers = async () => {
|
|
@@ -195,6 +240,131 @@ export const getMcpServer = async (sessionId, group) => {
|
|
|
195
240
|
};
|
|
196
241
|
export const deleteMcpServer = (sessionId) => {
|
|
197
242
|
delete servers[sessionId];
|
|
243
|
+
// Clean up any per-session isolated upstream clients for this session
|
|
244
|
+
cleanupIsolatedSession(sessionId);
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Close an isolated upstream client and its transport, killing the whole stdio
|
|
248
|
+
* process tree (to avoid orphaned wrappers like `npx`) when applicable.
|
|
249
|
+
*/
|
|
250
|
+
const closeIsolatedClient = (serverName, client, transport) => {
|
|
251
|
+
try {
|
|
252
|
+
client.close();
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
console.warn(`[${serverName}] Error closing isolated client:`, e);
|
|
256
|
+
}
|
|
257
|
+
const candidateTransport = transport;
|
|
258
|
+
const stdioPid = typeof candidateTransport.pid === 'number' ? candidateTransport.pid : null;
|
|
259
|
+
try {
|
|
260
|
+
transport.close();
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
console.warn(`[${serverName}] Error closing isolated transport:`, e);
|
|
264
|
+
}
|
|
265
|
+
// For stdio transports, kill the whole process tree to avoid orphans
|
|
266
|
+
if (stdioPid) {
|
|
267
|
+
killStdioProcessTree(serverName, stdioPid);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* Clean up per-session isolated upstream clients for a given session.
|
|
272
|
+
* Closes all clients and transports, then removes the session entry.
|
|
273
|
+
*/
|
|
274
|
+
const cleanupIsolatedSession = (sessionId) => {
|
|
275
|
+
const sessionClients = sessionIsolatedClients.get(sessionId);
|
|
276
|
+
if (sessionClients) {
|
|
277
|
+
for (const [serverName, { client, transport }] of sessionClients) {
|
|
278
|
+
closeIsolatedClient(serverName, client, transport);
|
|
279
|
+
}
|
|
280
|
+
sessionIsolatedClients.delete(sessionId);
|
|
281
|
+
// Clean up any leftover creation locks for this session
|
|
282
|
+
for (const key of isolatedClientCreationLocks.keys()) {
|
|
283
|
+
if (key.startsWith(`${sessionId}:`)) {
|
|
284
|
+
isolatedClientCreationLocks.delete(key);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
console.log(`Cleaned up isolated clients for session: ${sessionId}`);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
/** Helper to write an isolated session upstream client into the tracking map. */
|
|
291
|
+
const setSessionIsolatedClient = (sessionId, serverName, client, transport) => {
|
|
292
|
+
let sessionClients = sessionIsolatedClients.get(sessionId);
|
|
293
|
+
if (!sessionClients) {
|
|
294
|
+
sessionClients = new Map();
|
|
295
|
+
sessionIsolatedClients.set(sessionId, sessionClients);
|
|
296
|
+
}
|
|
297
|
+
sessionClients.set(serverName, { client, transport });
|
|
298
|
+
};
|
|
299
|
+
/**
|
|
300
|
+
* Get or create a per-session isolated upstream client for a server.
|
|
301
|
+
* Used when the server has `perSessionClient: true` in its config.
|
|
302
|
+
* Uses a creation lock to prevent duplicate connections from concurrent calls.
|
|
303
|
+
*/
|
|
304
|
+
const getOrCreateIsolatedClient = async (sessionId, serverInfo) => {
|
|
305
|
+
// Quick check without lock — already exists
|
|
306
|
+
const sessionClients = sessionIsolatedClients.get(sessionId);
|
|
307
|
+
if (sessionClients) {
|
|
308
|
+
const existing = sessionClients.get(serverInfo.name);
|
|
309
|
+
if (existing) {
|
|
310
|
+
return existing;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Use a lock keyed by session+server to prevent concurrent duplicate creation
|
|
314
|
+
const lockKey = `${sessionId}:${serverInfo.name}`;
|
|
315
|
+
const existingLock = isolatedClientCreationLocks.get(lockKey);
|
|
316
|
+
if (existingLock) {
|
|
317
|
+
return existingLock;
|
|
318
|
+
}
|
|
319
|
+
const createPromise = (async () => {
|
|
320
|
+
// Re-check after acquiring the lock — another call may have created it
|
|
321
|
+
const sessionClientsAfterLock = sessionIsolatedClients.get(sessionId);
|
|
322
|
+
if (sessionClientsAfterLock) {
|
|
323
|
+
const existing = sessionClientsAfterLock.get(serverInfo.name);
|
|
324
|
+
if (existing) {
|
|
325
|
+
return existing;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const serverConfig = serverInfo.config;
|
|
329
|
+
if (!serverConfig) {
|
|
330
|
+
throw new Error(`Server config not found for isolated server: ${serverInfo.name}`);
|
|
331
|
+
}
|
|
332
|
+
if (!sessionIsolatedClients.has(sessionId)) {
|
|
333
|
+
sessionIsolatedClients.set(sessionId, new Map());
|
|
334
|
+
}
|
|
335
|
+
const reservedClients = sessionIsolatedClients.get(sessionId);
|
|
336
|
+
const transport = await createTransportFromConfig(serverInfo.name, serverConfig);
|
|
337
|
+
const client = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
|
|
338
|
+
try {
|
|
339
|
+
await client.connect(transport, serverInfo.options || {});
|
|
340
|
+
}
|
|
341
|
+
catch (connectError) {
|
|
342
|
+
// Connect failed — close the client/transport and kill any spawned
|
|
343
|
+
// process tree so a failed handshake doesn't leak resources.
|
|
344
|
+
closeIsolatedClient(serverInfo.name, client, transport);
|
|
345
|
+
throw connectError;
|
|
346
|
+
}
|
|
347
|
+
// Guard: the session was cleaned up during the async connect if its map was
|
|
348
|
+
// deleted (identity no longer matches) — close the just-created connection.
|
|
349
|
+
if (sessionIsolatedClients.get(sessionId) !== reservedClients) {
|
|
350
|
+
console.warn(`Session ${sessionId} was deleted during isolated client creation for ${serverInfo.name}, closing new client`);
|
|
351
|
+
closeIsolatedClient(serverInfo.name, client, transport);
|
|
352
|
+
throw new Error(`Session ${sessionId} no longer exists`);
|
|
353
|
+
}
|
|
354
|
+
setSessionIsolatedClient(sessionId, serverInfo.name, client, transport);
|
|
355
|
+
console.log(`Created isolated client for session ${sessionId} -> ${serverInfo.name}`);
|
|
356
|
+
return { client, transport };
|
|
357
|
+
})();
|
|
358
|
+
isolatedClientCreationLocks.set(lockKey, createPromise);
|
|
359
|
+
// Release the lock once creation settles. The rejection itself is surfaced to
|
|
360
|
+
// (and handled by) the awaiting caller below; swallow it here so this
|
|
361
|
+
// bookkeeping chain never becomes an unhandled promise rejection.
|
|
362
|
+
void createPromise
|
|
363
|
+
.finally(() => {
|
|
364
|
+
isolatedClientCreationLocks.delete(lockKey);
|
|
365
|
+
})
|
|
366
|
+
.catch(() => undefined);
|
|
367
|
+
return createPromise;
|
|
198
368
|
};
|
|
199
369
|
export const notifyToolChanged = async (name, options) => {
|
|
200
370
|
await registerAllTools(false, name, options);
|
|
@@ -325,6 +495,11 @@ const normalizeResourceForCache = (resource) => {
|
|
|
325
495
|
};
|
|
326
496
|
// Store all server information
|
|
327
497
|
let serverInfos = [];
|
|
498
|
+
// Per-session upstream clients for isolated servers (perSessionClient: true).
|
|
499
|
+
// Map<sessionId, Map<serverName, { client, transport }>>
|
|
500
|
+
const sessionIsolatedClients = new Map();
|
|
501
|
+
// Locks to prevent concurrent creation of the same isolated client
|
|
502
|
+
const isolatedClientCreationLocks = new Map();
|
|
328
503
|
// Track servers pending a cache-refresh reinstall.
|
|
329
504
|
// Consumed once by createTransportFromConfig on the next reconnect.
|
|
330
505
|
const pendingReinstalls = new Set();
|
|
@@ -465,6 +640,10 @@ export const cleanupAllServers = () => {
|
|
|
465
640
|
}
|
|
466
641
|
}
|
|
467
642
|
serverInfos = [];
|
|
643
|
+
// Drain all per-session isolated upstream clients (perSessionClient: true),
|
|
644
|
+
for (const sessionId of [...sessionIsolatedClients.keys()]) {
|
|
645
|
+
cleanupIsolatedSession(sessionId);
|
|
646
|
+
}
|
|
468
647
|
// Clear session servers as well
|
|
469
648
|
Object.keys(servers).forEach((sessionId) => {
|
|
470
649
|
delete servers[sessionId];
|
|
@@ -806,51 +985,76 @@ export const createTransportFromConfig = async (name, conf) => {
|
|
|
806
985
|
return transport;
|
|
807
986
|
};
|
|
808
987
|
// Helper function to handle client.callTool with reconnection logic
|
|
809
|
-
const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries = 1) => {
|
|
810
|
-
|
|
988
|
+
const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries = 1, isolated) => {
|
|
989
|
+
// Local, reassignable refs so a reconnect can swap in the new connection for
|
|
990
|
+
// the next retry attempt. For isolated calls these track the per-session
|
|
991
|
+
// client/transport; otherwise they mirror the shared serverInfo connection.
|
|
992
|
+
let client = isolated ? isolated.client : serverInfo.client;
|
|
993
|
+
let transport = isolated ? isolated.transport : serverInfo.transport;
|
|
994
|
+
if (!client) {
|
|
811
995
|
throw new Error(`Client not found for server: ${serverInfo.name}`);
|
|
812
996
|
}
|
|
813
997
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
814
998
|
try {
|
|
815
|
-
const result = await
|
|
999
|
+
const result = await client.callTool(toolParams, undefined, options || {});
|
|
816
1000
|
// Check auth error
|
|
817
1001
|
checkAuthError(result);
|
|
818
1002
|
return result;
|
|
819
1003
|
}
|
|
820
1004
|
catch (error) {
|
|
821
1005
|
const isHttp40xError = isRecoverableHttp4xxError(error);
|
|
822
|
-
// Only retry for StreamableHTTPClientTransport
|
|
823
|
-
const isStreamableHttp =
|
|
824
|
-
const isSSE =
|
|
825
|
-
if (attempt < maxRetries &&
|
|
826
|
-
serverInfo.
|
|
827
|
-
((isStreamableHttp && isHttp40xError) || isSSE)) {
|
|
828
|
-
console.warn(`${isHttp40xError ? 'HTTP 40x error' : 'error'} detected for ${isStreamableHttp ? 'StreamableHTTP' : 'SSE'} server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`);
|
|
1006
|
+
// Only retry for StreamableHTTPClientTransport and SSE transports
|
|
1007
|
+
const isStreamableHttp = transport instanceof StreamableHTTPClientTransport;
|
|
1008
|
+
const isSSE = transport instanceof SSEClientTransport;
|
|
1009
|
+
if (attempt < maxRetries && transport && ((isStreamableHttp && isHttp40xError) || isSSE)) {
|
|
1010
|
+
console.warn(`${isHttp40xError ? 'HTTP 40x error' : 'error'} detected for ${isStreamableHttp ? 'StreamableHTTP' : 'SSE'} server ${serverInfo.name}${isolated ? ` (isolated session ${isolated.sessionId})` : ''}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`);
|
|
829
1011
|
try {
|
|
830
|
-
// Close existing connection
|
|
831
|
-
if (serverInfo.keepAliveIntervalId) {
|
|
832
|
-
clearInterval(serverInfo.keepAliveIntervalId);
|
|
833
|
-
serverInfo.keepAliveIntervalId = undefined;
|
|
834
|
-
}
|
|
835
|
-
serverInfo.client.close();
|
|
836
|
-
serverInfo.transport.close();
|
|
837
1012
|
const server = await getServerDao().findById(serverInfo.name);
|
|
838
1013
|
if (!server) {
|
|
839
1014
|
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
|
|
840
1015
|
}
|
|
841
|
-
// Recreate transport using helper function
|
|
842
1016
|
const newTransport = await createTransportFromConfig(serverInfo.name, server);
|
|
843
|
-
|
|
844
|
-
const client = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
|
|
1017
|
+
const newClient = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
|
|
845
1018
|
// Reconnect with new transport
|
|
846
|
-
await
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1019
|
+
await newClient.connect(newTransport, serverInfo.options || {});
|
|
1020
|
+
if (isolated) {
|
|
1021
|
+
// Isolated path: close only this session's stale connection and
|
|
1022
|
+
// replace its entry in the per-session map. Never touch the shared
|
|
1023
|
+
// serverInfo client/transport.
|
|
1024
|
+
try {
|
|
1025
|
+
client.close();
|
|
1026
|
+
}
|
|
1027
|
+
catch { /* empty */ }
|
|
1028
|
+
try {
|
|
1029
|
+
transport.close();
|
|
1030
|
+
}
|
|
1031
|
+
catch { /* empty */ }
|
|
1032
|
+
setSessionIsolatedClient(isolated.sessionId, serverInfo.name, newClient, newTransport);
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
// Shared path: tear down and replace the shared connection.
|
|
1036
|
+
if (serverInfo.keepAliveIntervalId) {
|
|
1037
|
+
clearInterval(serverInfo.keepAliveIntervalId);
|
|
1038
|
+
serverInfo.keepAliveIntervalId = undefined;
|
|
1039
|
+
}
|
|
1040
|
+
try {
|
|
1041
|
+
serverInfo.client?.close();
|
|
1042
|
+
}
|
|
1043
|
+
catch { /* empty */ }
|
|
1044
|
+
try {
|
|
1045
|
+
transport.close();
|
|
1046
|
+
}
|
|
1047
|
+
catch { /* empty */ }
|
|
1048
|
+
serverInfo.client = newClient;
|
|
1049
|
+
serverInfo.transport = newTransport;
|
|
1050
|
+
serverInfo.status = 'connected';
|
|
1051
|
+
}
|
|
1052
|
+
// Point the local refs at the new connection for the next attempt.
|
|
1053
|
+
client = newClient;
|
|
1054
|
+
transport = newTransport;
|
|
851
1055
|
// Reload tools list after reconnection
|
|
852
1056
|
try {
|
|
853
|
-
const tools = await
|
|
1057
|
+
const tools = await newClient.listTools({}, serverInfo.options || {});
|
|
854
1058
|
updateServerToolsCache(serverInfo, tools.tools);
|
|
855
1059
|
}
|
|
856
1060
|
catch (listToolsError) {
|
|
@@ -869,8 +1073,10 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
|
|
|
869
1073
|
serverName: serverInfo.name,
|
|
870
1074
|
error: summarizeErrorForLogging(reconnectError),
|
|
871
1075
|
});
|
|
872
|
-
|
|
873
|
-
|
|
1076
|
+
if (!isolated) {
|
|
1077
|
+
serverInfo.status = 'disconnected';
|
|
1078
|
+
serverInfo.error = `Failed to reconnect: ${formatErrorForLogging(reconnectError)}`;
|
|
1079
|
+
}
|
|
874
1080
|
// If this was the last attempt, throw the original error
|
|
875
1081
|
if (attempt === maxRetries) {
|
|
876
1082
|
throw error;
|
|
@@ -935,17 +1141,25 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
|
|
|
935
1141
|
// status. Reloading one server must not reconnect unrelated servers
|
|
936
1142
|
// (e.g. failed/disconnected ones), which would also leak their
|
|
937
1143
|
// previous stdio child processes. See #921.
|
|
938
|
-
// - a general/full initialization (no serverName) —
|
|
939
|
-
// server's state if it is already connected
|
|
1144
|
+
// - a general/full initialization (no serverName) — preserve this
|
|
1145
|
+
// server's state if it is already connected, or if an OAuth
|
|
1146
|
+
// authorization is in flight. Reconnecting during PKCE authorization
|
|
1147
|
+
// would replace the pending code verifier and break the callback.
|
|
940
1148
|
const existingServer = existingServerInfos.find((s) => s.name === name);
|
|
941
1149
|
const isDifferentServer = Boolean(serverName) && serverName !== name;
|
|
1150
|
+
const hasInflightOAuthAuthorization = existingServer?.status === 'oauth_required' &&
|
|
1151
|
+
Boolean(expandedConf.oauth?.pendingAuthorization?.state ||
|
|
1152
|
+
existingServer.oauth?.state);
|
|
942
1153
|
if (existingServer &&
|
|
943
|
-
(isDifferentServer ||
|
|
1154
|
+
(isDifferentServer ||
|
|
1155
|
+
(!serverName && (existingServer.status === 'connected' || hasInflightOAuthAuthorization)))) {
|
|
944
1156
|
nextServerInfos.push({
|
|
945
1157
|
...existingServer,
|
|
946
1158
|
enabled: expandedConf.enabled === undefined ? true : expandedConf.enabled,
|
|
947
1159
|
});
|
|
948
|
-
console.log(
|
|
1160
|
+
console.log(hasInflightOAuthAuthorization
|
|
1161
|
+
? `Server '${name}' has an in-flight OAuth authorization; preserving existing state.`
|
|
1162
|
+
: `Server '${name}' is already connected.`);
|
|
949
1163
|
continue;
|
|
950
1164
|
}
|
|
951
1165
|
let transport;
|
|
@@ -1264,6 +1478,7 @@ export const getServersInfo = async (page, limit, user) => {
|
|
|
1264
1478
|
const serverConfig = allServers.find((server) => server.name === name);
|
|
1265
1479
|
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
|
1266
1480
|
const resolvedType = inferServerType(serverConfig);
|
|
1481
|
+
const oauthConnected = Boolean(serverConfig?.oauth?.accessToken || serverConfig?.oauth?.refreshToken);
|
|
1267
1482
|
// Add enabled status and custom description to each tool
|
|
1268
1483
|
const toolsWithEnabled = tools.map((tool) => {
|
|
1269
1484
|
const toolConfig = serverConfig?.tools?.[tool.name];
|
|
@@ -1290,11 +1505,16 @@ export const getServersInfo = async (page, limit, user) => {
|
|
|
1290
1505
|
resources,
|
|
1291
1506
|
createTime,
|
|
1292
1507
|
enabled,
|
|
1293
|
-
oauth: oauth
|
|
1508
|
+
oauth: oauth || oauthConnected
|
|
1294
1509
|
? {
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1510
|
+
...(oauth
|
|
1511
|
+
? {
|
|
1512
|
+
authorizationUrl: oauth.authorizationUrl,
|
|
1513
|
+
state: oauth.state,
|
|
1514
|
+
// Don't expose codeVerifier to frontend for security
|
|
1515
|
+
}
|
|
1516
|
+
: {}),
|
|
1517
|
+
...(oauthConnected ? { connected: true } : {}),
|
|
1298
1518
|
}
|
|
1299
1519
|
: undefined,
|
|
1300
1520
|
config: resolvedType || serverConfig?.description || serverConfig?.command
|
|
@@ -1494,31 +1714,61 @@ function checkAuthError(result) {
|
|
|
1494
1714
|
}
|
|
1495
1715
|
}
|
|
1496
1716
|
}
|
|
1717
|
+
const closeServerRuntime = (serverInfo) => {
|
|
1718
|
+
if (serverInfo.keepAliveIntervalId) {
|
|
1719
|
+
clearInterval(serverInfo.keepAliveIntervalId);
|
|
1720
|
+
serverInfo.keepAliveIntervalId = undefined;
|
|
1721
|
+
console.log('Cleared MCP server keep-alive interval');
|
|
1722
|
+
}
|
|
1723
|
+
const candidateTransport = serverInfo.transport;
|
|
1724
|
+
const stdioPid = typeof candidateTransport?.pid === 'number' ? candidateTransport.pid : null;
|
|
1725
|
+
if (serverInfo.client) {
|
|
1726
|
+
try {
|
|
1727
|
+
serverInfo.client.close();
|
|
1728
|
+
}
|
|
1729
|
+
catch {
|
|
1730
|
+
console.warn('Error closing MCP client during runtime shutdown');
|
|
1731
|
+
}
|
|
1732
|
+
serverInfo.client = undefined;
|
|
1733
|
+
}
|
|
1734
|
+
if (serverInfo.transport) {
|
|
1735
|
+
try {
|
|
1736
|
+
serverInfo.transport.close();
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
console.warn('Error closing MCP transport during runtime shutdown');
|
|
1740
|
+
}
|
|
1741
|
+
serverInfo.transport = undefined;
|
|
1742
|
+
}
|
|
1743
|
+
if (stdioPid) {
|
|
1744
|
+
killStdioProcessTree(serverInfo.name, stdioPid);
|
|
1745
|
+
}
|
|
1746
|
+
console.log('Closed MCP server client and transport');
|
|
1747
|
+
};
|
|
1497
1748
|
// Close server client and transport
|
|
1498
1749
|
function closeServer(name) {
|
|
1499
1750
|
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
|
1500
|
-
if (serverInfo
|
|
1501
|
-
|
|
1502
|
-
if (serverInfo.keepAliveIntervalId) {
|
|
1503
|
-
clearInterval(serverInfo.keepAliveIntervalId);
|
|
1504
|
-
serverInfo.keepAliveIntervalId = undefined;
|
|
1505
|
-
console.log(`Cleared keep-alive interval for server: ${serverInfo.name}`);
|
|
1506
|
-
}
|
|
1507
|
-
// Capture the child PID via duck-typing. `instanceof StdioClientTransport`
|
|
1508
|
-
// is unreliable under pnpm's "dual package hazard" — a different copy of
|
|
1509
|
-
// @modelcontextprotocol/sdk in node_modules makes the check return false
|
|
1510
|
-
// even for genuine stdio transports. The `pid` getter is the SDK's public
|
|
1511
|
-
// contract, so checking for it is both safer and version-agnostic.
|
|
1512
|
-
const candidateTransport = serverInfo.transport;
|
|
1513
|
-
const stdioPid = typeof candidateTransport.pid === 'number' ? candidateTransport.pid : null;
|
|
1514
|
-
serverInfo.client.close();
|
|
1515
|
-
serverInfo.transport.close();
|
|
1516
|
-
if (stdioPid) {
|
|
1517
|
-
killStdioProcessTree(name, stdioPid);
|
|
1518
|
-
}
|
|
1519
|
-
console.log(`Closed client and transport for server: ${serverInfo.name}`);
|
|
1751
|
+
if (serverInfo) {
|
|
1752
|
+
closeServerRuntime(serverInfo);
|
|
1520
1753
|
}
|
|
1521
1754
|
}
|
|
1755
|
+
export const resetServerOAuthConnection = (name) => {
|
|
1756
|
+
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
|
1757
|
+
if (!serverInfo) {
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
closeServerRuntime(serverInfo);
|
|
1761
|
+
serverInfo.status = 'oauth_required';
|
|
1762
|
+
serverInfo.error = null;
|
|
1763
|
+
serverInfo.tools = [];
|
|
1764
|
+
serverInfo.prompts = [];
|
|
1765
|
+
serverInfo.resources = [];
|
|
1766
|
+
serverInfo.oauth = undefined;
|
|
1767
|
+
broadcastToolListChanged();
|
|
1768
|
+
broadcastPromptListChanged();
|
|
1769
|
+
broadcastResourceListChanged();
|
|
1770
|
+
return true;
|
|
1771
|
+
};
|
|
1522
1772
|
// Kill the entire process tree of a stdio transport's child process.
|
|
1523
1773
|
//
|
|
1524
1774
|
// transport.close() only sends SIGTERM to the direct child. When the server is
|
|
@@ -1944,8 +2194,13 @@ export const handleCallToolRequest = async (request, extra) => {
|
|
|
1944
2194
|
});
|
|
1945
2195
|
}
|
|
1946
2196
|
// Call the tool on the target server (MCP servers)
|
|
1947
|
-
|
|
1948
|
-
|
|
2197
|
+
// For servers with perSessionClient: true, use a per-session dedicated client
|
|
2198
|
+
let isolatedCtx;
|
|
2199
|
+
if (targetServerInfo.config?.perSessionClient && sessionId) {
|
|
2200
|
+
const isolated = await getOrCreateIsolatedClient(sessionId, targetServerInfo);
|
|
2201
|
+
isolatedCtx = { sessionId, client: isolated.client, transport: isolated.transport };
|
|
2202
|
+
}
|
|
2203
|
+
else if (!targetServerInfo.client) {
|
|
1949
2204
|
throw new Error(`Client not found for server: ${targetServerInfo.name}`);
|
|
1950
2205
|
}
|
|
1951
2206
|
// Use toolArgs if it has properties, otherwise fallback to request.params.arguments
|
|
@@ -1954,13 +2209,14 @@ export const handleCallToolRequest = async (request, extra) => {
|
|
|
1954
2209
|
toolName: targetToolName,
|
|
1955
2210
|
serverName: targetServerInfo.name,
|
|
1956
2211
|
arguments: summarizeArgumentsForLogging(finalArgs),
|
|
2212
|
+
perSessionClient: !!targetServerInfo.config?.perSessionClient,
|
|
1957
2213
|
});
|
|
1958
2214
|
const cleanToolName = normalizeToolNameForServer(targetServerInfo.name, targetToolName);
|
|
1959
2215
|
await reserveHostedIfNeeded(targetServerInfo.name, cleanToolName);
|
|
1960
2216
|
const result = await callToolWithReconnect(targetServerInfo, {
|
|
1961
2217
|
name: cleanToolName,
|
|
1962
2218
|
arguments: finalArgs,
|
|
1963
|
-
}, targetServerInfo.options || {});
|
|
2219
|
+
}, targetServerInfo.options || {}, 1, isolatedCtx);
|
|
1964
2220
|
await settleHostedIfNeeded({
|
|
1965
2221
|
success: !result.isError,
|
|
1966
2222
|
requestContent: finalArgs,
|
|
@@ -2088,13 +2344,18 @@ export const handleCallToolRequest = async (request, extra) => {
|
|
|
2088
2344
|
});
|
|
2089
2345
|
}
|
|
2090
2346
|
// Handle MCP servers
|
|
2091
|
-
|
|
2092
|
-
|
|
2347
|
+
// For servers with perSessionClient: true, use a per-session dedicated client
|
|
2348
|
+
let isolatedCtx;
|
|
2349
|
+
if (serverInfo.config?.perSessionClient && sessionId) {
|
|
2350
|
+
const isolated = await getOrCreateIsolatedClient(sessionId, serverInfo);
|
|
2351
|
+
isolatedCtx = { sessionId, client: isolated.client, transport: isolated.transport };
|
|
2352
|
+
}
|
|
2353
|
+
else if (!serverInfo.client) {
|
|
2093
2354
|
throw new Error(`Client not found for server: ${serverInfo.name}`);
|
|
2094
2355
|
}
|
|
2095
2356
|
const cleanToolName = normalizeToolNameForServer(serverInfo.name, routeToolName);
|
|
2096
2357
|
await reserveHostedIfNeeded(serverInfo.name, cleanToolName);
|
|
2097
|
-
const result = await callToolWithReconnect(serverInfo, { ...request.params, name: cleanToolName }, serverInfo.options || {});
|
|
2358
|
+
const result = await callToolWithReconnect(serverInfo, { ...request.params, name: cleanToolName }, serverInfo.options || {}, 1, isolatedCtx);
|
|
2098
2359
|
await settleHostedIfNeeded({
|
|
2099
2360
|
success: !result.isError,
|
|
2100
2361
|
requestContent: request.params.arguments,
|