@samanhappy/mcphub 1.0.35 → 1.0.37

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 (139) hide show
  1. package/README.fr.md +48 -17
  2. package/README.md +50 -39
  3. package/README.zh.md +50 -37
  4. package/dist/clients/openapi.js +32 -1
  5. package/dist/clients/openapi.js.map +1 -1
  6. package/dist/controllers/credentialBindingController.js +56 -0
  7. package/dist/controllers/credentialBindingController.js.map +1 -0
  8. package/dist/controllers/groupController.js +63 -14
  9. package/dist/controllers/groupController.js.map +1 -1
  10. package/dist/controllers/serverController.js +26 -4
  11. package/dist/controllers/serverController.js.map +1 -1
  12. package/dist/controllers/userController.js +3 -0
  13. package/dist/controllers/userController.js.map +1 -1
  14. package/dist/dao/CredentialBindingDao.js +48 -0
  15. package/dist/dao/CredentialBindingDao.js.map +1 -0
  16. package/dist/dao/CredentialBindingDaoDbImpl.js +20 -0
  17. package/dist/dao/CredentialBindingDaoDbImpl.js.map +1 -0
  18. package/dist/dao/DaoFactory.js +7 -0
  19. package/dist/dao/DaoFactory.js.map +1 -1
  20. package/dist/dao/DatabaseDaoFactory.js +4 -0
  21. package/dist/dao/DatabaseDaoFactory.js.map +1 -1
  22. package/dist/dao/GroupDaoDbImpl.js +18 -0
  23. package/dist/dao/GroupDaoDbImpl.js.map +1 -1
  24. package/dist/dao/ServerDaoDbImpl.js +3 -0
  25. package/dist/dao/ServerDaoDbImpl.js.map +1 -1
  26. package/dist/db/connection.js +3 -2
  27. package/dist/db/connection.js.map +1 -1
  28. package/dist/db/entities/CredentialBinding.js +33 -0
  29. package/dist/db/entities/CredentialBinding.js.map +1 -0
  30. package/dist/db/entities/Group.js +9 -1
  31. package/dist/db/entities/Group.js.map +1 -1
  32. package/dist/db/entities/Server.js +4 -0
  33. package/dist/db/entities/Server.js.map +1 -1
  34. package/dist/db/entities/index.js +3 -1
  35. package/dist/db/entities/index.js.map +1 -1
  36. package/dist/db/groupSchema.js +29 -0
  37. package/dist/db/groupSchema.js.map +1 -0
  38. package/dist/middlewares/auth.js +2 -0
  39. package/dist/middlewares/auth.js.map +1 -1
  40. package/dist/middlewares/i18n.js +2 -1
  41. package/dist/middlewares/i18n.js.map +1 -1
  42. package/dist/routes/index.js +19 -4
  43. package/dist/routes/index.js.map +1 -1
  44. package/dist/services/activityLoggingService.js +21 -8
  45. package/dist/services/activityLoggingService.js.map +1 -1
  46. package/dist/services/credentialBindingService.js +193 -0
  47. package/dist/services/credentialBindingService.js.map +1 -0
  48. package/dist/services/groupService.js +57 -23
  49. package/dist/services/groupService.js.map +1 -1
  50. package/dist/services/logService.js +11 -2
  51. package/dist/services/logService.js.map +1 -1
  52. package/dist/services/mcpService.js +478 -77
  53. package/dist/services/mcpService.js.map +1 -1
  54. package/dist/services/oauthClientRegistration.js +22 -7
  55. package/dist/services/oauthClientRegistration.js.map +1 -1
  56. package/dist/services/oauthSettingsStore.js +3 -0
  57. package/dist/services/oauthSettingsStore.js.map +1 -1
  58. package/dist/services/principalRuntimeService.js +109 -0
  59. package/dist/services/principalRuntimeService.js.map +1 -0
  60. package/dist/services/serverConfigPresenter.js +3 -0
  61. package/dist/services/serverConfigPresenter.js.map +1 -1
  62. package/dist/services/sseService.js +65 -16
  63. package/dist/services/sseService.js.map +1 -1
  64. package/dist/utils/credentialTemplate.js +38 -0
  65. package/dist/utils/credentialTemplate.js.map +1 -0
  66. package/dist/utils/groupAccess.js +17 -0
  67. package/dist/utils/groupAccess.js.map +1 -0
  68. package/dist/utils/migration.js +11 -0
  69. package/dist/utils/migration.js.map +1 -1
  70. package/dist/utils/rateLimit.js +85 -18
  71. package/dist/utils/rateLimit.js.map +1 -1
  72. package/dist/utils/serverConfigPersistence.js +2 -0
  73. package/dist/utils/serverConfigPersistence.js.map +1 -1
  74. package/frontend/dist/assets/{ActivityPage-B4OBJUBq.js → ActivityPage-Bx5-Jrxq.js} +2 -2
  75. package/frontend/dist/assets/{ActivityPage-B4OBJUBq.js.map → ActivityPage-Bx5-Jrxq.js.map} +1 -1
  76. package/frontend/dist/assets/{ConfirmDialog-3APts2Y9.js → ConfirmDialog-uWBwm8J1.js} +2 -2
  77. package/frontend/dist/assets/{ConfirmDialog-3APts2Y9.js.map → ConfirmDialog-uWBwm8J1.js.map} +1 -1
  78. package/frontend/dist/assets/CredentialsPage-BGbk_kBx.js +2 -0
  79. package/frontend/dist/assets/CredentialsPage-BGbk_kBx.js.map +1 -0
  80. package/frontend/dist/assets/{Dashboard-czP-DGHn.js → Dashboard-gHeXLPEn.js} +2 -2
  81. package/frontend/dist/assets/{Dashboard-czP-DGHn.js.map → Dashboard-gHeXLPEn.js.map} +1 -1
  82. package/frontend/dist/assets/{DeleteDialog-D9ycSRvn.js → DeleteDialog-BYhrMGZo.js} +2 -2
  83. package/frontend/dist/assets/{DeleteDialog-D9ycSRvn.js.map → DeleteDialog-BYhrMGZo.js.map} +1 -1
  84. package/frontend/dist/assets/{EndpointCopy-CDwtdCHE.js → EndpointCopy-BRwX7Dpv.js} +2 -2
  85. package/frontend/dist/assets/{EndpointCopy-CDwtdCHE.js.map → EndpointCopy-BRwX7Dpv.js.map} +1 -1
  86. package/frontend/dist/assets/GroupsPage-grCAx55k.js +33 -0
  87. package/frontend/dist/assets/GroupsPage-grCAx55k.js.map +1 -0
  88. package/frontend/dist/assets/{LoginPage-BBzViqLx.js → LoginPage-C7-m2jmB.js} +2 -2
  89. package/frontend/dist/assets/{LoginPage-BBzViqLx.js.map → LoginPage-C7-m2jmB.js.map} +1 -1
  90. package/frontend/dist/assets/{LogsPage-CqbaqFiQ.js → LogsPage-B00XTokA.js} +2 -2
  91. package/frontend/dist/assets/{LogsPage-CqbaqFiQ.js.map → LogsPage-B00XTokA.js.map} +1 -1
  92. package/frontend/dist/assets/{MarketPage-DinCKwrj.js → MarketPage-CVJMdSDD.js} +2 -2
  93. package/frontend/dist/assets/{MarketPage-DinCKwrj.js.map → MarketPage-CVJMdSDD.js.map} +1 -1
  94. package/frontend/dist/assets/{OAuthConsentPage-BuPcJ2zS.js → OAuthConsentPage-znW3HtpR.js} +2 -2
  95. package/frontend/dist/assets/{OAuthConsentPage-BuPcJ2zS.js.map → OAuthConsentPage-znW3HtpR.js.map} +1 -1
  96. package/frontend/dist/assets/{Pagination-Dwko3g48.js → Pagination-CLUvn47x.js} +2 -2
  97. package/frontend/dist/assets/{Pagination-Dwko3g48.js.map → Pagination-CLUvn47x.js.map} +1 -1
  98. package/frontend/dist/assets/{PromptsPage-CJ-wYRAL.js → PromptsPage-DGcNS3fO.js} +2 -2
  99. package/frontend/dist/assets/{PromptsPage-CJ-wYRAL.js.map → PromptsPage-DGcNS3fO.js.map} +1 -1
  100. package/frontend/dist/assets/{ResourcesPage-DRV8gnZV.js → ResourcesPage-CfyZoadu.js} +2 -2
  101. package/frontend/dist/assets/{ResourcesPage-DRV8gnZV.js.map → ResourcesPage-CfyZoadu.js.map} +1 -1
  102. package/frontend/dist/assets/ServersPage-BlC0RdIu.js +39 -0
  103. package/frontend/dist/assets/ServersPage-BlC0RdIu.js.map +1 -0
  104. package/frontend/dist/assets/{SettingsPage-Czv4OhtK.js → SettingsPage-CiVNDyr_.js} +2 -2
  105. package/frontend/dist/assets/{SettingsPage-Czv4OhtK.js.map → SettingsPage-CiVNDyr_.js.map} +1 -1
  106. package/frontend/dist/assets/{StatusDot-CwpmIlJi.js → StatusDot-DYO56shE.js} +2 -2
  107. package/frontend/dist/assets/{StatusDot-CwpmIlJi.js.map → StatusDot-DYO56shE.js.map} +1 -1
  108. package/frontend/dist/assets/{ToggleGroup-1qFTkDqh.js → ToggleGroup-r8yGt_Fq.js} +2 -2
  109. package/frontend/dist/assets/{ToggleGroup-1qFTkDqh.js.map → ToggleGroup-r8yGt_Fq.js.map} +1 -1
  110. package/frontend/dist/assets/{UsersPage-CAMXgywh.js → UsersPage-B3muXE90.js} +2 -2
  111. package/frontend/dist/assets/{UsersPage-CAMXgywh.js.map → UsersPage-B3muXE90.js.map} +1 -1
  112. package/frontend/dist/assets/{framework-vendor-BHAQX05P.js → framework-vendor-_YL27Pfc.js} +2 -2
  113. package/frontend/dist/assets/{framework-vendor-BHAQX05P.js.map → framework-vendor-_YL27Pfc.js.map} +1 -1
  114. package/frontend/dist/assets/{i18n-vendor-CMcB7XUR.js → i18n-vendor-BMaj5qV8.js} +2 -2
  115. package/frontend/dist/assets/{i18n-vendor-CMcB7XUR.js.map → i18n-vendor-BMaj5qV8.js.map} +1 -1
  116. package/frontend/dist/assets/{icons-vendor-HZHHWeTa.js → icons-vendor-VW-UwEhw.js} +3 -3
  117. package/frontend/dist/assets/{icons-vendor-HZHHWeTa.js.map → icons-vendor-VW-UwEhw.js.map} +1 -1
  118. package/frontend/dist/assets/index-B-aT0HW5.js +3 -0
  119. package/frontend/dist/assets/index-B-aT0HW5.js.map +1 -0
  120. package/frontend/dist/assets/{index-DtrCVU1h.css → index-B3OqAhGz.css} +1 -1
  121. package/frontend/dist/assets/{resourceService-BTVgor9F.js → resourceService-CSXOSi7M.js} +2 -2
  122. package/frontend/dist/assets/{resourceService-BTVgor9F.js.map → resourceService-CSXOSi7M.js.map} +1 -1
  123. package/frontend/dist/assets/{useCostData-CDVGUx_e.js → useCostData-BOxUFQAH.js} +2 -2
  124. package/frontend/dist/assets/{useCostData-CDVGUx_e.js.map → useCostData-BOxUFQAH.js.map} +1 -1
  125. package/frontend/dist/assets/useSettingsData-DmpLlRI6.js +2 -0
  126. package/frontend/dist/assets/{useSettingsData-92nglN_X.js.map → useSettingsData-DmpLlRI6.js.map} +1 -1
  127. package/frontend/dist/assets/variableDetection-cmwwuSWB.js +16 -0
  128. package/frontend/dist/assets/variableDetection-cmwwuSWB.js.map +1 -0
  129. package/frontend/dist/index.html +5 -5
  130. package/package.json +4 -5
  131. package/frontend/dist/assets/GroupsPage-BgUHjkGb.js +0 -33
  132. package/frontend/dist/assets/GroupsPage-BgUHjkGb.js.map +0 -1
  133. package/frontend/dist/assets/ServersPage-CbtcAcVH.js +0 -39
  134. package/frontend/dist/assets/ServersPage-CbtcAcVH.js.map +0 -1
  135. package/frontend/dist/assets/index-Cb6IBpZR.js +0 -3
  136. package/frontend/dist/assets/index-Cb6IBpZR.js.map +0 -1
  137. package/frontend/dist/assets/useSettingsData-92nglN_X.js +0 -2
  138. package/frontend/dist/assets/variableDetection-BlOwsGsz.js +0 -16
  139. package/frontend/dist/assets/variableDetection-BlOwsGsz.js.map +0 -1
@@ -1,3 +1,9 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { canAccessGroupRoute } from '../utils/groupAccess.js';
3
+ import { PrincipalRuntimeService } from './principalRuntimeService.js';
4
+ import { UserContextService } from './userContextService.js';
5
+ import { deleteCredentialBindings, missingCredentialError } from './credentialBindingService.js';
6
+ import { CredentialBindingError, hasCredentialTemplate, validateCredentialTemplate } from '../utils/credentialTemplate.js';
1
7
  import os from 'os';
2
8
  import path from 'path';
3
9
  import fs from 'fs';
@@ -519,8 +525,14 @@ const normalizeResourceForCache = (resource) => {
519
525
  };
520
526
  // Store all server information
521
527
  let serverInfos = [];
528
+ // Bumped by every initializeClientsFromSettings pass. Connections are started
529
+ // fire-and-forget and settle long after the pass that started them returned, so
530
+ // a callback needs to know whether a later pass has run before it writes its
531
+ // result anywhere. See the `liveServerInfo` helper in that function.
532
+ let initGeneration = 0;
533
+ const principalServerScope = new AsyncLocalStorage();
522
534
  const getVisibleServerInfos = () => {
523
- return getDataService().filterData(serverInfos);
535
+ return getDataService().filterData(principalServerScope.getStore() ?? serverInfos);
524
536
  };
525
537
  const getVisibleServerByName = (name) => {
526
538
  return getVisibleServerInfos().find((serverInfo) => serverInfo.name === name);
@@ -656,7 +668,8 @@ export const summarizeServerConnections = (infos) => {
656
668
  };
657
669
  };
658
670
  export const getServerConnectionStats = () => {
659
- return summarizeServerConnections(serverInfos);
671
+ // Personal definitions have no shared connection whose readiness can be checked.
672
+ return summarizeServerConnections(serverInfos.filter((info) => !hasCredentialTemplate(info.config)));
660
673
  };
661
674
  // Returns true if all enabled servers are connected
662
675
  export const connected = () => {
@@ -665,6 +678,7 @@ export const connected = () => {
665
678
  };
666
679
  // Global cleanup function to close all connections
667
680
  export const cleanupAllServers = () => {
681
+ principalRuntimes.invalidate();
668
682
  for (const serverInfo of serverInfos) {
669
683
  try {
670
684
  if (serverInfo.client) {
@@ -927,8 +941,10 @@ export const createTransportFromConfig = async (name, conf) => {
927
941
  let transport;
928
942
  const env = {
929
943
  ...process.env,
930
- ...replaceEnvVars(conf.env || {}),
944
+ ...(hasCredentialTemplate(conf) ? conf.env : replaceEnvVars(conf.env || {})),
931
945
  };
946
+ // The encryption key belongs only to the hub, never to a child process.
947
+ delete env.MCPHUB_CREDENTIAL_ENCRYPTION_KEY;
932
948
  // SSRF guard: block URL/streamable-http transports from reaching
933
949
  // loopback / RFC1918 / link-local targets (e.g. cloud metadata service).
934
950
  // Admin-owned servers may legitimately target internal services, so they
@@ -941,11 +957,11 @@ export const createTransportFromConfig = async (name, conf) => {
941
957
  }
942
958
  if (conf.type === 'streamable-http') {
943
959
  const options = {};
944
- let headers = conf.headers ? replaceEnvVars(conf.headers, env) : {};
960
+ let headers = conf.headers ? (hasCredentialTemplate(conf) ? conf.headers : replaceEnvVars(conf.headers, env)) : {};
945
961
  const baseFetch = createAbortIsolatingFetch(createFetchWithProxy(getProxyConfigFromEnv(env)));
946
962
  const requestAwareFetch = createRedirectValidatingFetch(createRequestContextAwareFetch(baseFetch, conf.passthroughHeaders), allowInternal);
947
963
  // Create OAuth provider if configured - SDK will handle authentication automatically
948
- const authProvider = await createOAuthProvider(name, conf);
964
+ const authProvider = hasCredentialTemplate(conf) ? undefined : await createOAuthProvider(name, conf);
949
965
  if (authProvider) {
950
966
  options.authProvider = authProvider;
951
967
  // When the OAuth provider is active, strip any static Authorization
@@ -968,11 +984,11 @@ export const createTransportFromConfig = async (name, conf) => {
968
984
  else if (conf.url) {
969
985
  // SSE transport
970
986
  const options = {};
971
- let headers = conf.headers ? replaceEnvVars(conf.headers, env) : {};
987
+ let headers = conf.headers ? (hasCredentialTemplate(conf) ? conf.headers : replaceEnvVars(conf.headers, env)) : {};
972
988
  const baseFetch = createAbortIsolatingFetch(createFetchWithProxy(getProxyConfigFromEnv(env)));
973
989
  const requestAwareFetch = createRedirectValidatingFetch(createRequestContextAwareFetch(baseFetch, conf.passthroughHeaders), allowInternal);
974
990
  // Create OAuth provider if configured - SDK will handle authentication automatically
975
- const authProvider = await createOAuthProvider(name, conf);
991
+ const authProvider = hasCredentialTemplate(conf) ? undefined : await createOAuthProvider(name, conf);
976
992
  if (authProvider) {
977
993
  options.authProvider = authProvider;
978
994
  // Drop static Authorization header when OAuth manages auth (see above).
@@ -1032,7 +1048,10 @@ export const createTransportFromConfig = async (name, conf) => {
1032
1048
  env: env,
1033
1049
  stderr: 'pipe',
1034
1050
  });
1035
- if (transport.stderr) {
1051
+ if (transport.stderr && hasCredentialTemplate(conf)) {
1052
+ transport.stderr.on('data', () => undefined); // Personal upstream diagnostics can contain raw credentials.
1053
+ }
1054
+ else if (transport.stderr) {
1036
1055
  observeStdioStderr(transport, transport.stderr, (message) => {
1037
1056
  logger.log('Upstream server stderr', JSON.stringify({
1038
1057
  serverName: name,
@@ -1056,6 +1075,9 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1056
1075
  if (!client) {
1057
1076
  throw new Error(`Client not found for server: ${serverInfo.name}`);
1058
1077
  }
1078
+ if (hasCredentialTemplate(serverInfo.config)) {
1079
+ return client.callTool(toolParams, undefined, options || {});
1080
+ }
1059
1081
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1060
1082
  try {
1061
1083
  const result = await client.callTool(toolParams, undefined, options || {});
@@ -1183,6 +1205,7 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1183
1205
  const allServers = await getServerDao().findAll();
1184
1206
  const existingServerInfos = serverInfos;
1185
1207
  const nextServerInfos = [];
1208
+ const generation = ++initGeneration;
1186
1209
  try {
1187
1210
  for (const conf of allServers) {
1188
1211
  const { name } = conf;
@@ -1215,6 +1238,16 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1215
1238
  });
1216
1239
  continue;
1217
1240
  }
1241
+ if (hasCredentialTemplate(expandedConf)) {
1242
+ validateCredentialTemplate(expandedConf);
1243
+ nextServerInfos.push({
1244
+ name, owner: expandedConf.owner, visibility: expandedConf.visibility,
1245
+ sharedWithUsers: expandedConf.sharedWithUsers, enabled: true,
1246
+ status: 'disconnected', error: null, tools: [], prompts: [], resources: [],
1247
+ createTime: Date.now(), config: expandedConf,
1248
+ });
1249
+ continue;
1250
+ }
1218
1251
  // On-demand stdio servers: skip startup connect; spawn lazily on first
1219
1252
  // tool call. startOnDemand is a stdio-only optimisation - HTTP/SSE and
1220
1253
  // OpenAPI servers have no deferred child process, so they connect normally.
@@ -1222,23 +1255,25 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1222
1255
  // connection) so the tool list stays visible to agents even while sleeping.
1223
1256
  if (expandedConf.startOnDemand === true && isStdioServer(expandedConf)) {
1224
1257
  const existingOnDemand = existingServerInfos.find((s) => s.name === name);
1225
- nextServerInfos.push({
1226
- // Carry over cached tools/prompts/resources if the server was previously connected
1227
- ...(existingOnDemand ?? {
1228
- name,
1229
- status: 'disconnected',
1230
- error: null,
1231
- tools: [],
1232
- prompts: [],
1233
- resources: [],
1234
- createTime: Date.now(),
1235
- }),
1258
+ // Active calls, spawn promises and idle callbacks must share the same
1259
+ // runtime object across reloads, not copies of its mutable state.
1260
+ const onDemandInfo = existingOnDemand ?? {
1261
+ name,
1262
+ status: 'disconnected',
1263
+ error: null,
1264
+ tools: [],
1265
+ prompts: [],
1266
+ resources: [],
1267
+ createTime: Date.now(),
1268
+ };
1269
+ Object.assign(onDemandInfo, {
1236
1270
  owner: expandedConf.owner,
1237
1271
  visibility: expandedConf.visibility,
1238
1272
  sharedWithUsers: expandedConf.sharedWithUsers,
1239
1273
  enabled: true,
1240
1274
  config: expandedConf,
1241
1275
  });
1276
+ nextServerInfos.push(onDemandInfo);
1242
1277
  logger.log(`Skipping startup connect for on-demand server: ${name}`);
1243
1278
  continue;
1244
1279
  }
@@ -1371,7 +1406,36 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1371
1406
  }
1372
1407
  }
1373
1408
  else {
1374
- transport = await createTransportFromConfig(name, expandedConf);
1409
+ try {
1410
+ transport = await createTransportFromConfig(name, expandedConf);
1411
+ }
1412
+ catch (error) {
1413
+ // Fail this server only. Letting the error escape the loop hits the
1414
+ // outer catch, which restores `serverInfos` to its previous value -
1415
+ // empty at startup - so a single malformed config (an unparseable URL
1416
+ // rejected by assertSafeUrl, say) would drop every server from
1417
+ // getServersInfo and leave the whole fleet reporting 'connecting'.
1418
+ // The OpenAPI branch above already isolates its failures this way.
1419
+ logger.error('Failed to create transport for server', {
1420
+ serverName: name,
1421
+ error: summarizeErrorForLogging(error),
1422
+ });
1423
+ nextServerInfos.push({
1424
+ name,
1425
+ owner: expandedConf.owner,
1426
+ visibility: expandedConf.visibility,
1427
+ sharedWithUsers: expandedConf.sharedWithUsers,
1428
+ status: 'disconnected',
1429
+ error: `Failed to create transport: ${formatErrorForLogging(error)}`,
1430
+ tools: [],
1431
+ prompts: [],
1432
+ resources: [],
1433
+ createTime: Date.now(),
1434
+ enabled: true,
1435
+ config: expandedConf,
1436
+ });
1437
+ continue;
1438
+ }
1375
1439
  }
1376
1440
  const serverInfoRef = {};
1377
1441
  const client = createUpstreamMcpClient(name, () => serverInfoRef.current);
@@ -1417,12 +1481,39 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1417
1481
  };
1418
1482
  }
1419
1483
  nextServerInfos.push(serverInfo);
1484
+ // The connect below settles long after this loop returns. If another
1485
+ // initialization pass has run by then, `serverInfo` may no longer be the
1486
+ // object `serverInfos` holds for this server - even when the reuse guard
1487
+ // "preserved" it, because preservation spreads the entry into a fresh
1488
+ // object. Writing the result into a replaced object leaves the server
1489
+ // stuck on 'connecting' for good while the log reports a successful
1490
+ // connect and a full tool list.
1491
+ //
1492
+ // While this pass is still the newest one, `serverInfo` is the entry that
1493
+ // will be published, so it is the right target even before the swap. Once
1494
+ // a later pass has run, resolve the live entry by name and only apply the
1495
+ // result if it still owns this client - which is exactly the case where
1496
+ // the later pass preserved this server rather than reconnecting it.
1497
+ const liveServerInfo = () => {
1498
+ if (generation === initGeneration)
1499
+ return serverInfo;
1500
+ const current = serverInfos.find((si) => si.name === name);
1501
+ return current && current.client === client ? current : undefined;
1502
+ };
1420
1503
  connectClientWithDiagnostics(client, transport, initRequestOptions || requestOptions)
1421
1504
  .then(() => {
1505
+ const info = liveServerInfo();
1506
+ if (!info) {
1507
+ // Nothing will ever read this connection again, so shut it down
1508
+ // rather than leaving its stdio child process running.
1509
+ logger.log(`Discarding superseded connection result for server: ${name}`);
1510
+ closeServerRuntime(serverInfo);
1511
+ return;
1512
+ }
1422
1513
  logger.log(`Successfully connected client for server: ${name}`);
1423
1514
  const serverVersion = client.getServerVersion?.();
1424
- serverInfo.version = serverVersion?.version;
1425
- serverInfo.instructions = client.getInstructions?.();
1515
+ info.version = serverVersion?.version;
1516
+ info.instructions = client.getInstructions?.();
1426
1517
  const capabilities = client.getServerCapabilities();
1427
1518
  logger.log('Server capabilities', JSON.stringify(capabilities));
1428
1519
  let dataError = null;
@@ -1430,8 +1521,11 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1430
1521
  client
1431
1522
  .listTools({}, initRequestOptions || requestOptions)
1432
1523
  .then((tools) => {
1524
+ const target = liveServerInfo();
1525
+ if (!target)
1526
+ return;
1433
1527
  logger.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
1434
- updateServerToolsCache(serverInfo, tools.tools, {
1528
+ updateServerToolsCache(target, tools.tools, {
1435
1529
  reportEmbeddingProgress: options?.reportEmbeddingProgress === true && serverName === name,
1436
1530
  });
1437
1531
  // Broadcast only after tools are actually loaded into the cache.
@@ -1452,8 +1546,11 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1452
1546
  client
1453
1547
  .listPrompts({}, initRequestOptions || requestOptions)
1454
1548
  .then((prompts) => {
1549
+ const target = liveServerInfo();
1550
+ if (!target)
1551
+ return;
1455
1552
  logger.log(`Successfully listed ${prompts.prompts.length} prompts for server: ${name}`);
1456
- updateServerPromptsCache(serverInfo, prompts.prompts);
1553
+ updateServerPromptsCache(target, prompts.prompts);
1457
1554
  broadcastPromptListChanged();
1458
1555
  })
1459
1556
  .catch((error) => {
@@ -1468,8 +1565,11 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1468
1565
  client
1469
1566
  .listResources({}, initRequestOptions || requestOptions)
1470
1567
  .then((resources) => {
1568
+ const target = liveServerInfo();
1569
+ if (!target)
1570
+ return;
1471
1571
  logger.log(`Successfully listed ${resources.resources.length} resources for server: ${name}`);
1472
- updateServerResourcesCache(serverInfo, resources.resources);
1572
+ updateServerResourcesCache(target, resources.resources);
1473
1573
  broadcastResourceListChanged();
1474
1574
  })
1475
1575
  .catch((error) => {
@@ -1481,18 +1581,24 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1481
1581
  });
1482
1582
  }
1483
1583
  if (!dataError) {
1484
- serverInfo.status = 'connected';
1485
- serverInfo.error = null;
1584
+ info.status = 'connected';
1585
+ info.error = null;
1486
1586
  // Set up keep-alive ping for SSE connections via shared service
1487
- setupServerKeepAlive(serverInfo, expandedConf);
1587
+ setupServerKeepAlive(info, expandedConf);
1488
1588
  }
1489
1589
  else {
1490
- serverInfo.status = 'disconnected';
1491
- serverInfo.error = `Failed to list data: ${formatErrorForLogging(dataError)}`;
1492
- setupServerKeepAlive(serverInfo, expandedConf);
1590
+ info.status = 'disconnected';
1591
+ info.error = `Failed to list data: ${formatErrorForLogging(dataError)}`;
1592
+ setupServerKeepAlive(info, expandedConf);
1493
1593
  }
1494
1594
  })
1495
1595
  .catch(async (error) => {
1596
+ const info = liveServerInfo();
1597
+ if (!info) {
1598
+ logger.log(`Discarding superseded connection failure for server: ${name}`);
1599
+ closeServerRuntime(serverInfo);
1600
+ return;
1601
+ }
1496
1602
  // Check if this is an OAuth authorization error
1497
1603
  const isOAuthError = error?.message?.includes('OAuth authorization required') ||
1498
1604
  error?.message?.includes('Authorization required');
@@ -1501,10 +1607,10 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1501
1607
  // and stored the authorization URL in serverInfo.oauth
1502
1608
  logger.log(`OAuth authorization required for server ${name}. Status should be set to 'oauth_required'.`);
1503
1609
  // Make sure status is set correctly
1504
- if (serverInfo.status !== 'oauth_required') {
1505
- serverInfo.status = 'oauth_required';
1610
+ if (info.status !== 'oauth_required') {
1611
+ info.status = 'oauth_required';
1506
1612
  }
1507
- serverInfo.error = null;
1613
+ info.error = null;
1508
1614
  }
1509
1615
  else {
1510
1616
  logger.error('Failed to connect client for server', {
@@ -1512,9 +1618,9 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1512
1618
  error: summarizeErrorForLogging(error),
1513
1619
  });
1514
1620
  // Other connection errors
1515
- serverInfo.status = 'disconnected';
1516
- serverInfo.error = `Failed to connect: ${formatErrorForLogging(error)}`;
1517
- setupServerKeepAlive(serverInfo, expandedConf);
1621
+ info.status = 'disconnected';
1622
+ info.error = `Failed to connect: ${formatErrorForLogging(error)}`;
1623
+ setupServerKeepAlive(info, expandedConf);
1518
1624
  }
1519
1625
  });
1520
1626
  logger.log(`Initialized client for server: ${name}`);
@@ -1596,16 +1702,47 @@ export const getServersInfo = async (page, limit, user) => {
1596
1702
  const filterServerInfos = shouldApplyUserFilter && dataService.filterData
1597
1703
  ? dataService.filterData(filteredServerInfos, user)
1598
1704
  : filteredServerInfos;
1599
- const infos = filterServerInfos
1705
+ const principal = (user ||
1706
+ UserContextService.getInstance().getCurrentUser());
1707
+ const presentedInfos = await Promise.all(filterServerInfos.map(async (info) => {
1708
+ if (!hasCredentialTemplate(info.config) ||
1709
+ info.enabled === false ||
1710
+ !principal?.username ||
1711
+ principal.credentialEligible === false)
1712
+ return info;
1713
+ try {
1714
+ const lease = await principalRuntimes.acquire(info.name, principal);
1715
+ try {
1716
+ return { ...lease.info, config: info.config };
1717
+ }
1718
+ finally {
1719
+ lease.release();
1720
+ }
1721
+ }
1722
+ catch (error) {
1723
+ return {
1724
+ ...info,
1725
+ error: error instanceof CredentialBindingError
1726
+ ? error.message
1727
+ : 'Unable to resolve personal credentials',
1728
+ };
1729
+ }
1730
+ }));
1731
+ const infos = presentedInfos
1600
1732
  .filter((info) => requestedServerNames.has(info.name)) // Only include requested servers
1601
1733
  .map(({ name, version, instructions, owner, visibility, status, tools, prompts, resources, createTime, error, oauth, }) => {
1602
1734
  const serverConfig = allServers.find((server) => server.name === name);
1603
1735
  const enabled = serverConfig ? serverConfig.enabled !== false : true;
1604
1736
  const resolvedType = inferServerType(serverConfig);
1605
1737
  const oauthConnected = Boolean(serverConfig?.oauth?.accessToken || serverConfig?.oauth?.refreshToken);
1606
- // Add enabled status and custom description to each tool
1738
+ // Add enabled status and custom description to each tool.
1739
+ // toggleTool stores under whatever key the caller passed (prefixed,
1740
+ // as the dashboard sends, or bare, as a direct API call may) with no
1741
+ // normalization — check both so the dashboard reflects a disable set
1742
+ // either way instead of always showing enabled:true for one of them.
1607
1743
  const toolsWithEnabled = tools.map((tool) => {
1608
- const toolConfig = serverConfig?.tools?.[tool.name];
1744
+ const bareToolName = normalizeToolNameForServer(name, tool.name);
1745
+ const toolConfig = serverConfig?.tools?.[tool.name] ?? serverConfig?.tools?.[bareToolName];
1609
1746
  return buildToolWithDescriptionMetadata(tool, toolConfig);
1610
1747
  });
1611
1748
  const promptsWithEnabled = prompts.map((prompt) => {
@@ -1653,6 +1790,7 @@ export const getServersInfo = async (page, limit, user) => {
1653
1790
  config: resolvedType || serverConfig?.description || serverConfig?.command
1654
1791
  ? {
1655
1792
  ...(resolvedType ? { type: resolvedType } : {}),
1793
+ ...(hasCredentialTemplate(serverConfig) ? { credentialTemplate: validateCredentialTemplate(serverConfig) } : {}),
1656
1794
  ...(serverConfig?.description ? { description: serverConfig.description } : {}),
1657
1795
  // Expose command so the frontend can determine if reinstall is
1658
1796
  // supported (npx/uvx only). This is not a secret — it's the
@@ -1679,6 +1817,7 @@ export const getServerByOAuthState = (state) => {
1679
1817
  */
1680
1818
  export const reconnectServer = async (serverName) => {
1681
1819
  logger.log(`Reconnecting server: ${serverName}`);
1820
+ principalRuntimes.invalidate({ serverName });
1682
1821
  const serverInfo = getServerByName(serverName);
1683
1822
  if (!serverInfo) {
1684
1823
  throw new Error(`Server not found: ${serverName}`);
@@ -1757,7 +1896,11 @@ const filterToolsByConfig = async (serverName, tools) => {
1757
1896
  return tools;
1758
1897
  }
1759
1898
  return tools.filter((tool) => {
1760
- const toolConfig = serverConfig.tools?.[tool.name];
1899
+ // toggleTool stores the disable under whatever key string the caller used
1900
+ // (bare upstream name or server-prefixed name, never normalized) — check
1901
+ // both, same as findToolOnServer's execution-time gate.
1902
+ const bareToolName = normalizeToolNameForServer(serverName, tool.name);
1903
+ const toolConfig = serverConfig.tools?.[tool.name] ?? serverConfig.tools?.[bareToolName];
1761
1904
  // If tool is not in config, it's enabled by default
1762
1905
  return toolConfig?.enabled !== false;
1763
1906
  });
@@ -1804,6 +1947,7 @@ const classifyUnavailableReason = (qualifiedName) => {
1804
1947
  };
1805
1948
  // Add new server
1806
1949
  export const addServer = async (name, config) => {
1950
+ validateCredentialTemplate(config);
1807
1951
  const server = { name, ...config };
1808
1952
  const result = await getServerDao().create(server);
1809
1953
  if (result) {
@@ -1824,6 +1968,7 @@ export const removeServer = async (name) => {
1824
1968
  // via npx / npm exec outlives the request and becomes an unkillable orphan
1825
1969
  // that leaks memory until the container is restarted.
1826
1970
  closeServer(name);
1971
+ await deleteCredentialBindings({ serverName: name });
1827
1972
  try {
1828
1973
  await removeServerToolEmbeddings(name);
1829
1974
  }
@@ -1836,6 +1981,7 @@ export const removeServer = async (name) => {
1836
1981
  // Add or update server (supports overriding existing servers for MCPB)
1837
1982
  export const addOrUpdateServer = async (name, config, allowOverride = false) => {
1838
1983
  try {
1984
+ validateCredentialTemplate(config);
1839
1985
  const exists = await getServerDao().exists(name);
1840
1986
  if (exists && !allowOverride) {
1841
1987
  return { success: false, message: 'Server name already exists' };
@@ -1883,7 +2029,14 @@ function checkAuthError(result) {
1883
2029
  }
1884
2030
  }
1885
2031
  }
2032
+ const clearIdleShutdown = (serverInfo) => {
2033
+ if (serverInfo.idleTimeoutId) {
2034
+ clearTimeout(serverInfo.idleTimeoutId);
2035
+ serverInfo.idleTimeoutId = undefined;
2036
+ }
2037
+ };
1886
2038
  const closeServerRuntime = (serverInfo) => {
2039
+ clearIdleShutdown(serverInfo);
1887
2040
  if (serverInfo.keepAliveIntervalId) {
1888
2041
  clearInterval(serverInfo.keepAliveIntervalId);
1889
2042
  serverInfo.keepAliveIntervalId = undefined;
@@ -1917,6 +2070,7 @@ const closeServerRuntime = (serverInfo) => {
1917
2070
  // Close server client and transport (keeps the entry in serverInfos; the next
1918
2071
  // initializeClientsFromSettings rebuild drops it if it is no longer configured).
1919
2072
  export function closeServer(name) {
2073
+ principalRuntimes.invalidate({ serverName: name });
1920
2074
  const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
1921
2075
  if (serverInfo) {
1922
2076
  closeServerRuntime(serverInfo);
@@ -1931,10 +2085,9 @@ export function closeServer(name) {
1931
2085
  * the server offers and trigger a cold-start on the next tool call.
1932
2086
  */
1933
2087
  const shutdownOnDemandServer = (serverInfo) => {
1934
- if (serverInfo.idleTimeoutId) {
1935
- clearTimeout(serverInfo.idleTimeoutId);
1936
- serverInfo.idleTimeoutId = undefined;
1937
- }
2088
+ // Discovery priming may finish while a tool call is using the same child.
2089
+ if (serverInfo.activeToolCalls)
2090
+ return;
1938
2091
  // Close runtime (kills process) but intentionally keep tools/prompts/resources
1939
2092
  closeServerRuntime(serverInfo);
1940
2093
  serverInfo.status = 'disconnected';
@@ -1942,14 +2095,17 @@ const shutdownOnDemandServer = (serverInfo) => {
1942
2095
  `${serverInfo.tools.length} tools cached for next wake-up`);
1943
2096
  };
1944
2097
  /**
1945
- * Resets (or starts) the idle-shutdown timer for an on-demand server.
1946
- * Call this after every successful tool invocation.
2098
+ * Starts the idle period once the last active call has finished.
1947
2099
  */
1948
2100
  const scheduleIdleShutdown = (serverInfo) => {
1949
- if (!serverInfo.config?.startOnDemand)
2101
+ clearIdleShutdown(serverInfo);
2102
+ if (!serverInfo.config?.startOnDemand ||
2103
+ !isStdioServer(serverInfo.config) ||
2104
+ hasCredentialTemplate(serverInfo.config) ||
2105
+ serverInfo.status !== 'connected' ||
2106
+ !serverInfo.client ||
2107
+ serverInfo.activeToolCalls) {
1950
2108
  return;
1951
- if (serverInfo.idleTimeoutId) {
1952
- clearTimeout(serverInfo.idleTimeoutId);
1953
2109
  }
1954
2110
  const idleMs = serverInfo.config.idleTimeoutMs ?? 300000; // default 5 minutes
1955
2111
  serverInfo.idleTimeoutId = setTimeout(() => {
@@ -1958,6 +2114,19 @@ const scheduleIdleShutdown = (serverInfo) => {
1958
2114
  }
1959
2115
  }, idleMs);
1960
2116
  };
2117
+ const beginOnDemandToolCall = (serverInfo) => {
2118
+ if (!serverInfo.config?.startOnDemand ||
2119
+ !isStdioServer(serverInfo.config) ||
2120
+ hasCredentialTemplate(serverInfo.config)) {
2121
+ return undefined;
2122
+ }
2123
+ clearIdleShutdown(serverInfo);
2124
+ serverInfo.activeToolCalls = (serverInfo.activeToolCalls ?? 0) + 1;
2125
+ return () => {
2126
+ serverInfo.activeToolCalls = serverInfo.activeToolCalls - 1;
2127
+ scheduleIdleShutdown(serverInfo);
2128
+ };
2129
+ };
1961
2130
  /**
1962
2131
  * Ensures an on-demand server is connected before a tool call proceeds.
1963
2132
  *
@@ -2094,6 +2263,7 @@ const isStdioServer = (conf) => !!conf && (conf.type === 'stdio' || (!conf.type
2094
2263
  */
2095
2264
  const primeOnDemandServers = () => {
2096
2265
  const targets = serverInfos.filter((si) => si.config?.startOnDemand === true &&
2266
+ !hasCredentialTemplate(si.config) &&
2097
2267
  isStdioServer(si.config) &&
2098
2268
  si.tools.length === 0 &&
2099
2269
  si.enabled !== false);
@@ -2103,9 +2273,9 @@ const primeOnDemandServers = () => {
2103
2273
  return Promise.allSettled(targets.map(async (si) => {
2104
2274
  try {
2105
2275
  await ensureServerReady(si);
2106
- // Sleep the child but keep the cached tool list visible to agents.
2276
+ // Sleep the child unless a tool call is using it; keep the cached tool list.
2107
2277
  shutdownOnDemandServer(si);
2108
- logger.log('On-demand server primed and asleep');
2278
+ logger.log('On-demand server tool cache primed');
2109
2279
  }
2110
2280
  catch (error) {
2111
2281
  logger.warn('Failed to prime on-demand server', {
@@ -2322,20 +2492,54 @@ const resolveNameFromGroup = (name, serverName, serverConfig) => {
2322
2492
  return replacePrefixedServerName(name, getExposedServerName(serverName, serverConfig), serverName);
2323
2493
  };
2324
2494
  const findToolOnServer = (serverInfo, toolName, allowRawName) => {
2325
- return serverInfo.tools.find((tool) => tool.name === toolName ||
2495
+ const tool = serverInfo.tools.find((tool) => tool.name === toolName ||
2326
2496
  (allowRawName && normalizeToolNameForServer(serverInfo.name, tool.name) === toolName));
2497
+ if (!tool) {
2498
+ return undefined;
2499
+ }
2500
+ // A tool disabled via POST /api/servers/:server/tools/:tool/toggle must be
2501
+ // unresolvable for execution, not just hidden from tools/list — otherwise a
2502
+ // caller who already knows the tool name can invoke it regardless of the
2503
+ // disabled flag.
2504
+ //
2505
+ // The runtime tool cache (serverInfo.tools, built by normalizeToolForCache)
2506
+ // never carries an `enabled` field — that field only exists on the
2507
+ // *projection* built for the dashboard/API (buildToolWithDescriptionMetadata),
2508
+ // which is a different object. So the persisted per-tool config must be
2509
+ // consulted directly here.
2510
+ //
2511
+ // toggleTool (serverController.ts) stores `tools[toolName] = ...` using
2512
+ // whatever string the caller passed as :toolName, with no normalization.
2513
+ // The dashboard sends the already-prefixed `tool.name`, matching what
2514
+ // getServersInfo's toolsWithEnabled already reads — but any other caller
2515
+ // (REST API used directly, scripts, docs examples) can just as validly
2516
+ // pass the bare upstream name, which then never matches a prefixed-only
2517
+ // lookup. Check both so a disable takes effect regardless of which form
2518
+ // was used to set it.
2519
+ const bareToolName = normalizeToolNameForServer(serverInfo.name, tool.name);
2520
+ const toolConfig = serverInfo.config?.tools?.[tool.name] ?? serverInfo.config?.tools?.[bareToolName];
2521
+ if (toolConfig && toolConfig.enabled === false) {
2522
+ return undefined;
2523
+ }
2524
+ return tool;
2327
2525
  };
2328
2526
  const assertToolAvailableForRoute = (tool, appsRouteContext) => {
2329
2527
  if (isAppOnlyTool(tool) && !appsRouteContext.enabled) {
2330
2528
  throw new Error(`Tool '${tool.name}' is only available to MCP Apps`);
2331
2529
  }
2332
2530
  };
2333
- const resolveToolInGroup = async (group, toolName, allowRawName) => {
2531
+ const resolveToolInGroup = async (group, toolName) => {
2334
2532
  const lookupGroup = getGroupLookupName(group);
2335
2533
  if (!lookupGroup) {
2336
2534
  return undefined;
2337
2535
  }
2338
2536
  const { filteredServerInfos, serverConfigsByName } = await getFilteredServerInfosForGroup(lookupGroup);
2537
+ // Accept raw names only on single-upstream routes, even without client
2538
+ // capabilities after session rebuild. Count configured servers as well so
2539
+ // a disabled or disconnected peer cannot make a group appear unambiguous.
2540
+ const allowRawName = !isSmartRoutingGroup(group) &&
2541
+ serverConfigsByName.size <= 1 &&
2542
+ filteredServerInfos.length === 1;
2339
2543
  for (const serverInfo of filteredServerInfos) {
2340
2544
  // A disconnected on-demand server may still have a cached tool list from a
2341
2545
  // previous wake-up; allow it through so it can be selected and woken by the
@@ -2402,7 +2606,7 @@ const projectToolForDownstream = (serverName, tool, appsRouteContext, serverConf
2402
2606
  name: projectNameForGroup(projectedTool.name, serverName, serverConfig),
2403
2607
  };
2404
2608
  };
2405
- export const handleListToolsRequest = async (_, extra) => {
2609
+ const handleListToolsRequestImpl = async (_, extra) => {
2406
2610
  const sessionId = extra.sessionId || '';
2407
2611
  const group = getGroup(sessionId);
2408
2612
  logger.log(`Handling ListToolsRequest for group: ${group}`);
@@ -2446,7 +2650,7 @@ export const handleListToolsRequest = async (_, extra) => {
2446
2650
  tools: allTools,
2447
2651
  };
2448
2652
  };
2449
- export const handleCallToolRequest = async (request, extra) => {
2653
+ const handleCallToolRequestImpl = async (request, extra) => {
2450
2654
  logger.log('Handling CallToolRequest for tool', summarizeToolRequestForLogging(request.params));
2451
2655
  const startTime = Date.now();
2452
2656
  const activityLogger = getActivityLoggingService();
@@ -2478,6 +2682,7 @@ export const handleCallToolRequest = async (request, extra) => {
2478
2682
  const keyName = bearerKeyContext.keyName || extra?.keyName || undefined;
2479
2683
  const sourceIp = requestContextService.getRequestContext()?.remoteAddress || undefined;
2480
2684
  let hostedReservation = null;
2685
+ let releaseOnDemandToolCall;
2481
2686
  const reserveHostedIfNeeded = async (serverName, toolName) => {
2482
2687
  const hostedAuth = requestContextService.getHostedAuthContext();
2483
2688
  assertHostedToolAllowed(hostedAuth, serverName, toolName);
@@ -2527,7 +2732,7 @@ export const handleCallToolRequest = async (request, extra) => {
2527
2732
  targetServerInfo = getVisibleServerByName(extra.server);
2528
2733
  }
2529
2734
  else if (getGroupLookupName(group)) {
2530
- const groupTool = await resolveToolInGroup(group, toolName, false);
2735
+ const groupTool = await resolveToolInGroup(group, toolName);
2531
2736
  if (groupTool) {
2532
2737
  targetServerInfo = groupTool.serverInfo;
2533
2738
  targetToolName = groupTool.toolName;
@@ -2547,6 +2752,7 @@ export const handleCallToolRequest = async (request, extra) => {
2547
2752
  }
2548
2753
  // If the target is an on-demand server that is not yet running, wake it up now.
2549
2754
  // Concurrent callers are serialised via ensureServerReady's singleton promise.
2755
+ releaseOnDemandToolCall = beginOnDemandToolCall(targetServerInfo);
2550
2756
  if (targetServerInfo.config?.startOnDemand && targetServerInfo.status !== 'connected') {
2551
2757
  await ensureServerReady(targetServerInfo);
2552
2758
  // Re-read status from the mutated serverInfo after async spawn
@@ -2626,6 +2832,7 @@ export const handleCallToolRequest = async (request, extra) => {
2626
2832
  keyId,
2627
2833
  keyName,
2628
2834
  sourceIp,
2835
+ hasCredentialTemplate: hasCredentialTemplate(targetServerInfo.config),
2629
2836
  });
2630
2837
  return await maybeCompressToolResult({
2631
2838
  content: [
@@ -2643,7 +2850,7 @@ export const handleCallToolRequest = async (request, extra) => {
2643
2850
  // Call the tool on the target server (MCP servers)
2644
2851
  // For servers with perSessionClient: true, use a per-session dedicated client
2645
2852
  let isolatedCtx;
2646
- if (targetServerInfo.config?.perSessionClient && sessionId) {
2853
+ if (targetServerInfo.config?.perSessionClient && !hasCredentialTemplate(targetServerInfo.config) && sessionId) {
2647
2854
  const isolated = await getOrCreateIsolatedClient(sessionId, targetServerInfo);
2648
2855
  isolatedCtx = { sessionId, client: isolated.client, transport: isolated.transport };
2649
2856
  }
@@ -2674,8 +2881,6 @@ export const handleCallToolRequest = async (request, extra) => {
2674
2881
  toolName: cleanToolName,
2675
2882
  result: summarizeToolResultForLogging(result),
2676
2883
  });
2677
- // Reset idle-shutdown timer for on-demand servers after each successful call
2678
- scheduleIdleShutdown(targetServerInfo);
2679
2884
  // Log successful activity
2680
2885
  const duration = Date.now() - startTime;
2681
2886
  await activityLogger.logToolCall({
@@ -2690,6 +2895,7 @@ export const handleCallToolRequest = async (request, extra) => {
2690
2895
  keyId,
2691
2896
  keyName,
2692
2897
  sourceIp,
2898
+ hasCredentialTemplate: hasCredentialTemplate(targetServerInfo.config),
2693
2899
  errorMessage: result.isError ? 'Tool returned error response' : undefined,
2694
2900
  });
2695
2901
  return await maybeCompressToolResult(result, {
@@ -2708,7 +2914,7 @@ export const handleCallToolRequest = async (request, extra) => {
2708
2914
  // (assertToolAvailableForRoute below still gates on appsRouteContext.enabled).
2709
2915
  const singleServerAppsRoute = !!appsRouteContext.serverInfo;
2710
2916
  const groupTool = !singleServerAppsRoute && lookupGroup
2711
- ? await resolveToolInGroup(lookupGroup, request.params.name, false)
2917
+ ? await resolveToolInGroup(group, request.params.name)
2712
2918
  : undefined;
2713
2919
  const serverInfo = singleServerAppsRoute
2714
2920
  ? appsRouteContext.serverInfo
@@ -2723,6 +2929,7 @@ export const handleCallToolRequest = async (request, extra) => {
2723
2929
  // Wake the on-demand server before invoking the tool. It may be asleep with
2724
2930
  // a cached tool list (visible above) but no live client. Mirrors the
2725
2931
  // $smart call_tool path. See #1029.
2932
+ releaseOnDemandToolCall = beginOnDemandToolCall(serverInfo);
2726
2933
  if (serverInfo.config?.startOnDemand && serverInfo.status !== 'connected') {
2727
2934
  await ensureServerReady(serverInfo);
2728
2935
  }
@@ -2789,6 +2996,7 @@ export const handleCallToolRequest = async (request, extra) => {
2789
2996
  keyId,
2790
2997
  keyName,
2791
2998
  sourceIp,
2999
+ hasCredentialTemplate: hasCredentialTemplate(serverInfo.config),
2792
3000
  });
2793
3001
  return await maybeCompressToolResult({
2794
3002
  content: [
@@ -2806,7 +3014,7 @@ export const handleCallToolRequest = async (request, extra) => {
2806
3014
  // Handle MCP servers
2807
3015
  // For servers with perSessionClient: true, use a per-session dedicated client
2808
3016
  let isolatedCtx;
2809
- if (serverInfo.config?.perSessionClient && sessionId) {
3017
+ if (serverInfo.config?.perSessionClient && !hasCredentialTemplate(serverInfo.config) && sessionId) {
2810
3018
  const isolated = await getOrCreateIsolatedClient(sessionId, serverInfo);
2811
3019
  isolatedCtx = { sessionId, client: isolated.client, transport: isolated.transport };
2812
3020
  }
@@ -2826,8 +3034,6 @@ export const handleCallToolRequest = async (request, extra) => {
2826
3034
  toolName: cleanToolName,
2827
3035
  result: summarizeToolResultForLogging(result),
2828
3036
  });
2829
- // Reset idle-shutdown timer for on-demand servers after each successful call
2830
- scheduleIdleShutdown(serverInfo);
2831
3037
  // Log successful activity
2832
3038
  const duration = Date.now() - startTime;
2833
3039
  await activityLogger.logToolCall({
@@ -2842,6 +3048,7 @@ export const handleCallToolRequest = async (request, extra) => {
2842
3048
  keyId,
2843
3049
  keyName,
2844
3050
  sourceIp,
3051
+ hasCredentialTemplate: hasCredentialTemplate(serverInfo.config),
2845
3052
  errorMessage: result.isError ? 'Tool returned error response' : undefined,
2846
3053
  });
2847
3054
  return await maybeCompressToolResult(result, {
@@ -2880,6 +3087,7 @@ export const handleCallToolRequest = async (request, extra) => {
2880
3087
  keyId,
2881
3088
  keyName,
2882
3089
  sourceIp,
3090
+ hasCredentialTemplate: hasCredentialTemplate(serverInfo?.config),
2883
3091
  // The reason is an internal diagnostic; the caller-facing message stays
2884
3092
  // identical for hidden and nonexistent targets.
2885
3093
  errorMessage: unavailable
@@ -2899,8 +3107,11 @@ export const handleCallToolRequest = async (request, extra) => {
2899
3107
  isError: true,
2900
3108
  };
2901
3109
  }
3110
+ finally {
3111
+ releaseOnDemandToolCall?.();
3112
+ }
2902
3113
  };
2903
- export const handleGetPromptRequest = async (request, extra) => {
3114
+ const handleGetPromptRequestImpl = async (request, extra) => {
2904
3115
  try {
2905
3116
  const { name, arguments: promptArgs } = request.params;
2906
3117
  const sessionId = extra?.sessionId || '';
@@ -2989,7 +3200,7 @@ export const handleGetPromptRequest = async (request, extra) => {
2989
3200
  };
2990
3201
  }
2991
3202
  };
2992
- export const handleListPromptsRequest = async (_, extra) => {
3203
+ const handleListPromptsRequestImpl = async (_, extra) => {
2993
3204
  const sessionId = extra.sessionId || '';
2994
3205
  const group = getGroup(sessionId);
2995
3206
  const lookupGroup = getGroupLookupName(group);
@@ -3033,7 +3244,7 @@ export const handleListPromptsRequest = async (_, extra) => {
3033
3244
  prompts: allPrompts,
3034
3245
  };
3035
3246
  };
3036
- export const handleListResourcesRequest = async (_, extra) => {
3247
+ const handleListResourcesRequestImpl = async (_, extra) => {
3037
3248
  const sessionId = extra.sessionId || '';
3038
3249
  const group = getGroup(sessionId);
3039
3250
  const lookupGroup = getGroupLookupName(group);
@@ -3078,7 +3289,7 @@ export const handleListResourcesRequest = async (_, extra) => {
3078
3289
  resources: allResources,
3079
3290
  };
3080
3291
  };
3081
- export const handleListResourceTemplatesRequest = async (_, extra) => {
3292
+ const handleListResourceTemplatesRequestImpl = async (_, extra) => {
3082
3293
  const sessionId = extra.sessionId || '';
3083
3294
  const group = getGroup(sessionId);
3084
3295
  const lookupGroup = getGroupLookupName(group);
@@ -3101,7 +3312,7 @@ export const handleListResourceTemplatesRequest = async (_, extra) => {
3101
3312
  resourceTemplates: results.flatMap((result) => result.status === 'fulfilled' ? result.value : []),
3102
3313
  };
3103
3314
  };
3104
- export const handleReadResourceRequest = async (request, extra) => {
3315
+ const handleReadResourceRequestImpl = async (request, extra) => {
3105
3316
  try {
3106
3317
  const { uri } = request.params;
3107
3318
  const sessionId = extra.sessionId || '';
@@ -3198,6 +3409,197 @@ export const handleReadResourceRequest = async (request, extra) => {
3198
3409
  };
3199
3410
  }
3200
3411
  };
3412
+ // Personal runtimes never enter serverInfos, config exports, or shared embedding caches.
3413
+ const createPrincipalRuntime = async (name, resolvedConfig) => {
3414
+ const info = {
3415
+ name,
3416
+ owner: resolvedConfig.owner,
3417
+ visibility: resolvedConfig.visibility,
3418
+ sharedWithUsers: resolvedConfig.sharedWithUsers,
3419
+ enabled: true,
3420
+ config: resolvedConfig,
3421
+ options: resolvedConfig.options,
3422
+ status: 'connecting',
3423
+ error: null,
3424
+ tools: [],
3425
+ prompts: [],
3426
+ resources: [],
3427
+ createTime: Date.now(),
3428
+ };
3429
+ const safeMethods = new Set([
3430
+ 'callTool',
3431
+ 'getPrompt',
3432
+ 'readResource',
3433
+ 'listTools',
3434
+ 'listPrompts',
3435
+ 'listResources',
3436
+ 'listResourceTemplates',
3437
+ ]);
3438
+ const protectClient = (client) => new Proxy(client, {
3439
+ get(target, property) {
3440
+ const value = Reflect.get(target, property);
3441
+ if (typeof value !== 'function')
3442
+ return value;
3443
+ if (!safeMethods.has(String(property)))
3444
+ return value.bind(target);
3445
+ return async (...args) => {
3446
+ try {
3447
+ return await value.apply(target, args);
3448
+ }
3449
+ catch {
3450
+ info.status = 'disconnected';
3451
+ throw new CredentialBindingError(`Personal credential request failed for '${name}'. Check your binding in Credentials.`);
3452
+ }
3453
+ };
3454
+ },
3455
+ });
3456
+ try {
3457
+ if (resolvedConfig.type === 'openapi') {
3458
+ const client = new OpenAPIClient(resolvedConfig);
3459
+ await client.initialize();
3460
+ info.openApiClient = protectClient(client);
3461
+ info.tools = client
3462
+ .getTools()
3463
+ .map((tool) => ({
3464
+ ...tool,
3465
+ name: `${name}${getNameSeparator()}${tool.name}`,
3466
+ inputSchema: cleanInputSchema(tool.inputSchema),
3467
+ }));
3468
+ }
3469
+ else {
3470
+ const transport = await createTransportFromConfig(name, resolvedConfig);
3471
+ const client = new Client({ name: `mcp-client-${name}`, version: '1.0.0' }, {
3472
+ capabilities: MCP_APPS_CAPABILITIES,
3473
+ jsonSchemaValidator: new ResilientJsonSchemaValidator(),
3474
+ });
3475
+ info.transport = transport;
3476
+ info.client = protectClient(client);
3477
+ await client.connect(transport, resolvedConfig.options || {});
3478
+ const capabilities = client.getServerCapabilities();
3479
+ if (capabilities?.tools)
3480
+ info.tools = (await client.listTools({}, resolvedConfig.options)).tools.map((tool) => normalizeToolForCache(name, tool));
3481
+ if (capabilities?.prompts)
3482
+ info.prompts = (await client.listPrompts({}, resolvedConfig.options)).prompts.map((prompt) => normalizePromptForCache(name, prompt));
3483
+ if (capabilities?.resources)
3484
+ info.resources = (await client.listResources({}, resolvedConfig.options)).resources.map(normalizeResourceForCache);
3485
+ info.version = client.getServerVersion()?.version;
3486
+ client.onclose = () => {
3487
+ info.status = 'disconnected';
3488
+ };
3489
+ }
3490
+ info.status = 'connected';
3491
+ return info;
3492
+ }
3493
+ catch {
3494
+ closeServerRuntime(info);
3495
+ throw new CredentialBindingError(`Unable to connect '${name}' with your personal credentials. Check your binding in Credentials.`);
3496
+ }
3497
+ };
3498
+ const principalRuntimes = new PrincipalRuntimeService(createPrincipalRuntime, closeServerRuntime);
3499
+ const withPrincipalServers = (handler, operation) => async (request, extra) => {
3500
+ if (!serverInfos.some((info) => hasCredentialTemplate(info.config)))
3501
+ return handler(request, extra);
3502
+ // Built-in content does not require an upstream credential.
3503
+ if (operation === 'prompt') {
3504
+ const builtin = await getBuiltinPromptDao().findByName(request.params?.name);
3505
+ if (builtin && builtin.enabled !== false)
3506
+ return handler(request, extra);
3507
+ }
3508
+ else if (operation === 'resource') {
3509
+ const builtin = await getBuiltinResourceDao().findByUri(request.params?.uri);
3510
+ if (builtin && builtin.enabled !== false)
3511
+ return handler(request, extra);
3512
+ }
3513
+ const group = RequestContextService.getInstance().getGroupContext() ||
3514
+ extra?.group ||
3515
+ getGroup(extra?.sessionId || '') ||
3516
+ undefined;
3517
+ const { filteredServerInfos: candidates, serverConfigsByName } = await getFilteredServerInfosForGroup(getGroupLookupName(group));
3518
+ const requestedName = ['call_tool', 'describe_tool'].includes(request?.params?.name)
3519
+ ? request.params.arguments?.toolName
3520
+ : request?.params?.name;
3521
+ const target = candidates.find((info) => {
3522
+ const exposedName = getExposedServerName(info.name, serverConfigsByName.get(info.name));
3523
+ return (extra?.server === info.name ||
3524
+ (typeof requestedName === 'string' &&
3525
+ requestedName.startsWith(`${exposedName}${getNameSeparator()}`)));
3526
+ });
3527
+ // A call to a known target must not spawn or keep unrelated personal children alive.
3528
+ const selected = target && (operation === 'tool' || operation === 'prompt') ? [target] : candidates;
3529
+ const templated = selected.filter((info) => hasCredentialTemplate(info.config));
3530
+ if (!templated.length)
3531
+ return handler(request, extra);
3532
+ const user = UserContextService.getInstance().getCurrentUser();
3533
+ const releases = [];
3534
+ const scoped = new Map(serverInfos.map((info) => [info.name, info]));
3535
+ const failures = new Map();
3536
+ try {
3537
+ await Promise.all(templated.map(async (info) => {
3538
+ try {
3539
+ if (!user?.username ||
3540
+ user.credentialEligible === false ||
3541
+ RequestContextService.getInstance().getKeyKindContext() === 'system')
3542
+ throw missingCredentialError(info.name);
3543
+ const lease = await principalRuntimes.acquire(info.name, user);
3544
+ releases.push(lease.release);
3545
+ scoped.set(info.name, lease.info);
3546
+ }
3547
+ catch (error) {
3548
+ failures.set(info.name, error instanceof CredentialBindingError
3549
+ ? error
3550
+ : new CredentialBindingError('Unable to resolve personal credentials'));
3551
+ }
3552
+ }));
3553
+ const requiredTarget = target ||
3554
+ (candidates.length === 1 && operation !== 'list' && requestedName !== 'search_tools'
3555
+ ? templated[0]
3556
+ : undefined);
3557
+ const failure = requiredTarget ? failures.get(requiredTarget.name) : undefined;
3558
+ if (failure) {
3559
+ if (operation === 'resource')
3560
+ return {
3561
+ contents: [
3562
+ { uri: request.params?.uri || '', mimeType: 'text/plain', text: failure.message },
3563
+ ],
3564
+ };
3565
+ return { content: [{ type: 'text', text: failure.message }], isError: true };
3566
+ }
3567
+ // Dashboard/OpenAPI callers may address a tool by its raw name plus server.
3568
+ let resolvedRequest = request;
3569
+ if (operation === 'tool' &&
3570
+ extra?.server &&
3571
+ hasCredentialTemplate(scoped.get(extra.server)?.config)) {
3572
+ const prefix = `${extra.server}${getNameSeparator()}`;
3573
+ if (typeof requestedName === 'string' && !requestedName.startsWith(prefix)) {
3574
+ resolvedRequest =
3575
+ request.params.name === 'call_tool'
3576
+ ? {
3577
+ ...request,
3578
+ params: {
3579
+ ...request.params,
3580
+ arguments: {
3581
+ ...request.params.arguments,
3582
+ toolName: `${prefix}${requestedName}`,
3583
+ },
3584
+ },
3585
+ }
3586
+ : { ...request, params: { ...request.params, name: `${prefix}${requestedName}` } };
3587
+ }
3588
+ }
3589
+ return await principalServerScope.run([...scoped.values()], () => handler(resolvedRequest, extra));
3590
+ }
3591
+ finally {
3592
+ for (const release of releases)
3593
+ release();
3594
+ }
3595
+ };
3596
+ export const handleListToolsRequest = withPrincipalServers(handleListToolsRequestImpl, 'list');
3597
+ export const handleCallToolRequest = withPrincipalServers(handleCallToolRequestImpl, 'tool');
3598
+ export const handleGetPromptRequest = withPrincipalServers(handleGetPromptRequestImpl, 'prompt');
3599
+ export const handleListPromptsRequest = withPrincipalServers(handleListPromptsRequestImpl, 'list');
3600
+ export const handleListResourcesRequest = withPrincipalServers(handleListResourcesRequestImpl, 'list');
3601
+ export const handleListResourceTemplatesRequest = withPrincipalServers(handleListResourceTemplatesRequestImpl, 'list');
3602
+ export const handleReadResourceRequest = withPrincipalServers(handleReadResourceRequestImpl, 'resource');
3201
3603
  export const createMcpServer = (name, version, options) => {
3202
3604
  const normalizedOptions = typeof options === 'string' ? { group: options } : (options ?? {});
3203
3605
  // Determine server name based on routing type
@@ -3229,13 +3631,8 @@ export const createMcpServer = (name, version, options) => {
3229
3631
  return server;
3230
3632
  };
3231
3633
  export const getFilteredServerInfosForGroup = async (group, options) => {
3232
- // Resolve group server configs. We look up the group directly from the DAO
3233
- // rather than going through getServerConfigsInGroup (which calls getAllGroups
3234
- // and applies filterData on groups). Groups don't carry a visibility field,
3235
- // so admin-owned groups would be filtered out for non-admin users even though
3236
- // bearer-key auth already authorized access and individual servers inside the
3237
- // group may be public. Server-level filterData below still enforces per-server
3238
- // visibility. Fix for #914.
3634
+ // Resolve raw groups so legacy routing remains compatible, then enforce explicit visibility.
3635
+ let matchedGroup = false;
3239
3636
  let serverConfigs = [];
3240
3637
  if (group) {
3241
3638
  const groupDao = getGroupDao();
@@ -3247,6 +3644,10 @@ export const getFilteredServerInfosForGroup = async (group, options) => {
3247
3644
  }
3248
3645
  }
3249
3646
  if (foundGroup) {
3647
+ matchedGroup = true;
3648
+ if (!canAccessGroupRoute(foundGroup)) {
3649
+ return { filteredServerInfos: [], serverConfigsByName: new Map() };
3650
+ }
3250
3651
  serverConfigs = normalizeGroupServers(foundGroup.servers || []);
3251
3652
  }
3252
3653
  }
@@ -3262,7 +3663,7 @@ export const getFilteredServerInfosForGroup = async (group, options) => {
3262
3663
  filteredServerInfos.push(serverInfo);
3263
3664
  continue;
3264
3665
  }
3265
- if (serverNamesInGroup.size === 0) {
3666
+ if (!matchedGroup) {
3266
3667
  if (serverInfo.name === group) {
3267
3668
  filteredServerInfos.push(serverInfo);
3268
3669
  }