@samanhappy/mcphub 1.1.9 → 1.1.11

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 (113) hide show
  1. package/dist/clients/legacyMcpClient.js +10 -2
  2. package/dist/clients/legacyMcpClient.js.map +1 -1
  3. package/dist/controllers/groupController.js +19 -0
  4. package/dist/controllers/groupController.js.map +1 -1
  5. package/dist/controllers/serverController.js +2 -0
  6. package/dist/controllers/serverController.js.map +1 -1
  7. package/dist/dao/CapabilitySnapshotDao.js +38 -0
  8. package/dist/dao/CapabilitySnapshotDao.js.map +1 -0
  9. package/dist/dao/CapabilitySnapshotDaoDbImpl.js +18 -0
  10. package/dist/dao/CapabilitySnapshotDaoDbImpl.js.map +1 -0
  11. package/dist/dao/DaoFactory.js +7 -0
  12. package/dist/dao/DaoFactory.js.map +1 -1
  13. package/dist/dao/DatabaseDaoFactory.js +4 -0
  14. package/dist/dao/DatabaseDaoFactory.js.map +1 -1
  15. package/dist/dao/ServerDaoDbImpl.js +3 -0
  16. package/dist/dao/ServerDaoDbImpl.js.map +1 -1
  17. package/dist/db/entities/CapabilitySnapshot.js +37 -0
  18. package/dist/db/entities/CapabilitySnapshot.js.map +1 -0
  19. package/dist/db/entities/Group.js.map +1 -1
  20. package/dist/db/entities/Server.js +4 -0
  21. package/dist/db/entities/Server.js.map +1 -1
  22. package/dist/db/entities/index.js +3 -1
  23. package/dist/db/entities/index.js.map +1 -1
  24. package/dist/services/capabilitySnapshotService.js +106 -0
  25. package/dist/services/capabilitySnapshotService.js.map +1 -0
  26. package/dist/services/groupService.js +1 -0
  27. package/dist/services/groupService.js.map +1 -1
  28. package/dist/services/keepAliveService.js +52 -36
  29. package/dist/services/keepAliveService.js.map +1 -1
  30. package/dist/services/mcpOAuthProvider.js +19 -2
  31. package/dist/services/mcpOAuthProvider.js.map +1 -1
  32. package/dist/services/mcpService.js +408 -103
  33. package/dist/services/mcpService.js.map +1 -1
  34. package/dist/services/oauthClientRegistration.js +2 -1
  35. package/dist/services/oauthClientRegistration.js.map +1 -1
  36. package/dist/services/principalRuntimeService.js +27 -3
  37. package/dist/services/principalRuntimeService.js.map +1 -1
  38. package/dist/services/smartRoutingService.js +19 -5
  39. package/dist/services/smartRoutingService.js.map +1 -1
  40. package/dist/services/templateService.js +5 -0
  41. package/dist/services/templateService.js.map +1 -1
  42. package/dist/services/upstreamListRefresh.js +37 -0
  43. package/dist/services/upstreamListRefresh.js.map +1 -0
  44. package/dist/utils/groupToolNames.js +21 -0
  45. package/dist/utils/groupToolNames.js.map +1 -0
  46. package/dist/utils/mcpProtocol.js +14 -1
  47. package/dist/utils/mcpProtocol.js.map +1 -1
  48. package/dist/utils/migration.js +1 -0
  49. package/dist/utils/migration.js.map +1 -1
  50. package/dist/utils/oauthServerMetadata.js +23 -0
  51. package/dist/utils/oauthServerMetadata.js.map +1 -0
  52. package/dist/utils/parameterConversion.js +9 -2
  53. package/dist/utils/parameterConversion.js.map +1 -1
  54. package/dist/utils/serverConfigPersistence.js +11 -0
  55. package/dist/utils/serverConfigPersistence.js.map +1 -1
  56. package/dist/utils/settingsSync.js +2 -0
  57. package/dist/utils/settingsSync.js.map +1 -1
  58. package/frontend/dist/assets/{ActivityPage-CFRX20v4.js → ActivityPage-D8nsR_f_.js} +2 -2
  59. package/frontend/dist/assets/{ActivityPage-CFRX20v4.js.map → ActivityPage-D8nsR_f_.js.map} +1 -1
  60. package/frontend/dist/assets/{CopyClientConfigDialog-CDEiSInj.js → CopyClientConfigDialog-DgHrZUv_.js} +2 -2
  61. package/frontend/dist/assets/{CopyClientConfigDialog-CDEiSInj.js.map → CopyClientConfigDialog-DgHrZUv_.js.map} +1 -1
  62. package/frontend/dist/assets/{CredentialsPage-BCLHY_fN.js → CredentialsPage-B_-H2SKV.js} +2 -2
  63. package/frontend/dist/assets/{CredentialsPage-BCLHY_fN.js.map → CredentialsPage-B_-H2SKV.js.map} +1 -1
  64. package/frontend/dist/assets/{Dashboard-B3Y6nAFL.js → Dashboard-Bhl6u2n-.js} +2 -2
  65. package/frontend/dist/assets/{Dashboard-B3Y6nAFL.js.map → Dashboard-Bhl6u2n-.js.map} +1 -1
  66. package/frontend/dist/assets/{EndpointCopy-BMxT99Ne.js → EndpointCopy-Cbm48MAT.js} +2 -2
  67. package/frontend/dist/assets/{EndpointCopy-BMxT99Ne.js.map → EndpointCopy-Cbm48MAT.js.map} +1 -1
  68. package/frontend/dist/assets/GroupsPage-DhBTIL-O.js +33 -0
  69. package/frontend/dist/assets/GroupsPage-DhBTIL-O.js.map +1 -0
  70. package/frontend/dist/assets/{LoginPage-BjYzY6RH.js → LoginPage-DuyaaEX3.js} +2 -2
  71. package/frontend/dist/assets/{LoginPage-BjYzY6RH.js.map → LoginPage-DuyaaEX3.js.map} +1 -1
  72. package/frontend/dist/assets/{LogsPage-CGcFNKJn.js → LogsPage-BtYlQ3Fg.js} +2 -2
  73. package/frontend/dist/assets/{LogsPage-CGcFNKJn.js.map → LogsPage-BtYlQ3Fg.js.map} +1 -1
  74. package/frontend/dist/assets/{MarketPage-B7JPqFw3.js → MarketPage-QNT3w149.js} +2 -2
  75. package/frontend/dist/assets/{MarketPage-B7JPqFw3.js.map → MarketPage-QNT3w149.js.map} +1 -1
  76. package/frontend/dist/assets/{OAuthConsentPage-BYi_kgNj.js → OAuthConsentPage-fjVkCE13.js} +2 -2
  77. package/frontend/dist/assets/{OAuthConsentPage-BYi_kgNj.js.map → OAuthConsentPage-fjVkCE13.js.map} +1 -1
  78. package/frontend/dist/assets/{PromptsPage-x6X5trLX.js → PromptsPage-CWQEgxAH.js} +2 -2
  79. package/frontend/dist/assets/{PromptsPage-x6X5trLX.js.map → PromptsPage-CWQEgxAH.js.map} +1 -1
  80. package/frontend/dist/assets/{ResourcesPage-BJZCPBs8.js → ResourcesPage-Dw3p1r3S.js} +2 -2
  81. package/frontend/dist/assets/{ResourcesPage-BJZCPBs8.js.map → ResourcesPage-Dw3p1r3S.js.map} +1 -1
  82. package/frontend/dist/assets/ServersPage-C9F8T1eo.js +39 -0
  83. package/frontend/dist/assets/ServersPage-C9F8T1eo.js.map +1 -0
  84. package/frontend/dist/assets/{SettingsPage-C53DI0Sz.js → SettingsPage-DGLThCbs.js} +2 -2
  85. package/frontend/dist/assets/{SettingsPage-C53DI0Sz.js.map → SettingsPage-DGLThCbs.js.map} +1 -1
  86. package/frontend/dist/assets/{StatusDot-BDJd3Iix.js → StatusDot-L_s7TahY.js} +2 -2
  87. package/frontend/dist/assets/{StatusDot-BDJd3Iix.js.map → StatusDot-L_s7TahY.js.map} +1 -1
  88. package/frontend/dist/assets/{ToggleGroup-BbmRhILg.js → ToggleGroup-BYsHpk5D.js} +2 -2
  89. package/frontend/dist/assets/{ToggleGroup-BbmRhILg.js.map → ToggleGroup-BYsHpk5D.js.map} +1 -1
  90. package/frontend/dist/assets/{UsersPage-NUG6vF8C.js → UsersPage-uH8VPdD5.js} +2 -2
  91. package/frontend/dist/assets/{UsersPage-NUG6vF8C.js.map → UsersPage-uH8VPdD5.js.map} +1 -1
  92. package/frontend/dist/assets/{clipboard-7BggsilD.js → clipboard-7o0A3Yh4.js} +2 -2
  93. package/frontend/dist/assets/{clipboard-7BggsilD.js.map → clipboard-7o0A3Yh4.js.map} +1 -1
  94. package/frontend/dist/assets/index-Bflb_BXR.js +3 -0
  95. package/frontend/dist/assets/index-Bflb_BXR.js.map +1 -0
  96. package/frontend/dist/assets/index-JDk4RjZI.css +1 -0
  97. package/frontend/dist/assets/{resourceService-B674Fopr.js → resourceService-0jFnNuzj.js} +2 -2
  98. package/frontend/dist/assets/{resourceService-B674Fopr.js.map → resourceService-0jFnNuzj.js.map} +1 -1
  99. package/frontend/dist/assets/{useCostData-8Z1HjAL5.js → useCostData-BrYWvkNn.js} +2 -2
  100. package/frontend/dist/assets/{useCostData-8Z1HjAL5.js.map → useCostData-BrYWvkNn.js.map} +1 -1
  101. package/frontend/dist/assets/variableDetection-D0qCtrcY.js +18 -0
  102. package/frontend/dist/assets/variableDetection-D0qCtrcY.js.map +1 -0
  103. package/frontend/dist/index.html +2 -2
  104. package/package.json +1 -1
  105. package/frontend/dist/assets/GroupsPage-5iCk1LKT.js +0 -33
  106. package/frontend/dist/assets/GroupsPage-5iCk1LKT.js.map +0 -1
  107. package/frontend/dist/assets/ServersPage-CsyGLden.js +0 -39
  108. package/frontend/dist/assets/ServersPage-CsyGLden.js.map +0 -1
  109. package/frontend/dist/assets/index-B_IITM9e.js +0 -3
  110. package/frontend/dist/assets/index-B_IITM9e.js.map +0 -1
  111. package/frontend/dist/assets/index-CnqLKewL.css +0 -1
  112. package/frontend/dist/assets/variableDetection-C0nP3Jj2.js +0 -18
  113. package/frontend/dist/assets/variableDetection-C0nP3Jj2.js.map +0 -1
@@ -1,18 +1,21 @@
1
+ import { projectGroupToolName, assertValidGroupToolNames } from '../utils/groupToolNames.js';
1
2
  import { isSafePassthroughHeader } from '../utils/passthroughHeaders.js';
2
3
  import { observeToolCall, setMetricsServer } from './metricsService.js';
3
4
  import { hasPersonalOAuth, PersonalOAuthContext, personalOAuthContexts, personalAuthorizationRequired, } from './personalOAuthService.js';
4
5
  import { preserveListFreshness, combineListFreshness, remainingListTtl, } from '../utils/listFreshness.js';
5
6
  import { expandServerConfig, getServerEnvironment } from './serverConfigEnvironment.js';
6
7
  import { getMcpRequestGroup } from '../utils/mcpRequestGroup.js';
8
+ import { revalidateUpstreamList } from './upstreamListRefresh.js';
7
9
  import { LegacyMcpClient } from '../clients/legacyMcpClient.js';
8
10
  import { getElicitationCapabilities, installElicitationRelay, isElicitationClientClosed, withElicitation, } from './elicitationService.js';
9
- import { LEGACY_PROTOCOL_VERSIONS } from '../utils/mcpProtocol.js';
11
+ import { LEGACY_PROTOCOL_VERSIONS, resolveUpstreamProtocolMode } from '../utils/mcpProtocol.js';
10
12
  import { AsyncLocalStorage } from 'node:async_hooks';
11
13
  import { createHash } from 'node:crypto';
12
14
  import { canAccessGroupRoute } from '../utils/groupAccess.js';
13
15
  import { canServeToolRequests } from '../utils/serverAvailability.js';
14
16
  import { assertToolCallArgumentsSafe, isToolCallGuardEnabled, ToolCallBlockedError, } from '../middlewares/toolCallGuard.js';
15
17
  import { applyHiddenParams, findToolConfig, removeHiddenArguments, } from '../utils/toolConfig.js';
18
+ import { prepareCapabilitySnapshot, recordCapabilitySnapshot, flushCapabilitySnapshot, } from './capabilitySnapshotService.js';
16
19
  import { PrincipalRuntimeService } from './principalRuntimeService.js';
17
20
  import { UserContextService } from './userContextService.js';
18
21
  import { deleteCredentialBindings, missingCredentialError } from './credentialBindingService.js';
@@ -389,7 +392,7 @@ const getOrCreateIsolatedClient = async (sessionId, serverInfo, downstream) => {
389
392
  }
390
393
  const reservedClients = sessionIsolatedClients.get(sessionId);
391
394
  const transport = await createTransportFromConfig(serverInfo.name, serverConfig);
392
- const client = createUpstreamMcpClient(serverInfo.name, () => serverInfo, downstream);
395
+ const client = createUpstreamMcpClient(serverInfo.name, () => serverInfo, serverConfig, downstream);
393
396
  try {
394
397
  await connectClientWithDiagnostics(client, transport, serverInfo.options || {});
395
398
  }
@@ -723,6 +726,8 @@ const createConnectGate = (limit) => {
723
726
  // result anywhere. See the `liveServerInfo` helper in that function.
724
727
  let initGeneration = 0;
725
728
  const principalServerScope = new AsyncLocalStorage();
729
+ // Header-dependent discovery must never publish descriptors or embeddings globally.
730
+ const requestDiscoveryInfos = new WeakSet();
726
731
  const getVisibleServerInfos = () => {
727
732
  return getDataService().filterData(principalServerScope.getStore() ?? serverInfos);
728
733
  };
@@ -825,6 +830,8 @@ export const setServerInfosForTest = (infos) => {
825
830
  serverInfos = infos;
826
831
  };
827
832
  export const updateServerToolsCache = (serverInfo, tools, options) => {
833
+ if (!options?.restored)
834
+ recordCapabilitySnapshot(serverInfo, tools);
828
835
  serverInfo.tools = preserveListFreshness(tools, tools.map((tool) => normalizeToolForCache(serverInfo.name, tool)));
829
836
  syncToolsAsVectorEmbeddings(serverInfo.name, serverInfo.tools, {
830
837
  reportProgress: options?.reportEmbeddingProgress === true,
@@ -838,15 +845,81 @@ const updateServerPromptsCache = (serverInfo, prompts) => {
838
845
  const updateServerResourcesCache = (serverInfo, resources) => {
839
846
  serverInfo.resources = preserveListFreshness(resources, resources.map(normalizeResourceForCache));
840
847
  };
848
+ // Modern HTTP has no list-changed push stream. Refresh only the lists needed
849
+ // by the current request; TTL and in-flight work are scoped to the live client.
850
+ const refreshModernServerLists = async (info, fields) => {
851
+ const client = info.client;
852
+ if (!client ||
853
+ client.getProtocolEra?.() !== 'modern' ||
854
+ info.enabled === false ||
855
+ info.status !== 'connected')
856
+ return;
857
+ const capabilities = client.getServerCapabilities();
858
+ const current = () => info.client === client && info.enabled !== false && info.status === 'connected';
859
+ await Promise.all(fields.map(async (field) => {
860
+ if (!capabilities?.[field])
861
+ return;
862
+ await revalidateUpstreamList(client, field, async () => {
863
+ if (field === 'tools') {
864
+ const { tools } = await client.listTools({}, info.options || {});
865
+ if (!current())
866
+ return;
867
+ if (hasPersonalAuthentication(info.config) || requestDiscoveryInfos.has(info)) {
868
+ info.tools = preserveListFreshness(tools, tools.map((tool) => normalizeToolForCache(info.name, tool)));
869
+ if (!requestDiscoveryInfos.has(info))
870
+ recordCapabilitySnapshot(info, tools);
871
+ if (!requestDiscoveryInfos.has(info))
872
+ void syncCredentialServerToolEmbeddings(info.name, info.tools, {
873
+ partial: true,
874
+ }).catch(() => {
875
+ logger.warn('[EMBED_SYNC_ERROR] Failed to sync personal tool embeddings');
876
+ });
877
+ }
878
+ else {
879
+ const previous = JSON.stringify(info.tools);
880
+ updateServerToolsCache(info, tools);
881
+ if (previous !== JSON.stringify(info.tools))
882
+ broadcastToolListChanged();
883
+ }
884
+ }
885
+ else if (field === 'prompts') {
886
+ const { prompts } = await client.listPrompts({}, info.options || {});
887
+ if (current()) {
888
+ const previous = JSON.stringify(info.prompts);
889
+ updateServerPromptsCache(info, prompts);
890
+ if (!hasPersonalAuthentication(info.config) &&
891
+ !requestDiscoveryInfos.has(info) &&
892
+ previous !== JSON.stringify(info.prompts))
893
+ broadcastPromptListChanged();
894
+ }
895
+ }
896
+ else {
897
+ const { resources } = await client.listResources({}, info.options || {});
898
+ if (current()) {
899
+ const previous = JSON.stringify(info.resources);
900
+ updateServerResourcesCache(info, resources);
901
+ if (!hasPersonalAuthentication(info.config) &&
902
+ !requestDiscoveryInfos.has(info) &&
903
+ previous !== JSON.stringify(info.resources))
904
+ broadcastResourceListChanged();
905
+ }
906
+ }
907
+ });
908
+ }));
909
+ };
841
910
  const logListChangedRefreshError = (listType) => {
842
911
  logger.warn(`Failed to refresh ${listType} list after upstream notification`);
843
912
  };
844
- const createUpstreamMcpClient = (name, getServerInfo, downstream) => {
913
+ const createUpstreamMcpClient = (name, getServerInfo, serverConfig, downstream) => {
845
914
  const elicitation = getElicitationCapabilities(downstream);
915
+ // The existing relay only supports legacy server-to-client requests. Keep
916
+ // elicitation-enabled isolated clients legacy until an input_required bridge exists.
917
+ const mode = resolveUpstreamProtocolMode(serverConfig, Boolean(elicitation));
846
918
  const client = new LegacyMcpClient({
847
919
  name: `mcp-client-${name}`,
848
920
  version: '1.0.0',
849
921
  }, {
922
+ versionNegotiation: { mode },
850
923
  capabilities: { ...MCP_APPS_CAPABILITIES, ...(elicitation ? { elicitation } : {}) },
851
924
  jsonSchemaValidator: new ResilientJsonSchemaValidator(),
852
925
  listChanged: {
@@ -958,19 +1031,8 @@ export const cleanupAllServers = () => {
958
1031
  for (const id of clientStates.keys())
959
1032
  removeClientState(id);
960
1033
  principalRuntimes.invalidate();
961
- for (const serverInfo of serverInfos) {
962
- try {
963
- if (serverInfo.client) {
964
- serverInfo.client.close();
965
- }
966
- if (serverInfo.transport) {
967
- serverInfo.transport.close();
968
- }
969
- }
970
- catch (error) {
971
- logger.warn('Error closing server', { serverName: serverInfo.name, error });
972
- }
973
- }
1034
+ for (const serverInfo of serverInfos)
1035
+ closeServerRuntime(serverInfo);
974
1036
  serverInfos = [];
975
1037
  // Drain all per-session isolated upstream clients (perSessionClient: true),
976
1038
  for (const sessionId of [...sessionIsolatedClients.keys()]) {
@@ -1374,6 +1436,38 @@ const withoutHiddenArguments = (serverInfo, toolName, args) => {
1374
1436
  const toolConfig = findToolConfig(serverInfo.config?.tools, serverInfo.name, runtimeName, (serverInfo.tools ?? []).map((candidate) => candidate.name));
1375
1437
  return removeHiddenArguments(args, tool?.inputSchema, toolConfig);
1376
1438
  };
1439
+ const failedCallRefreshes = new WeakMap();
1440
+ const refreshCapabilitiesAfterFailedCall = async (info) => {
1441
+ if (!info.capabilityCache || !info.client || info.status !== 'connected')
1442
+ return;
1443
+ const pending = failedCallRefreshes.get(info);
1444
+ if (pending)
1445
+ return pending;
1446
+ const refresh = (async () => {
1447
+ try {
1448
+ const { tools } = await info.client.listTools({}, info.options || {});
1449
+ if (hasPersonalAuthentication(info.config)) {
1450
+ info.tools = tools.map((tool) => normalizeToolForCache(info.name, tool));
1451
+ recordCapabilitySnapshot(info, tools);
1452
+ }
1453
+ else {
1454
+ updateServerToolsCache(info, tools);
1455
+ broadcastToolListChanged();
1456
+ }
1457
+ await flushCapabilitySnapshot(info);
1458
+ }
1459
+ catch {
1460
+ logger.warn('Unable to refresh capability snapshot after failed call');
1461
+ }
1462
+ })();
1463
+ failedCallRefreshes.set(info, refresh);
1464
+ try {
1465
+ await refresh;
1466
+ }
1467
+ finally {
1468
+ failedCallRefreshes.delete(info);
1469
+ }
1470
+ };
1377
1471
  // Helper function to handle client.callTool with reconnection logic
1378
1472
  const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries = 1, isolated, extra) => {
1379
1473
  // Local, reassignable refs so a reconnect can swap in the new connection for
@@ -1390,19 +1484,37 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1390
1484
  arguments: withoutHiddenArguments(serverInfo, toolParams.name, toolParams.arguments),
1391
1485
  };
1392
1486
  if (hasPersonalAuthentication(serverInfo.config)) {
1393
- return client.callTool(toolParams, options || {});
1487
+ try {
1488
+ const result = await client.callTool(toolParams, options || {});
1489
+ if (result.isError)
1490
+ await refreshCapabilitiesAfterFailedCall(serverInfo);
1491
+ return result;
1492
+ }
1493
+ catch (error) {
1494
+ await refreshCapabilitiesAfterFailedCall(serverInfo);
1495
+ throw error;
1496
+ }
1394
1497
  }
1395
1498
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1396
1499
  try {
1397
1500
  const currentClient = client;
1398
1501
  const result = await withElicitation(currentClient, extra?.mcpReq, options || {}, async (callOptions) => {
1399
- const result = await currentClient.callTool(toolParams, callOptions);
1502
+ // Isolated clients do not own the refreshed discovery client's tool definitions.
1503
+ const toolDefinition = isolated && currentClient.getProtocolEra?.() === 'modern'
1504
+ ? serverInfo.tools.find((tool) => normalizeToolNameForServer(serverInfo.name, tool.name) === toolParams.name)
1505
+ : undefined;
1506
+ const result = await currentClient.callTool(toolParams, toolDefinition
1507
+ ? { ...callOptions, toolDefinition: toolDefinition }
1508
+ : callOptions);
1400
1509
  checkAuthError(result);
1401
1510
  return result;
1402
1511
  });
1512
+ if (result.isError)
1513
+ await refreshCapabilitiesAfterFailedCall(serverInfo);
1403
1514
  return result;
1404
1515
  }
1405
1516
  catch (error) {
1517
+ await refreshCapabilitiesAfterFailedCall(serverInfo);
1406
1518
  if (isElicitationClientClosed(client)) {
1407
1519
  closeIsolatedClient(serverInfo.name, client, transport);
1408
1520
  throw error;
@@ -1427,7 +1539,7 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1427
1539
  // Match initial connection expansion before URL validation and transport creation.
1428
1540
  const server = await expandServerConfig(rawConfig);
1429
1541
  const newTransport = await createTransportFromConfig(serverInfo.name, server);
1430
- const newClient = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
1542
+ const newClient = createUpstreamMcpClient(serverInfo.name, () => serverInfo, server);
1431
1543
  // Reconnect with new transport
1432
1544
  await connectClientWithDiagnostics(newClient, newTransport, serverInfo.options || {});
1433
1545
  if (isolated) {
@@ -1455,6 +1567,8 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1455
1567
  serverInfo.keepAliveIntervalId = undefined;
1456
1568
  }
1457
1569
  try {
1570
+ if (serverInfo.client)
1571
+ serverInfo.client.onclose = undefined;
1458
1572
  serverInfo.client?.close();
1459
1573
  }
1460
1574
  catch {
@@ -1498,6 +1612,8 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1498
1612
  if (!isolated) {
1499
1613
  serverInfo.status = 'disconnected';
1500
1614
  serverInfo.error = `Failed to reconnect: ${formatErrorForLogging(reconnectError)}`;
1615
+ if (serverInfo.config)
1616
+ setupServerKeepAlive(serverInfo, serverInfo.config);
1501
1617
  }
1502
1618
  // If this was the last attempt, throw the original error
1503
1619
  if (attempt === maxRetries) {
@@ -1506,7 +1622,17 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1506
1622
  }
1507
1623
  }
1508
1624
  else {
1509
- // Not an HTTP 40x error or no more retries, throw the original error
1625
+ const status = error?.status ?? error?.response?.status ?? error?.code;
1626
+ const connectionFailed = isUpstreamConnectionFailure(error) ||
1627
+ isUpstreamConnectionFailure(error?.cause) ||
1628
+ (error instanceof TypeError && error.message === 'fetch failed') ||
1629
+ (typeof status === 'number' && status >= 500 && status < 600);
1630
+ if (!isolated && (isStreamableHttp || isSSE) && connectionFailed) {
1631
+ serverInfo.status = 'disconnected';
1632
+ serverInfo.error = formatErrorForLogging(error);
1633
+ if (serverInfo.config)
1634
+ setupServerKeepAlive(serverInfo, serverInfo.config);
1635
+ }
1510
1636
  throw error;
1511
1637
  }
1512
1638
  }
@@ -1515,16 +1641,22 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1515
1641
  throw new Error('Unexpected error in callToolWithReconnect');
1516
1642
  };
1517
1643
  const setupServerKeepAlive = (serverInfo, serverConfig) => {
1644
+ const client = serverInfo.client;
1645
+ if (client &&
1646
+ serverInfo.status === 'connected' &&
1647
+ (serverInfo.transport instanceof SSEClientTransport ||
1648
+ serverInfo.transport instanceof StreamableHTTPClientTransport)) {
1649
+ client.onclose = () => {
1650
+ const current = getServerByName(serverInfo.name);
1651
+ if (current?.client !== client || current.status !== 'connected')
1652
+ return;
1653
+ current.status = 'disconnected';
1654
+ current.error = 'Upstream connection closed';
1655
+ setupServerKeepAlive(current, serverConfig);
1656
+ };
1657
+ }
1518
1658
  setupClientKeepAlive(serverInfo, serverConfig, {
1519
- reconnectServer: async (serverName) => {
1520
- try {
1521
- await reconnectServer(serverName);
1522
- }
1523
- catch (error) {
1524
- setupServerKeepAlive(serverInfo, serverConfig);
1525
- throw error;
1526
- }
1527
- },
1659
+ reconnectServer,
1528
1660
  }).catch((error) => logger.warn('Keepalive setup failed', {
1529
1661
  serverName: serverInfo.name,
1530
1662
  error: summarizeErrorForLogging(error),
@@ -1602,6 +1734,7 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1602
1734
  // connection) so the tool list stays visible to agents even while sleeping.
1603
1735
  if (expandedConf.startOnDemand === true && isStdioServer(expandedConf)) {
1604
1736
  const existingOnDemand = existingServerInfos.find((s) => s.name === name);
1737
+ const configChanged = JSON.stringify(existingOnDemand?.config) !== JSON.stringify(expandedConf);
1605
1738
  // Active calls, spawn promises and idle callbacks must share the same
1606
1739
  // runtime object across reloads, not copies of its mutable state.
1607
1740
  const onDemandInfo = existingOnDemand ?? {
@@ -1620,6 +1753,14 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1620
1753
  enabled: true,
1621
1754
  config: expandedConf,
1622
1755
  });
1756
+ const cachedTools = await prepareCapabilitySnapshot(onDemandInfo, expandedConf);
1757
+ if (cachedTools !== undefined) {
1758
+ if (!existingOnDemand || configChanged || onDemandInfo.tools.length === 0)
1759
+ updateServerToolsCache(onDemandInfo, cachedTools, { restored: true });
1760
+ }
1761
+ else if (configChanged || onDemandInfo.capabilityCache?.state === 'stale') {
1762
+ onDemandInfo.tools = [];
1763
+ }
1623
1764
  nextServerInfos.push(onDemandInfo);
1624
1765
  logger.log(`Skipping startup connect for on-demand server: ${name}`);
1625
1766
  continue;
@@ -1641,18 +1782,25 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1641
1782
  if (existingServer &&
1642
1783
  (isDifferentServer ||
1643
1784
  (!serverName && (existingServer.status === 'connected' || hasInflightOAuthAuthorization)))) {
1644
- nextServerInfos.push({
1645
- ...existingServer,
1785
+ // Timer callbacks must keep owning the published runtime entry.
1786
+ Object.assign(existingServer, {
1646
1787
  owner: expandedConf.owner,
1647
1788
  visibility: expandedConf.visibility,
1648
1789
  sharedWithUsers: expandedConf.sharedWithUsers,
1649
1790
  enabled: expandedConf.enabled === undefined ? true : expandedConf.enabled,
1650
1791
  });
1792
+ nextServerInfos.push(existingServer);
1651
1793
  logger.log(hasInflightOAuthAuthorization
1652
1794
  ? `Server '${name}' has an in-flight OAuth authorization; preserving existing state.`
1653
1795
  : `Server '${name}' is already connected.`);
1654
1796
  continue;
1655
1797
  }
1798
+ if (existingServer?.keepAliveIntervalId) {
1799
+ clearInterval(existingServer.keepAliveIntervalId);
1800
+ existingServer.keepAliveIntervalId = undefined;
1801
+ }
1802
+ if (existingServer?.client)
1803
+ existingServer.client.onclose = undefined;
1656
1804
  let transport;
1657
1805
  let openApiClient;
1658
1806
  if (expandedConf.type === 'openapi') {
@@ -1785,7 +1933,7 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1785
1933
  }
1786
1934
  }
1787
1935
  const serverInfoRef = {};
1788
- const client = createUpstreamMcpClient(name, () => serverInfoRef.current);
1936
+ const client = createUpstreamMcpClient(name, () => serverInfoRef.current, expandedConf);
1789
1937
  // Get request options from server configuration, with fallbacks
1790
1938
  const serverRequestOptions = expandedConf.options || {};
1791
1939
  const defaultRequestTimeout = Number(process.env.DEFAULT_REQUEST_TIMEOUT) || 60000;
@@ -1807,7 +1955,8 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1807
1955
  visibility: expandedConf.visibility,
1808
1956
  sharedWithUsers: expandedConf.sharedWithUsers,
1809
1957
  status: 'connecting',
1810
- error: null,
1958
+ recovery: existingServer?.recovery,
1959
+ error: existingServer?.recovery ? existingServer.error : null,
1811
1960
  tools: [],
1812
1961
  prompts: [],
1813
1962
  resources: [],
@@ -1821,6 +1970,7 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1821
1970
  const pendingAuth = expandedConf.oauth?.pendingAuthorization;
1822
1971
  if (pendingAuth) {
1823
1972
  serverInfo.status = 'oauth_required';
1973
+ serverInfo.recovery = undefined;
1824
1974
  serverInfo.error = null;
1825
1975
  serverInfo.oauth = {
1826
1976
  authorizationUrl: pendingAuth.authorizationUrl,
@@ -1828,19 +1978,8 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1828
1978
  };
1829
1979
  }
1830
1980
  nextServerInfos.push(serverInfo);
1831
- // The connect below settles long after this loop returns. If another
1832
- // initialization pass has run by then, `serverInfo` may no longer be the
1833
- // object `serverInfos` holds for this server - even when the reuse guard
1834
- // "preserved" it, because preservation spreads the entry into a fresh
1835
- // object. Writing the result into a replaced object leaves the server
1836
- // stuck on 'connecting' for good while the log reports a successful
1837
- // connect and a full tool list.
1838
- //
1839
- // While this pass is still the newest one, `serverInfo` is the entry that
1840
- // will be published, so it is the right target even before the swap. Once
1841
- // a later pass has run, resolve the live entry by name and only apply the
1842
- // result if it still owns this client - which is exactly the case where
1843
- // the later pass preserved this server rather than reconnecting it.
1981
+ // Before publication this pass owns the entry; after another pass, only
1982
+ // publish connection results if the live runtime still owns this client.
1844
1983
  const liveServerInfo = () => {
1845
1984
  if (generation === initGeneration)
1846
1985
  return serverInfo;
@@ -1991,6 +2130,7 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1991
2130
  info.status = 'oauth_required';
1992
2131
  }
1993
2132
  info.error = null;
2133
+ info.recovery = undefined;
1994
2134
  }
1995
2135
  else {
1996
2136
  logger.error('Failed to connect client for server', {
@@ -2103,7 +2243,7 @@ export const getServersInfo = async (page, limit, user) => {
2103
2243
  authorizationUrl: personal.authorizationUrl,
2104
2244
  }
2105
2245
  : undefined;
2106
- const lease = await principalRuntimes.acquire(info.name, principal);
2246
+ const lease = await principalRuntimes.acquire(info.name, principal, true);
2107
2247
  try {
2108
2248
  return { ...lease.info, config: info.config, ...(ownOAuth ? { oauth: ownOAuth } : {}) };
2109
2249
  }
@@ -2123,7 +2263,7 @@ export const getServersInfo = async (page, limit, user) => {
2123
2263
  }));
2124
2264
  const infos = presentedInfos
2125
2265
  .filter((info) => requestedServerNames.has(info.name)) // Only include requested servers
2126
- .map(({ name, version, packageVersion, latestVersion, updateAvailable, instructions, owner, visibility, status, tools, prompts, resources, createTime, error, oauth, }) => {
2266
+ .map(({ name, version, client, packageVersion, latestVersion, updateAvailable, instructions, owner, visibility, status, tools, prompts, resources, createTime, capabilityCache, error, recovery, oauth, }) => {
2127
2267
  const serverConfig = allServers.find((server) => server.name === name);
2128
2268
  const enabled = serverConfig ? serverConfig.enabled !== false : true;
2129
2269
  const resolvedType = inferServerType(serverConfig);
@@ -2154,12 +2294,17 @@ export const getServersInfo = async (page, limit, user) => {
2154
2294
  return {
2155
2295
  name,
2156
2296
  version,
2297
+ recovery: enabled ? recovery : undefined,
2298
+ protocolVersion: enabled && status === 'connected' && resolvedType !== 'openapi'
2299
+ ? client?.getNegotiatedProtocolVersion?.()
2300
+ : undefined,
2157
2301
  instructions,
2158
2302
  owner,
2159
2303
  visibility,
2160
2304
  status,
2161
2305
  error,
2162
2306
  tools: toolsWithEnabled,
2307
+ ...(capabilityCache ? { capabilityCache } : {}),
2163
2308
  prompts: promptsWithEnabled,
2164
2309
  resources: resourcesWithEnabled,
2165
2310
  createTime,
@@ -2238,6 +2383,7 @@ export const reconnectServer = async (serverName) => {
2238
2383
  }
2239
2384
  // Close existing connection if any
2240
2385
  if (serverInfo.client) {
2386
+ serverInfo.client.onclose = undefined;
2241
2387
  try {
2242
2388
  serverInfo.client.close();
2243
2389
  }
@@ -2259,7 +2405,7 @@ export const reconnectServer = async (serverName) => {
2259
2405
  }
2260
2406
  // Reinitialize the server
2261
2407
  await initializeClientsFromSettings(false, serverName);
2262
- logger.log(`Successfully reconnected server: ${serverName}`);
2408
+ logger.log(`Scheduled connection initialization for server: ${serverName}`);
2263
2409
  };
2264
2410
  /**
2265
2411
  * Resolve the installed package version for an npx/uvx stdio server and record
@@ -2519,6 +2665,9 @@ const clearIdleShutdown = (serverInfo) => {
2519
2665
  };
2520
2666
  const closeServerRuntime = (serverInfo) => {
2521
2667
  clearIdleShutdown(serverInfo);
2668
+ if (serverInfo.client)
2669
+ serverInfo.client.onclose = undefined;
2670
+ serverInfo.recovery = undefined;
2522
2671
  if (serverInfo.keepAliveIntervalId) {
2523
2672
  clearInterval(serverInfo.keepAliveIntervalId);
2524
2673
  serverInfo.keepAliveIntervalId = undefined;
@@ -2646,10 +2795,11 @@ const ensureServerReady = async (serverInfo) => {
2646
2795
  if (expandedConf.type === 'openapi') {
2647
2796
  return;
2648
2797
  }
2798
+ await prepareCapabilitySnapshot(serverInfo, expandedConf);
2649
2799
  const name = rawConfig.name;
2650
2800
  logger.log(`[${name}] Cold-starting on-demand server…`);
2651
2801
  const transport = await createTransportFromConfig(name, expandedConf);
2652
- const client = createUpstreamMcpClient(name, () => serverInfo);
2802
+ const client = createUpstreamMcpClient(name, () => serverInfo, expandedConf);
2653
2803
  const serverRequestOptions = expandedConf.options || {};
2654
2804
  const defaultRequestTimeout = Number(process.env.DEFAULT_REQUEST_TIMEOUT) || 60000;
2655
2805
  const requestOptions = {
@@ -2687,6 +2837,11 @@ const ensureServerReady = async (serverInfo) => {
2687
2837
  });
2688
2838
  }
2689
2839
  }
2840
+ else {
2841
+ updateServerToolsCache(serverInfo, []);
2842
+ broadcastToolListChanged();
2843
+ }
2844
+ await flushCapabilitySnapshot(serverInfo);
2690
2845
  if (capabilities?.prompts) {
2691
2846
  try {
2692
2847
  const prompts = await client.listPrompts({}, requestOptions);
@@ -2750,6 +2905,7 @@ const primeOnDemandServers = (gate) => {
2750
2905
  !hasPersonalAuthentication(si.config) &&
2751
2906
  isStdioServer(si.config) &&
2752
2907
  si.tools.length === 0 &&
2908
+ si.capabilityCache?.state !== 'fresh' &&
2753
2909
  si.enabled !== false);
2754
2910
  if (targets.length === 0)
2755
2911
  return Promise.resolve();
@@ -2763,7 +2919,10 @@ const primeOnDemandServers = (gate) => {
2763
2919
  await run(() => ensureServerReady(si),
2764
2920
  // A reload may replace this runtime or finish its discovery while it
2765
2921
  // waits for a slot. Neither needs another child from the old queue.
2766
- () => serverInfos.includes(si) && si.enabled !== false && si.tools.length === 0);
2922
+ () => serverInfos.includes(si) &&
2923
+ si.enabled !== false &&
2924
+ si.tools.length === 0 &&
2925
+ si.capabilityCache?.state !== 'fresh');
2767
2926
  // Sleep the child unless a tool call is using it; keep the cached tool list.
2768
2927
  shutdownOnDemandServer(si);
2769
2928
  logger.log('On-demand server tool cache primed');
@@ -3046,7 +3205,7 @@ const assertToolAvailableForRoute = (tool, appsRouteContext) => {
3046
3205
  throw new Error(`Tool '${tool.name}' is only available to MCP Apps`);
3047
3206
  }
3048
3207
  };
3049
- const resolveToolInGroup = async (group, toolName) => {
3208
+ const resolveToolInGroup = async (group, toolName, appsRouteContext) => {
3050
3209
  const lookupGroup = getGroupLookupName(group);
3051
3210
  if (!lookupGroup) {
3052
3211
  return undefined;
@@ -3058,22 +3217,30 @@ const resolveToolInGroup = async (group, toolName) => {
3058
3217
  const allowRawName = !isSmartRoutingGroup(group) &&
3059
3218
  serverConfigsByName.size <= 1 &&
3060
3219
  filteredServerInfos.length === 1;
3220
+ // Validate the full visible catalog before routing, even if the client skips tools/list.
3221
+ const hasOriginalToolNames = [...serverConfigsByName.values()].some((config) => config.preserveToolNames);
3222
+ if (hasOriginalToolNames) {
3223
+ await listGroupTools(lookupGroup, filteredServerInfos, serverConfigsByName, appsRouteContext);
3224
+ }
3061
3225
  for (const serverInfo of filteredServerInfos) {
3062
3226
  // An idle on-demand server is selected here and woken by the caller (#1029)
3063
3227
  if (!canServeToolRequests(serverInfo)) {
3064
3228
  continue;
3065
3229
  }
3066
3230
  const serverConfig = serverConfigsByName.get(serverInfo.name);
3067
- const internalToolName = resolveNameFromGroup(toolName, serverInfo.name, serverConfig);
3231
+ const internalToolName = serverConfig?.preserveToolNames
3232
+ ? `${serverInfo.name}${getNameSeparator()}${toolName}`
3233
+ : resolveNameFromGroup(toolName, serverInfo.name, serverConfig);
3068
3234
  const tool = findToolOnServer(serverInfo, internalToolName, allowRawName, serverConfigsByName.size === 0);
3069
- if (!tool) {
3235
+ if (!tool || (hasOriginalToolNames && !appsRouteContext.enabled && isAppOnlyTool(tool))) {
3070
3236
  continue;
3071
3237
  }
3072
3238
  const filteredTools = await filterToolsByGroup(lookupGroup, serverInfo.name, [tool], serverConfig);
3073
3239
  if (filteredTools.length === 0) {
3074
3240
  continue;
3075
3241
  }
3076
- return { serverInfo, toolName: internalToolName, tool };
3242
+ // Retain the resolved identity when callers revalidate discovery after waking.
3243
+ return { serverInfo, toolName: tool.name, tool };
3077
3244
  }
3078
3245
  return undefined;
3079
3246
  };
@@ -3127,7 +3294,7 @@ const projectToolForDownstream = (serverName, tool, appsRouteContext, serverConf
3127
3294
  // Group and aggregate routes keep qualified names, including single-member groups.
3128
3295
  return {
3129
3296
  ...projectedTool,
3130
- name: projectNameForGroup(projectedTool.name, serverName, serverConfig),
3297
+ name: projectGroupToolName(projectedTool.name, serverName, serverConfig, getNameSeparator()),
3131
3298
  };
3132
3299
  };
3133
3300
  const listGroupTools = async (group, filteredServerInfos, serverConfigsByName, appsRouteContext) => {
@@ -3135,7 +3302,10 @@ const listGroupTools = async (group, filteredServerInfos, serverConfigsByName, a
3135
3302
  // so this list reflects the freshly cached tools instead of returning empty.
3136
3303
  // No wake is triggered from list itself; the prime handles that. See #1029.
3137
3304
  await Promise.allSettled(filteredServerInfos
3138
- .filter((si) => si.config?.startOnDemand === true && si.tools.length === 0 && si.spawningPromise)
3305
+ .filter((si) => si.config?.startOnDemand === true &&
3306
+ si.tools.length === 0 &&
3307
+ si.capabilityCache?.state !== 'fresh' &&
3308
+ si.spawningPromise)
3139
3309
  .map((si) => si.spawningPromise));
3140
3310
  const useRawName = !!group && !isSmartRoutingGroup(group) && !(await findRouteGroup(group));
3141
3311
  const allTools = [];
@@ -3162,6 +3332,9 @@ const listGroupTools = async (group, filteredServerInfos, serverConfigsByName, a
3162
3332
  }));
3163
3333
  }
3164
3334
  }
3335
+ if ([...serverConfigsByName.values()].some((config) => config.preserveToolNames)) {
3336
+ assertValidGroupToolNames(allTools);
3337
+ }
3165
3338
  return allTools;
3166
3339
  };
3167
3340
  // Names the call handler always routes to a meta-tool on a Smart Routing session,
@@ -3351,7 +3524,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3351
3524
  targetServerInfo = getVisibleServerByName(extra.server);
3352
3525
  }
3353
3526
  else if (getGroupLookupName(group)) {
3354
- const groupTool = await resolveToolInGroup(group, toolName);
3527
+ const groupTool = await resolveToolInGroup(group, toolName, appsRouteContext);
3355
3528
  if (groupTool) {
3356
3529
  targetServerInfo = groupTool.serverInfo;
3357
3530
  targetToolName = groupTool.toolName;
@@ -3376,6 +3549,8 @@ const handleCallToolRequestImpl = async (request, extra) => {
3376
3549
  if (targetServerInfo.config?.startOnDemand && targetServerInfo.status !== 'connected') {
3377
3550
  await ensureServerReady(targetServerInfo);
3378
3551
  // Re-read status from the mutated serverInfo after async spawn
3552
+ // Routing may have captured a tool from the persisted directory before wake.
3553
+ targetTool = undefined;
3379
3554
  const freshStatus = targetServerInfo.status;
3380
3555
  if (freshStatus !== 'connected') {
3381
3556
  throw new Error(`Failed to start on-demand server '${targetServerInfo.name}' — check server logs`);
@@ -3549,13 +3724,13 @@ const handleCallToolRequestImpl = async (request, extra) => {
3549
3724
  // Apps support; keep legacy raw aggregate calls compatible.
3550
3725
  const singleServerAppsRoute = !getGroupLookupName(group) && !!appsRouteContext.serverInfo;
3551
3726
  const groupTool = !singleServerAppsRoute && lookupGroup
3552
- ? await resolveToolInGroup(group, request.params.name)
3727
+ ? await resolveToolInGroup(group, request.params.name, appsRouteContext)
3553
3728
  : undefined;
3554
3729
  const serverInfo = singleServerAppsRoute
3555
3730
  ? appsRouteContext.serverInfo
3556
3731
  : (groupTool?.serverInfo ?? (lookupGroup ? undefined : getServerByTool(request.params.name)));
3557
3732
  const routeToolName = groupTool?.toolName ?? request.params.name;
3558
- const tool = groupTool?.tool ??
3733
+ let tool = groupTool?.tool ??
3559
3734
  (serverInfo ? findToolOnServer(serverInfo, routeToolName, singleServerAppsRoute) : undefined);
3560
3735
  if (!serverInfo || !tool) {
3561
3736
  throw new ToolUnavailableError(`Tool not available: ${request.params.name}`, classifyUnavailableReason(request.params.name));
@@ -3568,6 +3743,10 @@ const handleCallToolRequestImpl = async (request, extra) => {
3568
3743
  releaseOnDemandToolCall = beginOnDemandToolCall(serverInfo);
3569
3744
  if (serverInfo.config?.startOnDemand && serverInfo.status !== 'connected') {
3570
3745
  await ensureServerReady(serverInfo);
3746
+ tool = findToolOnServer(serverInfo, routeToolName, singleServerAppsRoute);
3747
+ if (!tool)
3748
+ throw new ToolUnavailableError(`Tool not available: ${request.params.name}`, 'tool-not-found');
3749
+ assertToolAvailableForRoute(tool, appsRouteContext);
3571
3750
  }
3572
3751
  serverInfo.lastUsedAt = Date.now();
3573
3752
  // Handle OpenAPI servers differently
@@ -4118,10 +4297,41 @@ const handleReadResourceRequestImpl = async (request, extra) => {
4118
4297
  };
4119
4298
  }
4120
4299
  };
4300
+ // Preserve credential-safe errors for personal clients in every discovery scope.
4301
+ const personalClientMethods = new Set([
4302
+ 'callTool',
4303
+ 'getPrompt',
4304
+ 'readResource',
4305
+ 'listTools',
4306
+ 'listPrompts',
4307
+ 'listResources',
4308
+ 'listResourceTemplates',
4309
+ ]);
4310
+ const protectPersonalClient = (client, info, resolvedConfig) => new Proxy(client, {
4311
+ get(target, property) {
4312
+ const value = Reflect.get(target, property);
4313
+ if (typeof value !== 'function')
4314
+ return value;
4315
+ if (!personalClientMethods.has(String(property)))
4316
+ return value.bind(target);
4317
+ return async (...args) => {
4318
+ try {
4319
+ return await value.apply(target, args);
4320
+ }
4321
+ catch (error) {
4322
+ if (isUpstreamConnectionFailure(error))
4323
+ info.status = 'disconnected';
4324
+ const failure = createUpstreamRequestError(info.name, String(property), error, resolvedConfig);
4325
+ logger.error(failure.message);
4326
+ throw failure;
4327
+ }
4328
+ };
4329
+ },
4330
+ });
4121
4331
  // Personal runtimes never enter serverInfos or config exports. Their tool list
4122
4332
  // is added to the rows under the server name, the index the reindex endpoint
4123
4333
  // rebuilds from every binding.
4124
- const createPrincipalRuntime = async (name, resolvedConfig) => {
4334
+ const createPrincipalRuntime = async (name, resolvedConfig, scope, toolsOnly = false) => {
4125
4335
  const info = {
4126
4336
  name,
4127
4337
  owner: resolvedConfig.owner,
@@ -4137,41 +4347,22 @@ const createPrincipalRuntime = async (name, resolvedConfig) => {
4137
4347
  resources: [],
4138
4348
  createTime: Date.now(),
4139
4349
  };
4140
- const safeMethods = new Set([
4141
- 'callTool',
4142
- 'getPrompt',
4143
- 'readResource',
4144
- 'listTools',
4145
- 'listPrompts',
4146
- 'listResources',
4147
- 'listResourceTemplates',
4148
- ]);
4149
- const protectClient = (client) => new Proxy(client, {
4150
- get(target, property) {
4151
- const value = Reflect.get(target, property);
4152
- if (typeof value !== 'function')
4153
- return value;
4154
- if (!safeMethods.has(String(property)))
4155
- return value.bind(target);
4156
- return async (...args) => {
4157
- try {
4158
- return await value.apply(target, args);
4159
- }
4160
- catch (error) {
4161
- if (isUpstreamConnectionFailure(error))
4162
- info.status = 'disconnected';
4163
- const failure = createUpstreamRequestError(name, String(property), error, resolvedConfig);
4164
- logger.error(failure.message);
4165
- throw failure;
4166
- }
4167
- };
4168
- },
4169
- });
4350
+ const cachedTools = await prepareCapabilitySnapshot(info, resolvedConfig, scope);
4351
+ if (toolsOnly) {
4352
+ if (cachedTools === undefined)
4353
+ return undefined;
4354
+ info.tools = cachedTools.map((tool) => normalizeToolForCache(name, tool));
4355
+ void syncCredentialServerToolEmbeddings(name, info.tools, { partial: true }).catch(() => {
4356
+ logger.warn('[EMBED_SYNC_ERROR] Failed to sync personal tool embeddings');
4357
+ });
4358
+ info.status = 'disconnected';
4359
+ return info;
4360
+ }
4170
4361
  try {
4171
4362
  if (resolvedConfig.type === 'openapi') {
4172
4363
  const client = new OpenAPIClient(resolvedConfig);
4173
4364
  await client.initialize();
4174
- info.openApiClient = protectClient(client);
4365
+ info.openApiClient = protectPersonalClient(client, info, resolvedConfig);
4175
4366
  info.tools = client.getTools().map((tool) => ({
4176
4367
  ...tool,
4177
4368
  name: `${name}${getNameSeparator()}${tool.name}`,
@@ -4181,17 +4372,23 @@ const createPrincipalRuntime = async (name, resolvedConfig) => {
4181
4372
  else {
4182
4373
  const transport = await createTransportFromConfig(name, resolvedConfig);
4183
4374
  const client = new LegacyMcpClient({ name: `mcp-client-${name}`, version: '1.0.0' }, {
4375
+ versionNegotiation: { mode: resolveUpstreamProtocolMode(resolvedConfig) },
4184
4376
  capabilities: MCP_APPS_CAPABILITIES,
4185
4377
  jsonSchemaValidator: new ResilientJsonSchemaValidator(),
4186
4378
  });
4187
4379
  info.transport = transport;
4188
- info.client = protectClient(client);
4380
+ info.client = protectPersonalClient(client, info, resolvedConfig);
4189
4381
  await client.connect(transport, resolvedConfig.options || {});
4190
4382
  const capabilities = client.getServerCapabilities();
4191
4383
  if (capabilities?.tools) {
4192
4384
  const { tools } = await client.listTools({}, resolvedConfig.options);
4193
4385
  info.tools = preserveListFreshness(tools, tools.map((tool) => normalizeToolForCache(name, tool)));
4386
+ recordCapabilitySnapshot(info, tools);
4194
4387
  }
4388
+ else {
4389
+ recordCapabilitySnapshot(info, []);
4390
+ }
4391
+ await flushCapabilitySnapshot(info);
4195
4392
  if (capabilities?.prompts) {
4196
4393
  const { prompts } = await client.listPrompts({}, resolvedConfig.options);
4197
4394
  info.prompts = preserveListFreshness(prompts, prompts.map((prompt) => normalizePromptForCache(name, prompt)));
@@ -4226,7 +4423,7 @@ const createPrincipalRuntime = async (name, resolvedConfig) => {
4226
4423
  throw failure;
4227
4424
  }
4228
4425
  };
4229
- const principalRuntimes = new PrincipalRuntimeService(createPrincipalRuntime, closeServerRuntime);
4426
+ const principalRuntimes = new PrincipalRuntimeService(async (name, config, scope) => (await createPrincipalRuntime(name, config, scope)), closeServerRuntime, (name, config, scope) => createPrincipalRuntime(name, config, scope, true));
4230
4427
  /**
4231
4428
  * Resolve the tools of a personal-credential server on behalf of an explicit
4232
4429
  * principal, outside any request context.
@@ -4243,12 +4440,112 @@ const principalRuntimes = new PrincipalRuntimeService(createPrincipalRuntime, cl
4243
4440
  export const getServerToolsForPrincipal = async (serverName, principal) => {
4244
4441
  const lease = await principalRuntimes.acquire(serverName, principal);
4245
4442
  try {
4443
+ await refreshModernServerLists(lease.info, ['tools']);
4246
4444
  return lease.info.tools ?? [];
4247
4445
  }
4248
4446
  finally {
4249
4447
  lease.release();
4250
4448
  }
4251
4449
  };
4450
+ const withModernLists = (handler, fields) => async (request, extra) => {
4451
+ if (!(principalServerScope.getStore() ?? serverInfos).some((info) => info.client?.getProtocolEra?.() === 'modern')) {
4452
+ return handler(request, extra);
4453
+ }
4454
+ // Built-in content must not depend on an unrelated upstream being healthy.
4455
+ if (fields[0] === 'prompts' && request.method === 'prompts/get' && !extra?.server) {
4456
+ const builtin = await getBuiltinPromptDao().findByName(request.params?.name);
4457
+ if (builtin && builtin.enabled !== false)
4458
+ return handler(request, extra);
4459
+ }
4460
+ if (fields[0] === 'resources' && request.method === 'resources/read') {
4461
+ const builtin = await getBuiltinResourceDao().findByUri(request.params?.uri);
4462
+ if (builtin && builtin.enabled !== false)
4463
+ return handler(request, extra);
4464
+ }
4465
+ const { filteredServerInfos, serverConfigsByName } = await getFilteredServerInfosForGroup(getGroupLookupName(getMcpRequestGroup(extra)));
4466
+ const requestedName = ['call_tool', 'describe_tool'].includes(request?.params?.name)
4467
+ ? request.params.arguments?.toolName
4468
+ : request?.params?.name;
4469
+ let selected = filteredServerInfos;
4470
+ if (extra?.server) {
4471
+ selected = selected.filter((info) => info.name === extra.server);
4472
+ }
4473
+ else if (typeof requestedName === 'string' &&
4474
+ !(fields[0] === 'tools' &&
4475
+ [...serverConfigsByName.values()].some((config) => config.preserveToolNames))) {
4476
+ // Match the routing resolver's longest exposed prefix, including aliases.
4477
+ const targets = selected
4478
+ .map((info) => ({
4479
+ info,
4480
+ prefix: `${getExposedServerName(info.name, serverConfigsByName.get(info.name))}${getNameSeparator()}`,
4481
+ }))
4482
+ .filter(({ prefix }) => requestedName.startsWith(prefix))
4483
+ .sort((a, b) => b.prefix.length - a.prefix.length);
4484
+ if (targets.length)
4485
+ selected = [targets[0].info];
4486
+ }
4487
+ const scoped = new Map((principalServerScope.getStore() ?? serverInfos).map((info) => [info.name, info]));
4488
+ const requestClients = [];
4489
+ try {
4490
+ await Promise.all(selected.map(async (info) => {
4491
+ try {
4492
+ let discoveryInfo = info;
4493
+ const serverConfig = info.config;
4494
+ if (info.status === 'connected' &&
4495
+ info.client?.getProtocolEra?.() === 'modern' &&
4496
+ serverConfig?.passthroughHeaders?.some(isSafePassthroughHeader)) {
4497
+ // Reuse neither snapshots nor pending work across forwarded identities.
4498
+ // A disposable modern client also isolates SDK tool definitions and read caches.
4499
+ const transport = await createTransportFromConfig(info.name, serverConfig);
4500
+ const client = new LegacyMcpClient({ name: `mcp-client-${info.name}`, version: '1.0.0' }, {
4501
+ versionNegotiation: {
4502
+ mode: { pin: info.client.getNegotiatedProtocolVersion() },
4503
+ },
4504
+ capabilities: MCP_APPS_CAPABILITIES,
4505
+ jsonSchemaValidator: new ResilientJsonSchemaValidator(),
4506
+ });
4507
+ discoveryInfo = { ...info, client, transport, tools: [], prompts: [], resources: [] };
4508
+ if (hasPersonalAuthentication(serverConfig)) {
4509
+ discoveryInfo.client = protectPersonalClient(client, discoveryInfo, serverConfig);
4510
+ }
4511
+ requestClients.push(discoveryInfo.client);
4512
+ requestDiscoveryInfos.add(discoveryInfo);
4513
+ scoped.set(info.name, discoveryInfo);
4514
+ await connectClientWithDiagnostics(client, transport, info.options || {});
4515
+ }
4516
+ await refreshModernServerLists(discoveryInfo, fields);
4517
+ }
4518
+ catch {
4519
+ // Exclude only this upstream for this request. Keep its shared state
4520
+ // intact so later requests retry; disconnected projections force TTL zero.
4521
+ scoped.set(info.name, {
4522
+ ...info,
4523
+ status: 'disconnected',
4524
+ client: undefined,
4525
+ tools: [],
4526
+ prompts: [],
4527
+ resources: [],
4528
+ });
4529
+ logger.warn('Failed to refresh upstream discovery for request', {
4530
+ serverName: info.name,
4531
+ });
4532
+ }
4533
+ }));
4534
+ return await principalServerScope.run([...scoped.values()], () => handler(request, extra));
4535
+ }
4536
+ finally {
4537
+ await Promise.all([
4538
+ ...new Set([
4539
+ ...requestClients,
4540
+ ...[...scoped.values()]
4541
+ .filter((info) => requestDiscoveryInfos.has(info))
4542
+ .flatMap((info) => (info.client ? [info.client] : [])),
4543
+ ]),
4544
+ ].map((client) => client.close().catch(() => {
4545
+ logger.warn('Failed to close request discovery client');
4546
+ })));
4547
+ }
4548
+ };
4252
4549
  const withPrincipalServers = (handler, operation) => async (request, extra) => {
4253
4550
  if (!serverInfos.some((info) => hasPersonalAuthentication(info.config)))
4254
4551
  return handler(request, extra);
@@ -4274,7 +4571,10 @@ const withPrincipalServers = (handler, operation) => async (request, extra) => {
4274
4571
  if (extra?.server && !target && (operation === 'tool' || operation === 'prompt')) {
4275
4572
  throw new ToolUnavailableError(`Tool not available: ${requestedName}`, 'server-or-name-not-found');
4276
4573
  }
4277
- if (!extra?.server && typeof requestedName === 'string') {
4574
+ if (!extra?.server &&
4575
+ typeof requestedName === 'string' &&
4576
+ !(operation === 'tool' &&
4577
+ [...serverConfigsByName.values()].some((config) => config.preserveToolNames))) {
4278
4578
  let longestPrefix = 0;
4279
4579
  for (const info of candidates) {
4280
4580
  const exposedName = getExposedServerName(info.name, serverConfigsByName.get(info.name));
@@ -4303,7 +4603,7 @@ const withPrincipalServers = (handler, operation) => async (request, extra) => {
4303
4603
  throw hasPersonalOAuth(info.config)
4304
4604
  ? personalAuthorizationRequired(info.name)
4305
4605
  : missingCredentialError(info.name);
4306
- const lease = await principalRuntimes.acquire(info.name, user);
4606
+ const lease = await principalRuntimes.acquire(info.name, user, operation === 'tools-list');
4307
4607
  releases.push(lease.release);
4308
4608
  scoped.set(info.name, lease.info);
4309
4609
  }
@@ -4314,7 +4614,10 @@ const withPrincipalServers = (handler, operation) => async (request, extra) => {
4314
4614
  }
4315
4615
  }));
4316
4616
  const requiredTarget = target ||
4317
- (candidates.length === 1 && operation !== 'list' && requestedName !== 'search_tools'
4617
+ (candidates.length === 1 &&
4618
+ operation !== 'list' &&
4619
+ operation !== 'tools-list' &&
4620
+ requestedName !== 'search_tools'
4318
4621
  ? templated[0]
4319
4622
  : undefined);
4320
4623
  const failure = requiredTarget ? failures.get(requiredTarget.name) : undefined;
@@ -4443,14 +4746,16 @@ const withCacheHints = (handler, passthroughTtl = false, listField) => async (re
4443
4746
  // and capabilities. Shared caches must not reuse the gateway result across users.
4444
4747
  return { ...result, ttlMs, cacheScope: 'private' };
4445
4748
  };
4446
- export const handleListToolsRequest = withPrincipalServers(withCacheHints(handleListToolsRequestImpl, false, 'tools'), 'list');
4447
- const callToolWithPrincipal = withPrincipalServers(handleCallToolRequestImpl, 'tool');
4749
+ export const handleListToolsRequest = withPrincipalServers(withModernLists(withCacheHints(handleListToolsRequestImpl, false, 'tools'), ['tools']), 'tools-list');
4750
+ const callToolWithPrincipal = withPrincipalServers(withModernLists(handleCallToolRequestImpl, ['tools']), 'tool');
4448
4751
  export const handleCallToolRequest = (request, extra) => observeToolCall(() => callToolWithPrincipal(request, extra));
4449
- export const handleGetPromptRequest = withPrincipalServers(handleGetPromptRequestImpl, 'prompt');
4450
- export const handleListPromptsRequest = withPrincipalServers(withCacheHints(handleListPromptsRequestImpl, false, 'prompts'), 'list');
4451
- export const handleListResourcesRequest = withPrincipalServers(withCacheHints(handleListResourcesRequestImpl, false, 'resources'), 'list');
4752
+ export const handleGetPromptRequest = withPrincipalServers(withModernLists(handleGetPromptRequestImpl, ['prompts']), 'prompt');
4753
+ export const handleListPromptsRequest = withPrincipalServers(withModernLists(withCacheHints(handleListPromptsRequestImpl, false, 'prompts'), ['prompts']), 'list');
4754
+ export const handleListResourcesRequest = withPrincipalServers(withModernLists(withCacheHints(handleListResourcesRequestImpl, false, 'resources'), [
4755
+ 'resources',
4756
+ ]), 'list');
4452
4757
  export const handleListResourceTemplatesRequest = withPrincipalServers(withCacheHints(handleListResourceTemplatesRequestImpl, false, 'resourceTemplates'), 'list');
4453
- export const handleReadResourceRequest = withPrincipalServers(withCacheHints(handleReadResourceRequestImpl, true), 'resource');
4758
+ export const handleReadResourceRequest = withPrincipalServers(withModernLists(withCacheHints(handleReadResourceRequestImpl, true), ['resources']), 'resource');
4454
4759
  export const createMcpServer = (name, version, options) => {
4455
4760
  const normalizedOptions = typeof options === 'string' ? { group: options } : (options ?? {});
4456
4761
  // Determine server name based on routing type