@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.
Files changed (74) hide show
  1. package/dist/controllers/serverController.js +48 -0
  2. package/dist/controllers/serverController.js.map +1 -1
  3. package/dist/dao/ServerDaoDbImpl.js +3 -0
  4. package/dist/dao/ServerDaoDbImpl.js.map +1 -1
  5. package/dist/db/connection.js +96 -60
  6. package/dist/db/connection.js.map +1 -1
  7. package/dist/db/entities/Server.js +4 -0
  8. package/dist/db/entities/Server.js.map +1 -1
  9. package/dist/db/repositories/SystemConfigRepository.js +37 -15
  10. package/dist/db/repositories/SystemConfigRepository.js.map +1 -1
  11. package/dist/routes/index.js +2 -1
  12. package/dist/routes/index.js.map +1 -1
  13. package/dist/services/mcpService.js +324 -63
  14. package/dist/services/mcpService.js.map +1 -1
  15. package/dist/services/oauthClientRegistration.js +1 -0
  16. package/dist/services/oauthClientRegistration.js.map +1 -1
  17. package/dist/services/oauthSettingsStore.js +3 -0
  18. package/dist/services/oauthSettingsStore.js.map +1 -1
  19. package/dist/services/upstreamOAuthDisconnectService.js +160 -0
  20. package/dist/services/upstreamOAuthDisconnectService.js.map +1 -0
  21. package/dist/utils/dbRetry.js +1 -0
  22. package/dist/utils/dbRetry.js.map +1 -1
  23. package/dist/utils/migration.js +1 -0
  24. package/dist/utils/migration.js.map +1 -1
  25. package/dist/utils/serialization.js +2 -1
  26. package/dist/utils/serialization.js.map +1 -1
  27. package/dist/utils/serverConfigPersistence.js +1 -0
  28. package/dist/utils/serverConfigPersistence.js.map +1 -1
  29. package/frontend/dist/assets/{ActivityPage-BdZq-UUz.js → ActivityPage-BuY3HRab.js} +2 -2
  30. package/frontend/dist/assets/{ActivityPage-BdZq-UUz.js.map → ActivityPage-BuY3HRab.js.map} +1 -1
  31. package/frontend/dist/assets/{Dashboard-B9NgJISN.js → Dashboard-bO5Xtzsq.js} +2 -2
  32. package/frontend/dist/assets/{Dashboard-B9NgJISN.js.map → Dashboard-bO5Xtzsq.js.map} +1 -1
  33. package/frontend/dist/assets/{EndpointCopy-CxQGXtue.js → EndpointCopy-5nz6zs3k.js} +2 -2
  34. package/frontend/dist/assets/{EndpointCopy-CxQGXtue.js.map → EndpointCopy-5nz6zs3k.js.map} +1 -1
  35. package/frontend/dist/assets/{GroupsPage-DvkKDHf7.js → GroupsPage-cW-uxB8G.js} +2 -2
  36. package/frontend/dist/assets/{GroupsPage-DvkKDHf7.js.map → GroupsPage-cW-uxB8G.js.map} +1 -1
  37. package/frontend/dist/assets/{LoginPage-BJKZSGKK.js → LoginPage-E5RJsYsm.js} +2 -2
  38. package/frontend/dist/assets/{LoginPage-BJKZSGKK.js.map → LoginPage-E5RJsYsm.js.map} +1 -1
  39. package/frontend/dist/assets/{LogsPage-Dd0rwLjA.js → LogsPage-B7NIiglx.js} +2 -2
  40. package/frontend/dist/assets/{LogsPage-Dd0rwLjA.js.map → LogsPage-B7NIiglx.js.map} +1 -1
  41. package/frontend/dist/assets/{MarketPage-upzNkwbt.js → MarketPage-B_DUnxPX.js} +2 -2
  42. package/frontend/dist/assets/{MarketPage-upzNkwbt.js.map → MarketPage-B_DUnxPX.js.map} +1 -1
  43. package/frontend/dist/assets/{PromptsPage-BuLeSc0F.js → PromptsPage-DRsO8XiQ.js} +2 -2
  44. package/frontend/dist/assets/{PromptsPage-BuLeSc0F.js.map → PromptsPage-DRsO8XiQ.js.map} +1 -1
  45. package/frontend/dist/assets/{ResourcesPage-DrZ_QvOI.js → ResourcesPage-bVfPTdff.js} +2 -2
  46. package/frontend/dist/assets/{ResourcesPage-DrZ_QvOI.js.map → ResourcesPage-bVfPTdff.js.map} +1 -1
  47. package/frontend/dist/assets/ServersPage-V8rY98M6.js +37 -0
  48. package/frontend/dist/assets/ServersPage-V8rY98M6.js.map +1 -0
  49. package/frontend/dist/assets/{SettingsPage-lYf7PdC3.js → SettingsPage-Cq_1HeWE.js} +2 -2
  50. package/frontend/dist/assets/{SettingsPage-lYf7PdC3.js.map → SettingsPage-Cq_1HeWE.js.map} +1 -1
  51. package/frontend/dist/assets/{StatusDot-DoWgjhoz.js → StatusDot-BoDsWVzY.js} +2 -2
  52. package/frontend/dist/assets/{StatusDot-DoWgjhoz.js.map → StatusDot-BoDsWVzY.js.map} +1 -1
  53. package/frontend/dist/assets/{ToggleGroup-D5ZAjFw-.js → ToggleGroup-D8PkTqMI.js} +2 -2
  54. package/frontend/dist/assets/{ToggleGroup-D5ZAjFw-.js.map → ToggleGroup-D8PkTqMI.js.map} +1 -1
  55. package/frontend/dist/assets/{UsersPage-4N_KUrap.js → UsersPage-135nXZTG.js} +2 -2
  56. package/frontend/dist/assets/{UsersPage-4N_KUrap.js.map → UsersPage-135nXZTG.js.map} +1 -1
  57. package/frontend/dist/assets/{contextCost-DKF-jtb4.js → contextCost-4glX9T0N.js} +2 -2
  58. package/frontend/dist/assets/{contextCost-DKF-jtb4.js.map → contextCost-4glX9T0N.js.map} +1 -1
  59. package/frontend/dist/assets/index-CXPQETnb.js +3 -0
  60. package/frontend/dist/assets/index-CXPQETnb.js.map +1 -0
  61. package/frontend/dist/assets/{resourceService-Cu9CYEjx.js → resourceService-DsVPipmK.js} +2 -2
  62. package/frontend/dist/assets/{resourceService-Cu9CYEjx.js.map → resourceService-DsVPipmK.js.map} +1 -1
  63. package/frontend/dist/assets/useSettingsData-BHaYBWNy.js +2 -0
  64. package/frontend/dist/assets/{useSettingsData-CiFNo8wN.js.map → useSettingsData-BHaYBWNy.js.map} +1 -1
  65. package/frontend/dist/assets/{variableDetection-DChuQNs-.js → variableDetection-D6KIhNDI.js} +3 -3
  66. package/frontend/dist/assets/variableDetection-D6KIhNDI.js.map +1 -0
  67. package/frontend/dist/index.html +1 -1
  68. package/package.json +4 -4
  69. package/frontend/dist/assets/ServersPage-hag0KLrj.js +0 -37
  70. package/frontend/dist/assets/ServersPage-hag0KLrj.js.map +0 -1
  71. package/frontend/dist/assets/index-CSXs6vVg.js +0 -3
  72. package/frontend/dist/assets/index-CSXs6vVg.js.map +0 -1
  73. package/frontend/dist/assets/useSettingsData-CiFNo8wN.js +0 -2
  74. 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, ...args],
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
- if (!serverInfo.client) {
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 serverInfo.client.callTool(toolParams, undefined, options || {});
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 = serverInfo.transport instanceof StreamableHTTPClientTransport;
824
- const isSSE = serverInfo.transport instanceof SSEClientTransport;
825
- if (attempt < maxRetries &&
826
- serverInfo.transport &&
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
- // Create new client
844
- const client = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
1017
+ const newClient = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
845
1018
  // Reconnect with new transport
846
- await client.connect(newTransport, serverInfo.options || {});
847
- // Update server info with new client and transport
848
- serverInfo.client = client;
849
- serverInfo.transport = newTransport;
850
- serverInfo.status = 'connected';
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 client.listTools({}, serverInfo.options || {});
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
- serverInfo.status = 'disconnected';
873
- serverInfo.error = `Failed to reconnect: ${formatErrorForLogging(reconnectError)}`;
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) — only preserve this
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 || (!serverName && existingServer.status === 'connected'))) {
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(`Server '${name}' is already connected.`);
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
- authorizationUrl: oauth.authorizationUrl,
1296
- state: oauth.state,
1297
- // Don't expose codeVerifier to frontend for security
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 && serverInfo.client && serverInfo.transport) {
1501
- // Clear keep-alive interval if exists
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
- const client = targetServerInfo.client;
1948
- if (!client) {
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
- const client = serverInfo.client;
2092
- if (!client) {
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,