@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.
- package/README.fr.md +9 -0
- package/README.md +9 -0
- package/README.zh.md +9 -0
- package/dist/betterAuth.js +2 -1
- package/dist/betterAuth.js.map +1 -1
- package/dist/controllers/oauthCallbackController.js +51 -25
- package/dist/controllers/oauthCallbackController.js.map +1 -1
- package/dist/controllers/oauthDynamicRegistrationController.js +2 -1
- package/dist/controllers/oauthDynamicRegistrationController.js.map +1 -1
- package/dist/controllers/oauthServerController.js +3 -2
- package/dist/controllers/oauthServerController.js.map +1 -1
- package/dist/controllers/serverController.js +52 -2
- 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/entities/Server.js +4 -0
- package/dist/db/entities/Server.js.map +1 -1
- package/dist/middlewares/i18n.js +11 -12
- package/dist/middlewares/i18n.js.map +1 -1
- package/dist/models/User.js +18 -8
- package/dist/models/User.js.map +1 -1
- package/dist/routes/index.js +2 -1
- package/dist/routes/index.js.map +1 -1
- package/dist/services/betterAuthConfig.js +2 -1
- package/dist/services/betterAuthConfig.js.map +1 -1
- package/dist/services/keepAliveService.js +35 -6
- package/dist/services/keepAliveService.js.map +1 -1
- package/dist/services/mcpOAuthProvider.js +2 -1
- package/dist/services/mcpOAuthProvider.js.map +1 -1
- package/dist/services/mcpService.js +297 -66
- package/dist/services/mcpService.js.map +1 -1
- package/dist/services/oauthClientRegistration.js +3 -1
- 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/openApiGeneratorService.js +2 -1
- package/dist/services/openApiGeneratorService.js.map +1 -1
- package/dist/services/upstreamOAuthDisconnectService.js +160 -0
- package/dist/services/upstreamOAuthDisconnectService.js.map +1 -0
- package/dist/utils/i18n.js +48 -7
- package/dist/utils/i18n.js.map +1 -1
- package/dist/utils/installBaseUrl.js +24 -0
- package/dist/utils/installBaseUrl.js.map +1 -0
- package/dist/utils/migration.js +1 -0
- package/dist/utils/migration.js.map +1 -1
- package/dist/utils/path.js +24 -17
- package/dist/utils/path.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-a7iUIWgo.js → ActivityPage-ClYamLqW.js} +2 -2
- package/frontend/dist/assets/{ActivityPage-a7iUIWgo.js.map → ActivityPage-ClYamLqW.js.map} +1 -1
- package/frontend/dist/assets/{Dashboard-BymSNeX3.js → Dashboard-XMPV4Ypu.js} +2 -2
- package/frontend/dist/assets/{Dashboard-BymSNeX3.js.map → Dashboard-XMPV4Ypu.js.map} +1 -1
- package/frontend/dist/assets/{EndpointCopy-BpKGkoXE.js → EndpointCopy-Ch7HbIsp.js} +2 -2
- package/frontend/dist/assets/{EndpointCopy-BpKGkoXE.js.map → EndpointCopy-Ch7HbIsp.js.map} +1 -1
- package/frontend/dist/assets/{GroupsPage-CUDeiRrR.js → GroupsPage-DkUg6Xih.js} +2 -2
- package/frontend/dist/assets/GroupsPage-DkUg6Xih.js.map +1 -0
- package/frontend/dist/assets/{LoginPage-C0b1KVcS.js → LoginPage-Chm4p59K.js} +2 -2
- package/frontend/dist/assets/{LoginPage-C0b1KVcS.js.map → LoginPage-Chm4p59K.js.map} +1 -1
- package/frontend/dist/assets/{LogsPage-ndVl0S7X.js → LogsPage-D0QA2y2j.js} +2 -2
- package/frontend/dist/assets/{LogsPage-ndVl0S7X.js.map → LogsPage-D0QA2y2j.js.map} +1 -1
- package/frontend/dist/assets/{MarketPage-LS2z8vcu.js → MarketPage-CU9wn6vM.js} +2 -2
- package/frontend/dist/assets/{MarketPage-LS2z8vcu.js.map → MarketPage-CU9wn6vM.js.map} +1 -1
- package/frontend/dist/assets/{PromptsPage-0MAP2tLv.js → PromptsPage-Iht1gTW7.js} +2 -2
- package/frontend/dist/assets/{PromptsPage-0MAP2tLv.js.map → PromptsPage-Iht1gTW7.js.map} +1 -1
- package/frontend/dist/assets/{ResourcesPage-BXjmP5FT.js → ResourcesPage-DsOhIF8m.js} +2 -2
- package/frontend/dist/assets/{ResourcesPage-BXjmP5FT.js.map → ResourcesPage-DsOhIF8m.js.map} +1 -1
- package/frontend/dist/assets/ServersPage-Dq_yBsZM.js +37 -0
- package/frontend/dist/assets/ServersPage-Dq_yBsZM.js.map +1 -0
- package/frontend/dist/assets/{SettingsPage-BGHo0M9q.js → SettingsPage-DoHjlHsl.js} +2 -2
- package/frontend/dist/assets/{SettingsPage-BGHo0M9q.js.map → SettingsPage-DoHjlHsl.js.map} +1 -1
- package/frontend/dist/assets/{StatusDot-CMGPqJqj.js → StatusDot-BF-uWsCA.js} +2 -2
- package/frontend/dist/assets/{StatusDot-CMGPqJqj.js.map → StatusDot-BF-uWsCA.js.map} +1 -1
- package/frontend/dist/assets/{ToggleGroup-C2cwcIkv.js → ToggleGroup-DMC5NEP5.js} +2 -2
- package/frontend/dist/assets/{ToggleGroup-C2cwcIkv.js.map → ToggleGroup-DMC5NEP5.js.map} +1 -1
- package/frontend/dist/assets/{UsersPage-eX3oMsvb.js → UsersPage-BA9TJs2F.js} +2 -2
- package/frontend/dist/assets/{UsersPage-eX3oMsvb.js.map → UsersPage-BA9TJs2F.js.map} +1 -1
- package/frontend/dist/assets/{contextCost-BCKU_3Kc.js → contextCost-KhOeuF-d.js} +2 -2
- package/frontend/dist/assets/{contextCost-BCKU_3Kc.js.map → contextCost-KhOeuF-d.js.map} +1 -1
- package/frontend/dist/assets/{index-BlTPJflb.css → index-BfEBXfi0.css} +1 -1
- package/frontend/dist/assets/index-xwxOTCc_.js +3 -0
- package/frontend/dist/assets/index-xwxOTCc_.js.map +1 -0
- package/frontend/dist/assets/{resourceService-CVAZJPEy.js → resourceService-OokeUb4C.js} +2 -2
- package/frontend/dist/assets/{resourceService-CVAZJPEy.js.map → resourceService-OokeUb4C.js.map} +1 -1
- package/frontend/dist/assets/useSettingsData-DegWU8bg.js +2 -0
- package/frontend/dist/assets/{useSettingsData-BdEnzN5x.js.map → useSettingsData-DegWU8bg.js.map} +1 -1
- package/frontend/dist/assets/variableDetection-D6KIhNDI.js +16 -0
- package/frontend/dist/assets/variableDetection-D6KIhNDI.js.map +1 -0
- package/frontend/dist/index.html +2 -2
- package/mcp_settings.json +2 -8
- package/package.json +3 -3
- package/frontend/dist/assets/GroupsPage-CUDeiRrR.js.map +0 -1
- package/frontend/dist/assets/ServersPage-B_Ow-YRR.js +0 -37
- package/frontend/dist/assets/ServersPage-B_Ow-YRR.js.map +0 -1
- package/frontend/dist/assets/index-hse3fHTq.js +0 -3
- package/frontend/dist/assets/index-hse3fHTq.js.map +0 -1
- package/frontend/dist/assets/useSettingsData-BdEnzN5x.js +0 -2
- package/frontend/dist/assets/variableDetection-B7PPrIlN.js +0 -16
- 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
|
-
|
|
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
|
|
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 =
|
|
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})`);
|
|
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
|
-
|
|
844
|
-
const client = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
|
|
972
|
+
const newClient = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
|
|
845
973
|
// Reconnect with new transport
|
|
846
|
-
await
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
|
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
|
-
|
|
873
|
-
|
|
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) —
|
|
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 ||
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
|
1486
|
-
|
|
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
|
-
|
|
1933
|
-
|
|
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
|
-
|
|
2077
|
-
|
|
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,
|