@samanhappy/mcphub 1.0.22 → 1.0.24

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 (101) hide show
  1. package/README.fr.md +9 -0
  2. package/README.md +9 -0
  3. package/README.zh.md +9 -0
  4. package/dist/betterAuth.js +2 -1
  5. package/dist/betterAuth.js.map +1 -1
  6. package/dist/controllers/oauthCallbackController.js +51 -25
  7. package/dist/controllers/oauthCallbackController.js.map +1 -1
  8. package/dist/controllers/oauthDynamicRegistrationController.js +2 -1
  9. package/dist/controllers/oauthDynamicRegistrationController.js.map +1 -1
  10. package/dist/controllers/oauthServerController.js +3 -2
  11. package/dist/controllers/oauthServerController.js.map +1 -1
  12. package/dist/controllers/serverController.js +52 -2
  13. package/dist/controllers/serverController.js.map +1 -1
  14. package/dist/dao/ServerDaoDbImpl.js +3 -0
  15. package/dist/dao/ServerDaoDbImpl.js.map +1 -1
  16. package/dist/db/entities/Server.js +4 -0
  17. package/dist/db/entities/Server.js.map +1 -1
  18. package/dist/middlewares/i18n.js +11 -12
  19. package/dist/middlewares/i18n.js.map +1 -1
  20. package/dist/models/User.js +18 -8
  21. package/dist/models/User.js.map +1 -1
  22. package/dist/routes/index.js +2 -1
  23. package/dist/routes/index.js.map +1 -1
  24. package/dist/services/betterAuthConfig.js +2 -1
  25. package/dist/services/betterAuthConfig.js.map +1 -1
  26. package/dist/services/keepAliveService.js +35 -6
  27. package/dist/services/keepAliveService.js.map +1 -1
  28. package/dist/services/mcpOAuthProvider.js +2 -1
  29. package/dist/services/mcpOAuthProvider.js.map +1 -1
  30. package/dist/services/mcpService.js +297 -66
  31. package/dist/services/mcpService.js.map +1 -1
  32. package/dist/services/oauthClientRegistration.js +3 -1
  33. package/dist/services/oauthClientRegistration.js.map +1 -1
  34. package/dist/services/oauthSettingsStore.js +3 -0
  35. package/dist/services/oauthSettingsStore.js.map +1 -1
  36. package/dist/services/openApiGeneratorService.js +2 -1
  37. package/dist/services/openApiGeneratorService.js.map +1 -1
  38. package/dist/services/upstreamOAuthDisconnectService.js +160 -0
  39. package/dist/services/upstreamOAuthDisconnectService.js.map +1 -0
  40. package/dist/utils/i18n.js +48 -7
  41. package/dist/utils/i18n.js.map +1 -1
  42. package/dist/utils/installBaseUrl.js +24 -0
  43. package/dist/utils/installBaseUrl.js.map +1 -0
  44. package/dist/utils/migration.js +1 -0
  45. package/dist/utils/migration.js.map +1 -1
  46. package/dist/utils/path.js +24 -17
  47. package/dist/utils/path.js.map +1 -1
  48. package/dist/utils/serialization.js +2 -1
  49. package/dist/utils/serialization.js.map +1 -1
  50. package/dist/utils/serverConfigPersistence.js +1 -0
  51. package/dist/utils/serverConfigPersistence.js.map +1 -1
  52. package/frontend/dist/assets/{ActivityPage-a7iUIWgo.js → ActivityPage-ClYamLqW.js} +2 -2
  53. package/frontend/dist/assets/{ActivityPage-a7iUIWgo.js.map → ActivityPage-ClYamLqW.js.map} +1 -1
  54. package/frontend/dist/assets/{Dashboard-BymSNeX3.js → Dashboard-XMPV4Ypu.js} +2 -2
  55. package/frontend/dist/assets/{Dashboard-BymSNeX3.js.map → Dashboard-XMPV4Ypu.js.map} +1 -1
  56. package/frontend/dist/assets/{EndpointCopy-BpKGkoXE.js → EndpointCopy-Ch7HbIsp.js} +2 -2
  57. package/frontend/dist/assets/{EndpointCopy-BpKGkoXE.js.map → EndpointCopy-Ch7HbIsp.js.map} +1 -1
  58. package/frontend/dist/assets/{GroupsPage-CUDeiRrR.js → GroupsPage-DkUg6Xih.js} +2 -2
  59. package/frontend/dist/assets/GroupsPage-DkUg6Xih.js.map +1 -0
  60. package/frontend/dist/assets/{LoginPage-C0b1KVcS.js → LoginPage-Chm4p59K.js} +2 -2
  61. package/frontend/dist/assets/{LoginPage-C0b1KVcS.js.map → LoginPage-Chm4p59K.js.map} +1 -1
  62. package/frontend/dist/assets/{LogsPage-ndVl0S7X.js → LogsPage-D0QA2y2j.js} +2 -2
  63. package/frontend/dist/assets/{LogsPage-ndVl0S7X.js.map → LogsPage-D0QA2y2j.js.map} +1 -1
  64. package/frontend/dist/assets/{MarketPage-LS2z8vcu.js → MarketPage-CU9wn6vM.js} +2 -2
  65. package/frontend/dist/assets/{MarketPage-LS2z8vcu.js.map → MarketPage-CU9wn6vM.js.map} +1 -1
  66. package/frontend/dist/assets/{PromptsPage-0MAP2tLv.js → PromptsPage-Iht1gTW7.js} +2 -2
  67. package/frontend/dist/assets/{PromptsPage-0MAP2tLv.js.map → PromptsPage-Iht1gTW7.js.map} +1 -1
  68. package/frontend/dist/assets/{ResourcesPage-BXjmP5FT.js → ResourcesPage-DsOhIF8m.js} +2 -2
  69. package/frontend/dist/assets/{ResourcesPage-BXjmP5FT.js.map → ResourcesPage-DsOhIF8m.js.map} +1 -1
  70. package/frontend/dist/assets/ServersPage-Dq_yBsZM.js +37 -0
  71. package/frontend/dist/assets/ServersPage-Dq_yBsZM.js.map +1 -0
  72. package/frontend/dist/assets/{SettingsPage-BGHo0M9q.js → SettingsPage-DoHjlHsl.js} +2 -2
  73. package/frontend/dist/assets/{SettingsPage-BGHo0M9q.js.map → SettingsPage-DoHjlHsl.js.map} +1 -1
  74. package/frontend/dist/assets/{StatusDot-CMGPqJqj.js → StatusDot-BF-uWsCA.js} +2 -2
  75. package/frontend/dist/assets/{StatusDot-CMGPqJqj.js.map → StatusDot-BF-uWsCA.js.map} +1 -1
  76. package/frontend/dist/assets/{ToggleGroup-C2cwcIkv.js → ToggleGroup-DMC5NEP5.js} +2 -2
  77. package/frontend/dist/assets/{ToggleGroup-C2cwcIkv.js.map → ToggleGroup-DMC5NEP5.js.map} +1 -1
  78. package/frontend/dist/assets/{UsersPage-eX3oMsvb.js → UsersPage-BA9TJs2F.js} +2 -2
  79. package/frontend/dist/assets/{UsersPage-eX3oMsvb.js.map → UsersPage-BA9TJs2F.js.map} +1 -1
  80. package/frontend/dist/assets/{contextCost-BCKU_3Kc.js → contextCost-KhOeuF-d.js} +2 -2
  81. package/frontend/dist/assets/{contextCost-BCKU_3Kc.js.map → contextCost-KhOeuF-d.js.map} +1 -1
  82. package/frontend/dist/assets/{index-BlTPJflb.css → index-BfEBXfi0.css} +1 -1
  83. package/frontend/dist/assets/index-xwxOTCc_.js +3 -0
  84. package/frontend/dist/assets/index-xwxOTCc_.js.map +1 -0
  85. package/frontend/dist/assets/{resourceService-CVAZJPEy.js → resourceService-OokeUb4C.js} +2 -2
  86. package/frontend/dist/assets/{resourceService-CVAZJPEy.js.map → resourceService-OokeUb4C.js.map} +1 -1
  87. package/frontend/dist/assets/useSettingsData-DegWU8bg.js +2 -0
  88. package/frontend/dist/assets/{useSettingsData-BdEnzN5x.js.map → useSettingsData-DegWU8bg.js.map} +1 -1
  89. package/frontend/dist/assets/variableDetection-D6KIhNDI.js +16 -0
  90. package/frontend/dist/assets/variableDetection-D6KIhNDI.js.map +1 -0
  91. package/frontend/dist/index.html +2 -2
  92. package/mcp_settings.json +2 -8
  93. package/package.json +3 -3
  94. package/frontend/dist/assets/GroupsPage-CUDeiRrR.js.map +0 -1
  95. package/frontend/dist/assets/ServersPage-B_Ow-YRR.js +0 -37
  96. package/frontend/dist/assets/ServersPage-B_Ow-YRR.js.map +0 -1
  97. package/frontend/dist/assets/index-hse3fHTq.js +0 -3
  98. package/frontend/dist/assets/index-hse3fHTq.js.map +0 -1
  99. package/frontend/dist/assets/useSettingsData-BdEnzN5x.js +0 -2
  100. package/frontend/dist/assets/variableDetection-B7PPrIlN.js +0 -16
  101. package/frontend/dist/assets/variableDetection-B7PPrIlN.js.map +0 -1
@@ -195,6 +195,131 @@ export const getMcpServer = async (sessionId, group) => {
195
195
  };
196
196
  export const deleteMcpServer = (sessionId) => {
197
197
  delete servers[sessionId];
198
+ // Clean up any per-session isolated upstream clients for this session
199
+ cleanupIsolatedSession(sessionId);
200
+ };
201
+ /**
202
+ * Close an isolated upstream client and its transport, killing the whole stdio
203
+ * process tree (to avoid orphaned wrappers like `npx`) when applicable.
204
+ */
205
+ const closeIsolatedClient = (serverName, client, transport) => {
206
+ try {
207
+ client.close();
208
+ }
209
+ catch (e) {
210
+ console.warn(`[${serverName}] Error closing isolated client:`, e);
211
+ }
212
+ const candidateTransport = transport;
213
+ const stdioPid = typeof candidateTransport.pid === 'number' ? candidateTransport.pid : null;
214
+ try {
215
+ transport.close();
216
+ }
217
+ catch (e) {
218
+ console.warn(`[${serverName}] Error closing isolated transport:`, e);
219
+ }
220
+ // For stdio transports, kill the whole process tree to avoid orphans
221
+ if (stdioPid) {
222
+ killStdioProcessTree(serverName, stdioPid);
223
+ }
224
+ };
225
+ /**
226
+ * Clean up per-session isolated upstream clients for a given session.
227
+ * Closes all clients and transports, then removes the session entry.
228
+ */
229
+ const cleanupIsolatedSession = (sessionId) => {
230
+ const sessionClients = sessionIsolatedClients.get(sessionId);
231
+ if (sessionClients) {
232
+ for (const [serverName, { client, transport }] of sessionClients) {
233
+ closeIsolatedClient(serverName, client, transport);
234
+ }
235
+ sessionIsolatedClients.delete(sessionId);
236
+ // Clean up any leftover creation locks for this session
237
+ for (const key of isolatedClientCreationLocks.keys()) {
238
+ if (key.startsWith(`${sessionId}:`)) {
239
+ isolatedClientCreationLocks.delete(key);
240
+ }
241
+ }
242
+ console.log(`Cleaned up isolated clients for session: ${sessionId}`);
243
+ }
244
+ };
245
+ /** Helper to write an isolated session upstream client into the tracking map. */
246
+ const setSessionIsolatedClient = (sessionId, serverName, client, transport) => {
247
+ let sessionClients = sessionIsolatedClients.get(sessionId);
248
+ if (!sessionClients) {
249
+ sessionClients = new Map();
250
+ sessionIsolatedClients.set(sessionId, sessionClients);
251
+ }
252
+ sessionClients.set(serverName, { client, transport });
253
+ };
254
+ /**
255
+ * Get or create a per-session isolated upstream client for a server.
256
+ * Used when the server has `perSessionClient: true` in its config.
257
+ * Uses a creation lock to prevent duplicate connections from concurrent calls.
258
+ */
259
+ const getOrCreateIsolatedClient = async (sessionId, serverInfo) => {
260
+ // Quick check without lock — already exists
261
+ const sessionClients = sessionIsolatedClients.get(sessionId);
262
+ if (sessionClients) {
263
+ const existing = sessionClients.get(serverInfo.name);
264
+ if (existing) {
265
+ return existing;
266
+ }
267
+ }
268
+ // Use a lock keyed by session+server to prevent concurrent duplicate creation
269
+ const lockKey = `${sessionId}:${serverInfo.name}`;
270
+ const existingLock = isolatedClientCreationLocks.get(lockKey);
271
+ if (existingLock) {
272
+ return existingLock;
273
+ }
274
+ const createPromise = (async () => {
275
+ // Re-check after acquiring the lock — another call may have created it
276
+ const sessionClientsAfterLock = sessionIsolatedClients.get(sessionId);
277
+ if (sessionClientsAfterLock) {
278
+ const existing = sessionClientsAfterLock.get(serverInfo.name);
279
+ if (existing) {
280
+ return existing;
281
+ }
282
+ }
283
+ const serverConfig = serverInfo.config;
284
+ if (!serverConfig) {
285
+ throw new Error(`Server config not found for isolated server: ${serverInfo.name}`);
286
+ }
287
+ if (!sessionIsolatedClients.has(sessionId)) {
288
+ sessionIsolatedClients.set(sessionId, new Map());
289
+ }
290
+ const reservedClients = sessionIsolatedClients.get(sessionId);
291
+ const transport = await createTransportFromConfig(serverInfo.name, serverConfig);
292
+ const client = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
293
+ try {
294
+ await client.connect(transport, serverInfo.options || {});
295
+ }
296
+ catch (connectError) {
297
+ // Connect failed — close the client/transport and kill any spawned
298
+ // process tree so a failed handshake doesn't leak resources.
299
+ closeIsolatedClient(serverInfo.name, client, transport);
300
+ throw connectError;
301
+ }
302
+ // Guard: the session was cleaned up during the async connect if its map was
303
+ // deleted (identity no longer matches) — close the just-created connection.
304
+ if (sessionIsolatedClients.get(sessionId) !== reservedClients) {
305
+ console.warn(`Session ${sessionId} was deleted during isolated client creation for ${serverInfo.name}, closing new client`);
306
+ closeIsolatedClient(serverInfo.name, client, transport);
307
+ throw new Error(`Session ${sessionId} no longer exists`);
308
+ }
309
+ setSessionIsolatedClient(sessionId, serverInfo.name, client, transport);
310
+ console.log(`Created isolated client for session ${sessionId} -> ${serverInfo.name}`);
311
+ return { client, transport };
312
+ })();
313
+ isolatedClientCreationLocks.set(lockKey, createPromise);
314
+ // Release the lock once creation settles. The rejection itself is surfaced to
315
+ // (and handled by) the awaiting caller below; swallow it here so this
316
+ // bookkeeping chain never becomes an unhandled promise rejection.
317
+ void createPromise
318
+ .finally(() => {
319
+ isolatedClientCreationLocks.delete(lockKey);
320
+ })
321
+ .catch(() => undefined);
322
+ return createPromise;
198
323
  };
199
324
  export const notifyToolChanged = async (name, options) => {
200
325
  await registerAllTools(false, name, options);
@@ -325,6 +450,11 @@ const normalizeResourceForCache = (resource) => {
325
450
  };
326
451
  // Store all server information
327
452
  let serverInfos = [];
453
+ // Per-session upstream clients for isolated servers (perSessionClient: true).
454
+ // Map<sessionId, Map<serverName, { client, transport }>>
455
+ const sessionIsolatedClients = new Map();
456
+ // Locks to prevent concurrent creation of the same isolated client
457
+ const isolatedClientCreationLocks = new Map();
328
458
  // Track servers pending a cache-refresh reinstall.
329
459
  // Consumed once by createTransportFromConfig on the next reconnect.
330
460
  const pendingReinstalls = new Set();
@@ -465,6 +595,10 @@ export const cleanupAllServers = () => {
465
595
  }
466
596
  }
467
597
  serverInfos = [];
598
+ // Drain all per-session isolated upstream clients (perSessionClient: true),
599
+ for (const sessionId of [...sessionIsolatedClients.keys()]) {
600
+ cleanupIsolatedSession(sessionId);
601
+ }
468
602
  // Clear session servers as well
469
603
  Object.keys(servers).forEach((sessionId) => {
470
604
  delete servers[sessionId];
@@ -806,51 +940,76 @@ export const createTransportFromConfig = async (name, conf) => {
806
940
  return transport;
807
941
  };
808
942
  // Helper function to handle client.callTool with reconnection logic
809
- const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries = 1) => {
810
- if (!serverInfo.client) {
943
+ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries = 1, isolated) => {
944
+ // Local, reassignable refs so a reconnect can swap in the new connection for
945
+ // the next retry attempt. For isolated calls these track the per-session
946
+ // client/transport; otherwise they mirror the shared serverInfo connection.
947
+ let client = isolated ? isolated.client : serverInfo.client;
948
+ let transport = isolated ? isolated.transport : serverInfo.transport;
949
+ if (!client) {
811
950
  throw new Error(`Client not found for server: ${serverInfo.name}`);
812
951
  }
813
952
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
814
953
  try {
815
- const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
954
+ const result = await client.callTool(toolParams, undefined, options || {});
816
955
  // Check auth error
817
956
  checkAuthError(result);
818
957
  return result;
819
958
  }
820
959
  catch (error) {
821
960
  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})`);
961
+ // Only retry for StreamableHTTPClientTransport and SSE transports
962
+ const isStreamableHttp = transport instanceof StreamableHTTPClientTransport;
963
+ const isSSE = transport instanceof SSEClientTransport;
964
+ if (attempt < maxRetries && transport && ((isStreamableHttp && isHttp40xError) || isSSE)) {
965
+ 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
966
  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
967
  const server = await getServerDao().findById(serverInfo.name);
838
968
  if (!server) {
839
969
  throw new Error(`Server configuration not found for: ${serverInfo.name}`);
840
970
  }
841
- // Recreate transport using helper function
842
971
  const newTransport = await createTransportFromConfig(serverInfo.name, server);
843
- // Create new client
844
- const client = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
972
+ const newClient = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
845
973
  // 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';
974
+ await newClient.connect(newTransport, serverInfo.options || {});
975
+ if (isolated) {
976
+ // Isolated path: close only this session's stale connection and
977
+ // replace its entry in the per-session map. Never touch the shared
978
+ // serverInfo client/transport.
979
+ try {
980
+ client.close();
981
+ }
982
+ catch { /* empty */ }
983
+ try {
984
+ transport.close();
985
+ }
986
+ catch { /* empty */ }
987
+ setSessionIsolatedClient(isolated.sessionId, serverInfo.name, newClient, newTransport);
988
+ }
989
+ else {
990
+ // Shared path: tear down and replace the shared connection.
991
+ if (serverInfo.keepAliveIntervalId) {
992
+ clearInterval(serverInfo.keepAliveIntervalId);
993
+ serverInfo.keepAliveIntervalId = undefined;
994
+ }
995
+ try {
996
+ serverInfo.client?.close();
997
+ }
998
+ catch { /* empty */ }
999
+ try {
1000
+ transport.close();
1001
+ }
1002
+ catch { /* empty */ }
1003
+ serverInfo.client = newClient;
1004
+ serverInfo.transport = newTransport;
1005
+ serverInfo.status = 'connected';
1006
+ }
1007
+ // Point the local refs at the new connection for the next attempt.
1008
+ client = newClient;
1009
+ transport = newTransport;
851
1010
  // Reload tools list after reconnection
852
1011
  try {
853
- const tools = await client.listTools({}, serverInfo.options || {});
1012
+ const tools = await newClient.listTools({}, serverInfo.options || {});
854
1013
  updateServerToolsCache(serverInfo, tools.tools);
855
1014
  }
856
1015
  catch (listToolsError) {
@@ -869,8 +1028,10 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
869
1028
  serverName: serverInfo.name,
870
1029
  error: summarizeErrorForLogging(reconnectError),
871
1030
  });
872
- serverInfo.status = 'disconnected';
873
- serverInfo.error = `Failed to reconnect: ${formatErrorForLogging(reconnectError)}`;
1031
+ if (!isolated) {
1032
+ serverInfo.status = 'disconnected';
1033
+ serverInfo.error = `Failed to reconnect: ${formatErrorForLogging(reconnectError)}`;
1034
+ }
874
1035
  // If this was the last attempt, throw the original error
875
1036
  if (attempt === maxRetries) {
876
1037
  throw error;
@@ -886,6 +1047,22 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
886
1047
  // This should not be reached, but just in case
887
1048
  throw new Error('Unexpected error in callToolWithReconnect');
888
1049
  };
1050
+ const setupServerKeepAlive = (serverInfo, serverConfig) => {
1051
+ setupClientKeepAlive(serverInfo, serverConfig, {
1052
+ reconnectServer: async (serverName) => {
1053
+ try {
1054
+ await reconnectServer(serverName);
1055
+ }
1056
+ catch (error) {
1057
+ setupServerKeepAlive(serverInfo, serverConfig);
1058
+ throw error;
1059
+ }
1060
+ },
1061
+ }).catch((error) => console.warn('Keepalive setup failed', {
1062
+ serverName: serverInfo.name,
1063
+ error: summarizeErrorForLogging(error),
1064
+ }));
1065
+ };
889
1066
  // Initialize MCP server clients
890
1067
  export const initializeClientsFromSettings = async (isInit, serverName, options) => {
891
1068
  const allServers = await getServerDao().findAll();
@@ -919,17 +1096,25 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
919
1096
  // status. Reloading one server must not reconnect unrelated servers
920
1097
  // (e.g. failed/disconnected ones), which would also leak their
921
1098
  // previous stdio child processes. See #921.
922
- // - a general/full initialization (no serverName) — only preserve this
923
- // server's state if it is already connected.
1099
+ // - a general/full initialization (no serverName) — preserve this
1100
+ // server's state if it is already connected, or if an OAuth
1101
+ // authorization is in flight. Reconnecting during PKCE authorization
1102
+ // would replace the pending code verifier and break the callback.
924
1103
  const existingServer = existingServerInfos.find((s) => s.name === name);
925
1104
  const isDifferentServer = Boolean(serverName) && serverName !== name;
1105
+ const hasInflightOAuthAuthorization = existingServer?.status === 'oauth_required' &&
1106
+ Boolean(expandedConf.oauth?.pendingAuthorization?.state ||
1107
+ existingServer.oauth?.state);
926
1108
  if (existingServer &&
927
- (isDifferentServer || (!serverName && existingServer.status === 'connected'))) {
1109
+ (isDifferentServer ||
1110
+ (!serverName && (existingServer.status === 'connected' || hasInflightOAuthAuthorization)))) {
928
1111
  nextServerInfos.push({
929
1112
  ...existingServer,
930
1113
  enabled: expandedConf.enabled === undefined ? true : expandedConf.enabled,
931
1114
  });
932
- console.log(`Server '${name}' is already connected.`);
1115
+ console.log(hasInflightOAuthAuthorization
1116
+ ? `Server '${name}' has an in-flight OAuth authorization; preserving existing state.`
1117
+ : `Server '${name}' is already connected.`);
933
1118
  continue;
934
1119
  }
935
1120
  let transport;
@@ -1144,14 +1329,12 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1144
1329
  serverInfo.status = 'connected';
1145
1330
  serverInfo.error = null;
1146
1331
  // Set up keep-alive ping for SSE connections via shared service
1147
- setupClientKeepAlive(serverInfo, expandedConf).catch((e) => console.warn('Keepalive setup failed', {
1148
- serverName: name,
1149
- error: summarizeErrorForLogging(e),
1150
- }));
1332
+ setupServerKeepAlive(serverInfo, expandedConf);
1151
1333
  }
1152
1334
  else {
1153
1335
  serverInfo.status = 'disconnected';
1154
1336
  serverInfo.error = `Failed to list data: ${formatErrorForLogging(dataError)}`;
1337
+ setupServerKeepAlive(serverInfo, expandedConf);
1155
1338
  }
1156
1339
  })
1157
1340
  .catch(async (error) => {
@@ -1176,6 +1359,7 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1176
1359
  // Other connection errors
1177
1360
  serverInfo.status = 'disconnected';
1178
1361
  serverInfo.error = `Failed to connect: ${formatErrorForLogging(error)}`;
1362
+ setupServerKeepAlive(serverInfo, expandedConf);
1179
1363
  }
1180
1364
  });
1181
1365
  console.log(`Initialized client for server: ${name}`);
@@ -1249,6 +1433,7 @@ export const getServersInfo = async (page, limit, user) => {
1249
1433
  const serverConfig = allServers.find((server) => server.name === name);
1250
1434
  const enabled = serverConfig ? serverConfig.enabled !== false : true;
1251
1435
  const resolvedType = inferServerType(serverConfig);
1436
+ const oauthConnected = Boolean(serverConfig?.oauth?.accessToken || serverConfig?.oauth?.refreshToken);
1252
1437
  // Add enabled status and custom description to each tool
1253
1438
  const toolsWithEnabled = tools.map((tool) => {
1254
1439
  const toolConfig = serverConfig?.tools?.[tool.name];
@@ -1275,11 +1460,16 @@ export const getServersInfo = async (page, limit, user) => {
1275
1460
  resources,
1276
1461
  createTime,
1277
1462
  enabled,
1278
- oauth: oauth
1463
+ oauth: oauth || oauthConnected
1279
1464
  ? {
1280
- authorizationUrl: oauth.authorizationUrl,
1281
- state: oauth.state,
1282
- // Don't expose codeVerifier to frontend for security
1465
+ ...(oauth
1466
+ ? {
1467
+ authorizationUrl: oauth.authorizationUrl,
1468
+ state: oauth.state,
1469
+ // Don't expose codeVerifier to frontend for security
1470
+ }
1471
+ : {}),
1472
+ ...(oauthConnected ? { connected: true } : {}),
1283
1473
  }
1284
1474
  : undefined,
1285
1475
  config: resolvedType || serverConfig?.description || serverConfig?.command
@@ -1479,31 +1669,61 @@ function checkAuthError(result) {
1479
1669
  }
1480
1670
  }
1481
1671
  }
1672
+ const closeServerRuntime = (serverInfo) => {
1673
+ if (serverInfo.keepAliveIntervalId) {
1674
+ clearInterval(serverInfo.keepAliveIntervalId);
1675
+ serverInfo.keepAliveIntervalId = undefined;
1676
+ console.log('Cleared MCP server keep-alive interval');
1677
+ }
1678
+ const candidateTransport = serverInfo.transport;
1679
+ const stdioPid = typeof candidateTransport?.pid === 'number' ? candidateTransport.pid : null;
1680
+ if (serverInfo.client) {
1681
+ try {
1682
+ serverInfo.client.close();
1683
+ }
1684
+ catch {
1685
+ console.warn('Error closing MCP client during runtime shutdown');
1686
+ }
1687
+ serverInfo.client = undefined;
1688
+ }
1689
+ if (serverInfo.transport) {
1690
+ try {
1691
+ serverInfo.transport.close();
1692
+ }
1693
+ catch {
1694
+ console.warn('Error closing MCP transport during runtime shutdown');
1695
+ }
1696
+ serverInfo.transport = undefined;
1697
+ }
1698
+ if (stdioPid) {
1699
+ killStdioProcessTree(serverInfo.name, stdioPid);
1700
+ }
1701
+ console.log('Closed MCP server client and transport');
1702
+ };
1482
1703
  // Close server client and transport
1483
1704
  function closeServer(name) {
1484
1705
  const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
1485
- if (serverInfo && serverInfo.client && serverInfo.transport) {
1486
- // Clear keep-alive interval if exists
1487
- if (serverInfo.keepAliveIntervalId) {
1488
- clearInterval(serverInfo.keepAliveIntervalId);
1489
- serverInfo.keepAliveIntervalId = undefined;
1490
- console.log(`Cleared keep-alive interval for server: ${serverInfo.name}`);
1491
- }
1492
- // Capture the child PID via duck-typing. `instanceof StdioClientTransport`
1493
- // is unreliable under pnpm's "dual package hazard" — a different copy of
1494
- // @modelcontextprotocol/sdk in node_modules makes the check return false
1495
- // even for genuine stdio transports. The `pid` getter is the SDK's public
1496
- // contract, so checking for it is both safer and version-agnostic.
1497
- const candidateTransport = serverInfo.transport;
1498
- const stdioPid = typeof candidateTransport.pid === 'number' ? candidateTransport.pid : null;
1499
- serverInfo.client.close();
1500
- serverInfo.transport.close();
1501
- if (stdioPid) {
1502
- killStdioProcessTree(name, stdioPid);
1503
- }
1504
- console.log(`Closed client and transport for server: ${serverInfo.name}`);
1706
+ if (serverInfo) {
1707
+ closeServerRuntime(serverInfo);
1505
1708
  }
1506
1709
  }
1710
+ export const resetServerOAuthConnection = (name) => {
1711
+ const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
1712
+ if (!serverInfo) {
1713
+ return false;
1714
+ }
1715
+ closeServerRuntime(serverInfo);
1716
+ serverInfo.status = 'oauth_required';
1717
+ serverInfo.error = null;
1718
+ serverInfo.tools = [];
1719
+ serverInfo.prompts = [];
1720
+ serverInfo.resources = [];
1721
+ serverInfo.oauth = undefined;
1722
+ broadcastToolListChanged();
1723
+ broadcastPromptListChanged();
1724
+ broadcastResourceListChanged();
1725
+ return true;
1726
+ };
1507
1727
  // Kill the entire process tree of a stdio transport's child process.
1508
1728
  //
1509
1729
  // transport.close() only sends SIGTERM to the direct child. When the server is
@@ -1929,8 +2149,13 @@ export const handleCallToolRequest = async (request, extra) => {
1929
2149
  });
1930
2150
  }
1931
2151
  // Call the tool on the target server (MCP servers)
1932
- const client = targetServerInfo.client;
1933
- if (!client) {
2152
+ // For servers with perSessionClient: true, use a per-session dedicated client
2153
+ let isolatedCtx;
2154
+ if (targetServerInfo.config?.perSessionClient && sessionId) {
2155
+ const isolated = await getOrCreateIsolatedClient(sessionId, targetServerInfo);
2156
+ isolatedCtx = { sessionId, client: isolated.client, transport: isolated.transport };
2157
+ }
2158
+ else if (!targetServerInfo.client) {
1934
2159
  throw new Error(`Client not found for server: ${targetServerInfo.name}`);
1935
2160
  }
1936
2161
  // Use toolArgs if it has properties, otherwise fallback to request.params.arguments
@@ -1939,13 +2164,14 @@ export const handleCallToolRequest = async (request, extra) => {
1939
2164
  toolName: targetToolName,
1940
2165
  serverName: targetServerInfo.name,
1941
2166
  arguments: summarizeArgumentsForLogging(finalArgs),
2167
+ perSessionClient: !!targetServerInfo.config?.perSessionClient,
1942
2168
  });
1943
2169
  const cleanToolName = normalizeToolNameForServer(targetServerInfo.name, targetToolName);
1944
2170
  await reserveHostedIfNeeded(targetServerInfo.name, cleanToolName);
1945
2171
  const result = await callToolWithReconnect(targetServerInfo, {
1946
2172
  name: cleanToolName,
1947
2173
  arguments: finalArgs,
1948
- }, targetServerInfo.options || {});
2174
+ }, targetServerInfo.options || {}, 1, isolatedCtx);
1949
2175
  await settleHostedIfNeeded({
1950
2176
  success: !result.isError,
1951
2177
  requestContent: finalArgs,
@@ -2073,13 +2299,18 @@ export const handleCallToolRequest = async (request, extra) => {
2073
2299
  });
2074
2300
  }
2075
2301
  // Handle MCP servers
2076
- const client = serverInfo.client;
2077
- if (!client) {
2302
+ // For servers with perSessionClient: true, use a per-session dedicated client
2303
+ let isolatedCtx;
2304
+ if (serverInfo.config?.perSessionClient && sessionId) {
2305
+ const isolated = await getOrCreateIsolatedClient(sessionId, serverInfo);
2306
+ isolatedCtx = { sessionId, client: isolated.client, transport: isolated.transport };
2307
+ }
2308
+ else if (!serverInfo.client) {
2078
2309
  throw new Error(`Client not found for server: ${serverInfo.name}`);
2079
2310
  }
2080
2311
  const cleanToolName = normalizeToolNameForServer(serverInfo.name, routeToolName);
2081
2312
  await reserveHostedIfNeeded(serverInfo.name, cleanToolName);
2082
- const result = await callToolWithReconnect(serverInfo, { ...request.params, name: cleanToolName }, serverInfo.options || {});
2313
+ const result = await callToolWithReconnect(serverInfo, { ...request.params, name: cleanToolName }, serverInfo.options || {}, 1, isolatedCtx);
2083
2314
  await settleHostedIfNeeded({
2084
2315
  success: !result.isError,
2085
2316
  requestContent: request.params.arguments,