@samanhappy/mcphub 1.1.10 → 1.1.12

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 (136) hide show
  1. package/README.md +4 -0
  2. package/README.zh.md +4 -0
  3. package/dist/cli/commands/keys.js +2 -2
  4. package/dist/cli/commands/keys.js.map +1 -1
  5. package/dist/clients/legacyMcpClient.js +10 -2
  6. package/dist/clients/legacyMcpClient.js.map +1 -1
  7. package/dist/controllers/bearerKeyController.js +5 -4
  8. package/dist/controllers/bearerKeyController.js.map +1 -1
  9. package/dist/controllers/configController.js +1 -1
  10. package/dist/controllers/configController.js.map +1 -1
  11. package/dist/controllers/groupController.js +19 -0
  12. package/dist/controllers/groupController.js.map +1 -1
  13. package/dist/controllers/serverController.js +5 -2
  14. package/dist/controllers/serverController.js.map +1 -1
  15. package/dist/dao/BearerKeyDao.js +34 -7
  16. package/dist/dao/BearerKeyDao.js.map +1 -1
  17. package/dist/dao/BearerKeyDaoDbImpl.js +18 -3
  18. package/dist/dao/BearerKeyDaoDbImpl.js.map +1 -1
  19. package/dist/dao/CapabilitySnapshotDao.js +38 -0
  20. package/dist/dao/CapabilitySnapshotDao.js.map +1 -0
  21. package/dist/dao/CapabilitySnapshotDaoDbImpl.js +18 -0
  22. package/dist/dao/CapabilitySnapshotDaoDbImpl.js.map +1 -0
  23. package/dist/dao/DaoFactory.js +7 -0
  24. package/dist/dao/DaoFactory.js.map +1 -1
  25. package/dist/dao/DatabaseDaoFactory.js +4 -0
  26. package/dist/dao/DatabaseDaoFactory.js.map +1 -1
  27. package/dist/dao/ServerDaoDbImpl.js +3 -0
  28. package/dist/dao/ServerDaoDbImpl.js.map +1 -1
  29. package/dist/db/entities/BearerKey.js +10 -2
  30. package/dist/db/entities/BearerKey.js.map +1 -1
  31. package/dist/db/entities/CapabilitySnapshot.js +37 -0
  32. package/dist/db/entities/CapabilitySnapshot.js.map +1 -0
  33. package/dist/db/entities/Group.js.map +1 -1
  34. package/dist/db/entities/Server.js +4 -0
  35. package/dist/db/entities/Server.js.map +1 -1
  36. package/dist/db/entities/index.js +3 -1
  37. package/dist/db/entities/index.js.map +1 -1
  38. package/dist/db/repositories/BearerKeyRepository.js +20 -3
  39. package/dist/db/repositories/BearerKeyRepository.js.map +1 -1
  40. package/dist/middlewares/auth.js +2 -2
  41. package/dist/middlewares/auth.js.map +1 -1
  42. package/dist/server.js +11 -1
  43. package/dist/server.js.map +1 -1
  44. package/dist/services/capabilitySnapshotService.js +106 -0
  45. package/dist/services/capabilitySnapshotService.js.map +1 -0
  46. package/dist/services/credentialBindingService.js +4 -0
  47. package/dist/services/credentialBindingService.js.map +1 -1
  48. package/dist/services/groupService.js +1 -0
  49. package/dist/services/groupService.js.map +1 -1
  50. package/dist/services/keepAliveService.js +52 -36
  51. package/dist/services/keepAliveService.js.map +1 -1
  52. package/dist/services/mcpOAuthProvider.js +19 -2
  53. package/dist/services/mcpOAuthProvider.js.map +1 -1
  54. package/dist/services/mcpService.js +405 -103
  55. package/dist/services/mcpService.js.map +1 -1
  56. package/dist/services/oauthClientRegistration.js +2 -1
  57. package/dist/services/oauthClientRegistration.js.map +1 -1
  58. package/dist/services/principalRuntimeService.js +27 -3
  59. package/dist/services/principalRuntimeService.js.map +1 -1
  60. package/dist/services/smartRoutingService.js +19 -5
  61. package/dist/services/smartRoutingService.js.map +1 -1
  62. package/dist/services/sseService.js +3 -3
  63. package/dist/services/sseService.js.map +1 -1
  64. package/dist/services/templateService.js +5 -0
  65. package/dist/services/templateService.js.map +1 -1
  66. package/dist/services/upstreamListRefresh.js +37 -0
  67. package/dist/services/upstreamListRefresh.js.map +1 -0
  68. package/dist/utils/bearerKeyToken.js +27 -0
  69. package/dist/utils/bearerKeyToken.js.map +1 -0
  70. package/dist/utils/groupToolNames.js +21 -0
  71. package/dist/utils/groupToolNames.js.map +1 -0
  72. package/dist/utils/mcpProtocol.js +14 -1
  73. package/dist/utils/mcpProtocol.js.map +1 -1
  74. package/dist/utils/migration.js +63 -5
  75. package/dist/utils/migration.js.map +1 -1
  76. package/dist/utils/oauthServerMetadata.js +23 -0
  77. package/dist/utils/oauthServerMetadata.js.map +1 -0
  78. package/dist/utils/parameterConversion.js +9 -2
  79. package/dist/utils/parameterConversion.js.map +1 -1
  80. package/dist/utils/serverConfigPersistence.js +11 -0
  81. package/dist/utils/serverConfigPersistence.js.map +1 -1
  82. package/dist/utils/settingsSync.js +2 -0
  83. package/dist/utils/settingsSync.js.map +1 -1
  84. package/frontend/dist/assets/{ActivityPage-DDa3yAck.js → ActivityPage-DWA-okla.js} +2 -2
  85. package/frontend/dist/assets/{ActivityPage-DDa3yAck.js.map → ActivityPage-DWA-okla.js.map} +1 -1
  86. package/frontend/dist/assets/{CopyClientConfigDialog-DPQOggV-.js → CopyClientConfigDialog-xeHg7JJj.js} +2 -2
  87. package/frontend/dist/assets/{CopyClientConfigDialog-DPQOggV-.js.map → CopyClientConfigDialog-xeHg7JJj.js.map} +1 -1
  88. package/frontend/dist/assets/{CredentialsPage-DmxY-iXb.js → CredentialsPage-D8mMIH5f.js} +2 -2
  89. package/frontend/dist/assets/{CredentialsPage-DmxY-iXb.js.map → CredentialsPage-D8mMIH5f.js.map} +1 -1
  90. package/frontend/dist/assets/{Dashboard-DCvveQBj.js → Dashboard-Diql_Btr.js} +2 -2
  91. package/frontend/dist/assets/{Dashboard-DCvveQBj.js.map → Dashboard-Diql_Btr.js.map} +1 -1
  92. package/frontend/dist/assets/{EndpointCopy-DRWz8guH.js → EndpointCopy-0d-dK6uJ.js} +2 -2
  93. package/frontend/dist/assets/{EndpointCopy-DRWz8guH.js.map → EndpointCopy-0d-dK6uJ.js.map} +1 -1
  94. package/frontend/dist/assets/GroupsPage-gZ0nnzCn.js +33 -0
  95. package/frontend/dist/assets/GroupsPage-gZ0nnzCn.js.map +1 -0
  96. package/frontend/dist/assets/{LoginPage-BVBf8nr6.js → LoginPage-BRyFcGQ5.js} +2 -2
  97. package/frontend/dist/assets/{LoginPage-BVBf8nr6.js.map → LoginPage-BRyFcGQ5.js.map} +1 -1
  98. package/frontend/dist/assets/{LogsPage-DbsmNJcA.js → LogsPage-C7erxObE.js} +2 -2
  99. package/frontend/dist/assets/{LogsPage-DbsmNJcA.js.map → LogsPage-C7erxObE.js.map} +1 -1
  100. package/frontend/dist/assets/{MarketPage-BOggDLJb.js → MarketPage-BZHQQB57.js} +2 -2
  101. package/frontend/dist/assets/{MarketPage-BOggDLJb.js.map → MarketPage-BZHQQB57.js.map} +1 -1
  102. package/frontend/dist/assets/{OAuthConsentPage-VON__2op.js → OAuthConsentPage-Czhwhcx8.js} +2 -2
  103. package/frontend/dist/assets/{OAuthConsentPage-VON__2op.js.map → OAuthConsentPage-Czhwhcx8.js.map} +1 -1
  104. package/frontend/dist/assets/{PromptsPage-BNkvIDZU.js → PromptsPage-D0yHQbns.js} +2 -2
  105. package/frontend/dist/assets/{PromptsPage-BNkvIDZU.js.map → PromptsPage-D0yHQbns.js.map} +1 -1
  106. package/frontend/dist/assets/{ResourcesPage-4WBvamN_.js → ResourcesPage-BFoqAAq0.js} +2 -2
  107. package/frontend/dist/assets/{ResourcesPage-4WBvamN_.js.map → ResourcesPage-BFoqAAq0.js.map} +1 -1
  108. package/frontend/dist/assets/{ServersPage-C7000Szj.js → ServersPage-CMG1tNig.js} +9 -9
  109. package/frontend/dist/assets/ServersPage-CMG1tNig.js.map +1 -0
  110. package/frontend/dist/assets/{SettingsPage-YX6dJjrM.js → SettingsPage-C6GAVHlw.js} +2 -2
  111. package/frontend/dist/assets/{SettingsPage-YX6dJjrM.js.map → SettingsPage-C6GAVHlw.js.map} +1 -1
  112. package/frontend/dist/assets/{StatusDot-mgJveZ2h.js → StatusDot-BniiEZ-W.js} +2 -2
  113. package/frontend/dist/assets/{StatusDot-mgJveZ2h.js.map → StatusDot-BniiEZ-W.js.map} +1 -1
  114. package/frontend/dist/assets/{ToggleGroup-DXyuwPy7.js → ToggleGroup-Df-pFUN3.js} +2 -2
  115. package/frontend/dist/assets/{ToggleGroup-DXyuwPy7.js.map → ToggleGroup-Df-pFUN3.js.map} +1 -1
  116. package/frontend/dist/assets/{UsersPage-BiUn8c0z.js → UsersPage-je7LsHJG.js} +2 -2
  117. package/frontend/dist/assets/{UsersPage-BiUn8c0z.js.map → UsersPage-je7LsHJG.js.map} +1 -1
  118. package/frontend/dist/assets/{clipboard-7MOQ-4Cg.js → clipboard-BrGjez3U.js} +2 -2
  119. package/frontend/dist/assets/{clipboard-7MOQ-4Cg.js.map → clipboard-BrGjez3U.js.map} +1 -1
  120. package/frontend/dist/assets/index-DL9uGzQF.js +3 -0
  121. package/frontend/dist/assets/{index-BKFFbupY.js.map → index-DL9uGzQF.js.map} +1 -1
  122. package/frontend/dist/assets/{index-B3hkLjCv.css → index-JDk4RjZI.css} +1 -1
  123. package/frontend/dist/assets/{resourceService-BOn21QbP.js → resourceService-8K1eUH8N.js} +2 -2
  124. package/frontend/dist/assets/{resourceService-BOn21QbP.js.map → resourceService-8K1eUH8N.js.map} +1 -1
  125. package/frontend/dist/assets/{useCostData-3iIVsNH6.js → useCostData-BuDleGRv.js} +2 -2
  126. package/frontend/dist/assets/{useCostData-3iIVsNH6.js.map → useCostData-BuDleGRv.js.map} +1 -1
  127. package/frontend/dist/assets/variableDetection-Djnw5--_.js +18 -0
  128. package/frontend/dist/assets/variableDetection-Djnw5--_.js.map +1 -0
  129. package/frontend/dist/index.html +2 -2
  130. package/package.json +1 -1
  131. package/frontend/dist/assets/GroupsPage-DtoTWqWU.js +0 -33
  132. package/frontend/dist/assets/GroupsPage-DtoTWqWU.js.map +0 -1
  133. package/frontend/dist/assets/ServersPage-C7000Szj.js.map +0 -1
  134. package/frontend/dist/assets/index-BKFFbupY.js +0 -3
  135. package/frontend/dist/assets/variableDetection-Cpa0rIrf.js +0 -18
  136. package/frontend/dist/assets/variableDetection-Cpa0rIrf.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, client, 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,6 +2294,7 @@ export const getServersInfo = async (page, limit, user) => {
2154
2294
  return {
2155
2295
  name,
2156
2296
  version,
2297
+ recovery: enabled ? recovery : undefined,
2157
2298
  protocolVersion: enabled && status === 'connected' && resolvedType !== 'openapi'
2158
2299
  ? client?.getNegotiatedProtocolVersion?.()
2159
2300
  : undefined,
@@ -2163,6 +2304,7 @@ export const getServersInfo = async (page, limit, user) => {
2163
2304
  status,
2164
2305
  error,
2165
2306
  tools: toolsWithEnabled,
2307
+ ...(capabilityCache ? { capabilityCache } : {}),
2166
2308
  prompts: promptsWithEnabled,
2167
2309
  resources: resourcesWithEnabled,
2168
2310
  createTime,
@@ -2241,6 +2383,7 @@ export const reconnectServer = async (serverName) => {
2241
2383
  }
2242
2384
  // Close existing connection if any
2243
2385
  if (serverInfo.client) {
2386
+ serverInfo.client.onclose = undefined;
2244
2387
  try {
2245
2388
  serverInfo.client.close();
2246
2389
  }
@@ -2262,7 +2405,7 @@ export const reconnectServer = async (serverName) => {
2262
2405
  }
2263
2406
  // Reinitialize the server
2264
2407
  await initializeClientsFromSettings(false, serverName);
2265
- logger.log(`Successfully reconnected server: ${serverName}`);
2408
+ logger.log(`Scheduled connection initialization for server: ${serverName}`);
2266
2409
  };
2267
2410
  /**
2268
2411
  * Resolve the installed package version for an npx/uvx stdio server and record
@@ -2522,6 +2665,9 @@ const clearIdleShutdown = (serverInfo) => {
2522
2665
  };
2523
2666
  const closeServerRuntime = (serverInfo) => {
2524
2667
  clearIdleShutdown(serverInfo);
2668
+ if (serverInfo.client)
2669
+ serverInfo.client.onclose = undefined;
2670
+ serverInfo.recovery = undefined;
2525
2671
  if (serverInfo.keepAliveIntervalId) {
2526
2672
  clearInterval(serverInfo.keepAliveIntervalId);
2527
2673
  serverInfo.keepAliveIntervalId = undefined;
@@ -2649,10 +2795,11 @@ const ensureServerReady = async (serverInfo) => {
2649
2795
  if (expandedConf.type === 'openapi') {
2650
2796
  return;
2651
2797
  }
2798
+ await prepareCapabilitySnapshot(serverInfo, expandedConf);
2652
2799
  const name = rawConfig.name;
2653
2800
  logger.log(`[${name}] Cold-starting on-demand server…`);
2654
2801
  const transport = await createTransportFromConfig(name, expandedConf);
2655
- const client = createUpstreamMcpClient(name, () => serverInfo);
2802
+ const client = createUpstreamMcpClient(name, () => serverInfo, expandedConf);
2656
2803
  const serverRequestOptions = expandedConf.options || {};
2657
2804
  const defaultRequestTimeout = Number(process.env.DEFAULT_REQUEST_TIMEOUT) || 60000;
2658
2805
  const requestOptions = {
@@ -2690,6 +2837,11 @@ const ensureServerReady = async (serverInfo) => {
2690
2837
  });
2691
2838
  }
2692
2839
  }
2840
+ else {
2841
+ updateServerToolsCache(serverInfo, []);
2842
+ broadcastToolListChanged();
2843
+ }
2844
+ await flushCapabilitySnapshot(serverInfo);
2693
2845
  if (capabilities?.prompts) {
2694
2846
  try {
2695
2847
  const prompts = await client.listPrompts({}, requestOptions);
@@ -2753,6 +2905,7 @@ const primeOnDemandServers = (gate) => {
2753
2905
  !hasPersonalAuthentication(si.config) &&
2754
2906
  isStdioServer(si.config) &&
2755
2907
  si.tools.length === 0 &&
2908
+ si.capabilityCache?.state !== 'fresh' &&
2756
2909
  si.enabled !== false);
2757
2910
  if (targets.length === 0)
2758
2911
  return Promise.resolve();
@@ -2766,7 +2919,10 @@ const primeOnDemandServers = (gate) => {
2766
2919
  await run(() => ensureServerReady(si),
2767
2920
  // A reload may replace this runtime or finish its discovery while it
2768
2921
  // waits for a slot. Neither needs another child from the old queue.
2769
- () => 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');
2770
2926
  // Sleep the child unless a tool call is using it; keep the cached tool list.
2771
2927
  shutdownOnDemandServer(si);
2772
2928
  logger.log('On-demand server tool cache primed');
@@ -3049,7 +3205,7 @@ const assertToolAvailableForRoute = (tool, appsRouteContext) => {
3049
3205
  throw new Error(`Tool '${tool.name}' is only available to MCP Apps`);
3050
3206
  }
3051
3207
  };
3052
- const resolveToolInGroup = async (group, toolName) => {
3208
+ const resolveToolInGroup = async (group, toolName, appsRouteContext) => {
3053
3209
  const lookupGroup = getGroupLookupName(group);
3054
3210
  if (!lookupGroup) {
3055
3211
  return undefined;
@@ -3061,22 +3217,30 @@ const resolveToolInGroup = async (group, toolName) => {
3061
3217
  const allowRawName = !isSmartRoutingGroup(group) &&
3062
3218
  serverConfigsByName.size <= 1 &&
3063
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
+ }
3064
3225
  for (const serverInfo of filteredServerInfos) {
3065
3226
  // An idle on-demand server is selected here and woken by the caller (#1029)
3066
3227
  if (!canServeToolRequests(serverInfo)) {
3067
3228
  continue;
3068
3229
  }
3069
3230
  const serverConfig = serverConfigsByName.get(serverInfo.name);
3070
- const internalToolName = resolveNameFromGroup(toolName, serverInfo.name, serverConfig);
3231
+ const internalToolName = serverConfig?.preserveToolNames
3232
+ ? `${serverInfo.name}${getNameSeparator()}${toolName}`
3233
+ : resolveNameFromGroup(toolName, serverInfo.name, serverConfig);
3071
3234
  const tool = findToolOnServer(serverInfo, internalToolName, allowRawName, serverConfigsByName.size === 0);
3072
- if (!tool) {
3235
+ if (!tool || (hasOriginalToolNames && !appsRouteContext.enabled && isAppOnlyTool(tool))) {
3073
3236
  continue;
3074
3237
  }
3075
3238
  const filteredTools = await filterToolsByGroup(lookupGroup, serverInfo.name, [tool], serverConfig);
3076
3239
  if (filteredTools.length === 0) {
3077
3240
  continue;
3078
3241
  }
3079
- return { serverInfo, toolName: internalToolName, tool };
3242
+ // Retain the resolved identity when callers revalidate discovery after waking.
3243
+ return { serverInfo, toolName: tool.name, tool };
3080
3244
  }
3081
3245
  return undefined;
3082
3246
  };
@@ -3130,7 +3294,7 @@ const projectToolForDownstream = (serverName, tool, appsRouteContext, serverConf
3130
3294
  // Group and aggregate routes keep qualified names, including single-member groups.
3131
3295
  return {
3132
3296
  ...projectedTool,
3133
- name: projectNameForGroup(projectedTool.name, serverName, serverConfig),
3297
+ name: projectGroupToolName(projectedTool.name, serverName, serverConfig, getNameSeparator()),
3134
3298
  };
3135
3299
  };
3136
3300
  const listGroupTools = async (group, filteredServerInfos, serverConfigsByName, appsRouteContext) => {
@@ -3138,7 +3302,10 @@ const listGroupTools = async (group, filteredServerInfos, serverConfigsByName, a
3138
3302
  // so this list reflects the freshly cached tools instead of returning empty.
3139
3303
  // No wake is triggered from list itself; the prime handles that. See #1029.
3140
3304
  await Promise.allSettled(filteredServerInfos
3141
- .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)
3142
3309
  .map((si) => si.spawningPromise));
3143
3310
  const useRawName = !!group && !isSmartRoutingGroup(group) && !(await findRouteGroup(group));
3144
3311
  const allTools = [];
@@ -3165,6 +3332,9 @@ const listGroupTools = async (group, filteredServerInfos, serverConfigsByName, a
3165
3332
  }));
3166
3333
  }
3167
3334
  }
3335
+ if ([...serverConfigsByName.values()].some((config) => config.preserveToolNames)) {
3336
+ assertValidGroupToolNames(allTools);
3337
+ }
3168
3338
  return allTools;
3169
3339
  };
3170
3340
  // Names the call handler always routes to a meta-tool on a Smart Routing session,
@@ -3354,7 +3524,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3354
3524
  targetServerInfo = getVisibleServerByName(extra.server);
3355
3525
  }
3356
3526
  else if (getGroupLookupName(group)) {
3357
- const groupTool = await resolveToolInGroup(group, toolName);
3527
+ const groupTool = await resolveToolInGroup(group, toolName, appsRouteContext);
3358
3528
  if (groupTool) {
3359
3529
  targetServerInfo = groupTool.serverInfo;
3360
3530
  targetToolName = groupTool.toolName;
@@ -3379,6 +3549,8 @@ const handleCallToolRequestImpl = async (request, extra) => {
3379
3549
  if (targetServerInfo.config?.startOnDemand && targetServerInfo.status !== 'connected') {
3380
3550
  await ensureServerReady(targetServerInfo);
3381
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;
3382
3554
  const freshStatus = targetServerInfo.status;
3383
3555
  if (freshStatus !== 'connected') {
3384
3556
  throw new Error(`Failed to start on-demand server '${targetServerInfo.name}' — check server logs`);
@@ -3552,13 +3724,13 @@ const handleCallToolRequestImpl = async (request, extra) => {
3552
3724
  // Apps support; keep legacy raw aggregate calls compatible.
3553
3725
  const singleServerAppsRoute = !getGroupLookupName(group) && !!appsRouteContext.serverInfo;
3554
3726
  const groupTool = !singleServerAppsRoute && lookupGroup
3555
- ? await resolveToolInGroup(group, request.params.name)
3727
+ ? await resolveToolInGroup(group, request.params.name, appsRouteContext)
3556
3728
  : undefined;
3557
3729
  const serverInfo = singleServerAppsRoute
3558
3730
  ? appsRouteContext.serverInfo
3559
3731
  : (groupTool?.serverInfo ?? (lookupGroup ? undefined : getServerByTool(request.params.name)));
3560
3732
  const routeToolName = groupTool?.toolName ?? request.params.name;
3561
- const tool = groupTool?.tool ??
3733
+ let tool = groupTool?.tool ??
3562
3734
  (serverInfo ? findToolOnServer(serverInfo, routeToolName, singleServerAppsRoute) : undefined);
3563
3735
  if (!serverInfo || !tool) {
3564
3736
  throw new ToolUnavailableError(`Tool not available: ${request.params.name}`, classifyUnavailableReason(request.params.name));
@@ -3571,6 +3743,10 @@ const handleCallToolRequestImpl = async (request, extra) => {
3571
3743
  releaseOnDemandToolCall = beginOnDemandToolCall(serverInfo);
3572
3744
  if (serverInfo.config?.startOnDemand && serverInfo.status !== 'connected') {
3573
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);
3574
3750
  }
3575
3751
  serverInfo.lastUsedAt = Date.now();
3576
3752
  // Handle OpenAPI servers differently
@@ -4121,10 +4297,41 @@ const handleReadResourceRequestImpl = async (request, extra) => {
4121
4297
  };
4122
4298
  }
4123
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
+ });
4124
4331
  // Personal runtimes never enter serverInfos or config exports. Their tool list
4125
4332
  // is added to the rows under the server name, the index the reindex endpoint
4126
4333
  // rebuilds from every binding.
4127
- const createPrincipalRuntime = async (name, resolvedConfig) => {
4334
+ const createPrincipalRuntime = async (name, resolvedConfig, scope, toolsOnly = false) => {
4128
4335
  const info = {
4129
4336
  name,
4130
4337
  owner: resolvedConfig.owner,
@@ -4140,41 +4347,22 @@ const createPrincipalRuntime = async (name, resolvedConfig) => {
4140
4347
  resources: [],
4141
4348
  createTime: Date.now(),
4142
4349
  };
4143
- const safeMethods = new Set([
4144
- 'callTool',
4145
- 'getPrompt',
4146
- 'readResource',
4147
- 'listTools',
4148
- 'listPrompts',
4149
- 'listResources',
4150
- 'listResourceTemplates',
4151
- ]);
4152
- const protectClient = (client) => new Proxy(client, {
4153
- get(target, property) {
4154
- const value = Reflect.get(target, property);
4155
- if (typeof value !== 'function')
4156
- return value;
4157
- if (!safeMethods.has(String(property)))
4158
- return value.bind(target);
4159
- return async (...args) => {
4160
- try {
4161
- return await value.apply(target, args);
4162
- }
4163
- catch (error) {
4164
- if (isUpstreamConnectionFailure(error))
4165
- info.status = 'disconnected';
4166
- const failure = createUpstreamRequestError(name, String(property), error, resolvedConfig);
4167
- logger.error(failure.message);
4168
- throw failure;
4169
- }
4170
- };
4171
- },
4172
- });
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
+ }
4173
4361
  try {
4174
4362
  if (resolvedConfig.type === 'openapi') {
4175
4363
  const client = new OpenAPIClient(resolvedConfig);
4176
4364
  await client.initialize();
4177
- info.openApiClient = protectClient(client);
4365
+ info.openApiClient = protectPersonalClient(client, info, resolvedConfig);
4178
4366
  info.tools = client.getTools().map((tool) => ({
4179
4367
  ...tool,
4180
4368
  name: `${name}${getNameSeparator()}${tool.name}`,
@@ -4184,17 +4372,23 @@ const createPrincipalRuntime = async (name, resolvedConfig) => {
4184
4372
  else {
4185
4373
  const transport = await createTransportFromConfig(name, resolvedConfig);
4186
4374
  const client = new LegacyMcpClient({ name: `mcp-client-${name}`, version: '1.0.0' }, {
4375
+ versionNegotiation: { mode: resolveUpstreamProtocolMode(resolvedConfig) },
4187
4376
  capabilities: MCP_APPS_CAPABILITIES,
4188
4377
  jsonSchemaValidator: new ResilientJsonSchemaValidator(),
4189
4378
  });
4190
4379
  info.transport = transport;
4191
- info.client = protectClient(client);
4380
+ info.client = protectPersonalClient(client, info, resolvedConfig);
4192
4381
  await client.connect(transport, resolvedConfig.options || {});
4193
4382
  const capabilities = client.getServerCapabilities();
4194
4383
  if (capabilities?.tools) {
4195
4384
  const { tools } = await client.listTools({}, resolvedConfig.options);
4196
4385
  info.tools = preserveListFreshness(tools, tools.map((tool) => normalizeToolForCache(name, tool)));
4386
+ recordCapabilitySnapshot(info, tools);
4197
4387
  }
4388
+ else {
4389
+ recordCapabilitySnapshot(info, []);
4390
+ }
4391
+ await flushCapabilitySnapshot(info);
4198
4392
  if (capabilities?.prompts) {
4199
4393
  const { prompts } = await client.listPrompts({}, resolvedConfig.options);
4200
4394
  info.prompts = preserveListFreshness(prompts, prompts.map((prompt) => normalizePromptForCache(name, prompt)));
@@ -4229,7 +4423,7 @@ const createPrincipalRuntime = async (name, resolvedConfig) => {
4229
4423
  throw failure;
4230
4424
  }
4231
4425
  };
4232
- 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));
4233
4427
  /**
4234
4428
  * Resolve the tools of a personal-credential server on behalf of an explicit
4235
4429
  * principal, outside any request context.
@@ -4246,12 +4440,112 @@ const principalRuntimes = new PrincipalRuntimeService(createPrincipalRuntime, cl
4246
4440
  export const getServerToolsForPrincipal = async (serverName, principal) => {
4247
4441
  const lease = await principalRuntimes.acquire(serverName, principal);
4248
4442
  try {
4443
+ await refreshModernServerLists(lease.info, ['tools']);
4249
4444
  return lease.info.tools ?? [];
4250
4445
  }
4251
4446
  finally {
4252
4447
  lease.release();
4253
4448
  }
4254
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
+ };
4255
4549
  const withPrincipalServers = (handler, operation) => async (request, extra) => {
4256
4550
  if (!serverInfos.some((info) => hasPersonalAuthentication(info.config)))
4257
4551
  return handler(request, extra);
@@ -4277,7 +4571,10 @@ const withPrincipalServers = (handler, operation) => async (request, extra) => {
4277
4571
  if (extra?.server && !target && (operation === 'tool' || operation === 'prompt')) {
4278
4572
  throw new ToolUnavailableError(`Tool not available: ${requestedName}`, 'server-or-name-not-found');
4279
4573
  }
4280
- if (!extra?.server && typeof requestedName === 'string') {
4574
+ if (!extra?.server &&
4575
+ typeof requestedName === 'string' &&
4576
+ !(operation === 'tool' &&
4577
+ [...serverConfigsByName.values()].some((config) => config.preserveToolNames))) {
4281
4578
  let longestPrefix = 0;
4282
4579
  for (const info of candidates) {
4283
4580
  const exposedName = getExposedServerName(info.name, serverConfigsByName.get(info.name));
@@ -4306,7 +4603,7 @@ const withPrincipalServers = (handler, operation) => async (request, extra) => {
4306
4603
  throw hasPersonalOAuth(info.config)
4307
4604
  ? personalAuthorizationRequired(info.name)
4308
4605
  : missingCredentialError(info.name);
4309
- const lease = await principalRuntimes.acquire(info.name, user);
4606
+ const lease = await principalRuntimes.acquire(info.name, user, operation === 'tools-list');
4310
4607
  releases.push(lease.release);
4311
4608
  scoped.set(info.name, lease.info);
4312
4609
  }
@@ -4317,7 +4614,10 @@ const withPrincipalServers = (handler, operation) => async (request, extra) => {
4317
4614
  }
4318
4615
  }));
4319
4616
  const requiredTarget = target ||
4320
- (candidates.length === 1 && operation !== 'list' && requestedName !== 'search_tools'
4617
+ (candidates.length === 1 &&
4618
+ operation !== 'list' &&
4619
+ operation !== 'tools-list' &&
4620
+ requestedName !== 'search_tools'
4321
4621
  ? templated[0]
4322
4622
  : undefined);
4323
4623
  const failure = requiredTarget ? failures.get(requiredTarget.name) : undefined;
@@ -4446,14 +4746,16 @@ const withCacheHints = (handler, passthroughTtl = false, listField) => async (re
4446
4746
  // and capabilities. Shared caches must not reuse the gateway result across users.
4447
4747
  return { ...result, ttlMs, cacheScope: 'private' };
4448
4748
  };
4449
- export const handleListToolsRequest = withPrincipalServers(withCacheHints(handleListToolsRequestImpl, false, 'tools'), 'list');
4450
- 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');
4451
4751
  export const handleCallToolRequest = (request, extra) => observeToolCall(() => callToolWithPrincipal(request, extra));
4452
- export const handleGetPromptRequest = withPrincipalServers(handleGetPromptRequestImpl, 'prompt');
4453
- export const handleListPromptsRequest = withPrincipalServers(withCacheHints(handleListPromptsRequestImpl, false, 'prompts'), 'list');
4454
- 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');
4455
4757
  export const handleListResourceTemplatesRequest = withPrincipalServers(withCacheHints(handleListResourceTemplatesRequestImpl, false, 'resourceTemplates'), 'list');
4456
- export const handleReadResourceRequest = withPrincipalServers(withCacheHints(handleReadResourceRequestImpl, true), 'resource');
4758
+ export const handleReadResourceRequest = withPrincipalServers(withModernLists(withCacheHints(handleReadResourceRequestImpl, true), ['resources']), 'resource');
4457
4759
  export const createMcpServer = (name, version, options) => {
4458
4760
  const normalizedOptions = typeof options === 'string' ? { group: options } : (options ?? {});
4459
4761
  // Determine server name based on routing type