@samanhappy/mcphub 1.0.35 → 1.0.36

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 (116) hide show
  1. package/README.fr.md +2 -1
  2. package/README.md +8 -5
  3. package/README.zh.md +7 -4
  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/serverController.js +8 -4
  9. package/dist/controllers/serverController.js.map +1 -1
  10. package/dist/controllers/userController.js +3 -0
  11. package/dist/controllers/userController.js.map +1 -1
  12. package/dist/dao/CredentialBindingDao.js +48 -0
  13. package/dist/dao/CredentialBindingDao.js.map +1 -0
  14. package/dist/dao/CredentialBindingDaoDbImpl.js +20 -0
  15. package/dist/dao/CredentialBindingDaoDbImpl.js.map +1 -0
  16. package/dist/dao/DaoFactory.js +7 -0
  17. package/dist/dao/DaoFactory.js.map +1 -1
  18. package/dist/dao/DatabaseDaoFactory.js +4 -0
  19. package/dist/dao/DatabaseDaoFactory.js.map +1 -1
  20. package/dist/dao/ServerDaoDbImpl.js +3 -0
  21. package/dist/dao/ServerDaoDbImpl.js.map +1 -1
  22. package/dist/db/entities/CredentialBinding.js +33 -0
  23. package/dist/db/entities/CredentialBinding.js.map +1 -0
  24. package/dist/db/entities/Server.js +4 -0
  25. package/dist/db/entities/Server.js.map +1 -1
  26. package/dist/db/entities/index.js +3 -1
  27. package/dist/db/entities/index.js.map +1 -1
  28. package/dist/middlewares/auth.js +2 -0
  29. package/dist/middlewares/auth.js.map +1 -1
  30. package/dist/middlewares/i18n.js +2 -1
  31. package/dist/middlewares/i18n.js.map +1 -1
  32. package/dist/routes/index.js +15 -1
  33. package/dist/routes/index.js.map +1 -1
  34. package/dist/services/activityLoggingService.js +21 -8
  35. package/dist/services/activityLoggingService.js.map +1 -1
  36. package/dist/services/credentialBindingService.js +193 -0
  37. package/dist/services/credentialBindingService.js.map +1 -0
  38. package/dist/services/mcpService.js +429 -65
  39. package/dist/services/mcpService.js.map +1 -1
  40. package/dist/services/principalRuntimeService.js +109 -0
  41. package/dist/services/principalRuntimeService.js.map +1 -0
  42. package/dist/services/serverConfigPresenter.js +3 -0
  43. package/dist/services/serverConfigPresenter.js.map +1 -1
  44. package/dist/services/sseService.js +2 -9
  45. package/dist/services/sseService.js.map +1 -1
  46. package/dist/utils/credentialTemplate.js +38 -0
  47. package/dist/utils/credentialTemplate.js.map +1 -0
  48. package/dist/utils/migration.js +9 -0
  49. package/dist/utils/migration.js.map +1 -1
  50. package/dist/utils/serverConfigPersistence.js +2 -0
  51. package/dist/utils/serverConfigPersistence.js.map +1 -1
  52. package/frontend/dist/assets/{ActivityPage-B4OBJUBq.js → ActivityPage-ZRx6b9hm.js} +2 -2
  53. package/frontend/dist/assets/{ActivityPage-B4OBJUBq.js.map → ActivityPage-ZRx6b9hm.js.map} +1 -1
  54. package/frontend/dist/assets/{ConfirmDialog-3APts2Y9.js → ConfirmDialog-uWBwm8J1.js} +2 -2
  55. package/frontend/dist/assets/{ConfirmDialog-3APts2Y9.js.map → ConfirmDialog-uWBwm8J1.js.map} +1 -1
  56. package/frontend/dist/assets/CredentialsPage-C9vnh01H.js +2 -0
  57. package/frontend/dist/assets/CredentialsPage-C9vnh01H.js.map +1 -0
  58. package/frontend/dist/assets/{Dashboard-czP-DGHn.js → Dashboard-BDGDQNz1.js} +2 -2
  59. package/frontend/dist/assets/{Dashboard-czP-DGHn.js.map → Dashboard-BDGDQNz1.js.map} +1 -1
  60. package/frontend/dist/assets/{DeleteDialog-D9ycSRvn.js → DeleteDialog-BYhrMGZo.js} +2 -2
  61. package/frontend/dist/assets/{DeleteDialog-D9ycSRvn.js.map → DeleteDialog-BYhrMGZo.js.map} +1 -1
  62. package/frontend/dist/assets/{EndpointCopy-CDwtdCHE.js → EndpointCopy-BY51t0Ks.js} +2 -2
  63. package/frontend/dist/assets/{EndpointCopy-CDwtdCHE.js.map → EndpointCopy-BY51t0Ks.js.map} +1 -1
  64. package/frontend/dist/assets/{GroupsPage-BgUHjkGb.js → GroupsPage-DyHyW7NW.js} +2 -2
  65. package/frontend/dist/assets/GroupsPage-DyHyW7NW.js.map +1 -0
  66. package/frontend/dist/assets/{LoginPage-BBzViqLx.js → LoginPage-CZOKlkR_.js} +2 -2
  67. package/frontend/dist/assets/{LoginPage-BBzViqLx.js.map → LoginPage-CZOKlkR_.js.map} +1 -1
  68. package/frontend/dist/assets/{LogsPage-CqbaqFiQ.js → LogsPage-FDWOOIPg.js} +2 -2
  69. package/frontend/dist/assets/{LogsPage-CqbaqFiQ.js.map → LogsPage-FDWOOIPg.js.map} +1 -1
  70. package/frontend/dist/assets/{MarketPage-DinCKwrj.js → MarketPage-DKfZgoIs.js} +2 -2
  71. package/frontend/dist/assets/{MarketPage-DinCKwrj.js.map → MarketPage-DKfZgoIs.js.map} +1 -1
  72. package/frontend/dist/assets/{OAuthConsentPage-BuPcJ2zS.js → OAuthConsentPage-Hk_DJ0dW.js} +2 -2
  73. package/frontend/dist/assets/{OAuthConsentPage-BuPcJ2zS.js.map → OAuthConsentPage-Hk_DJ0dW.js.map} +1 -1
  74. package/frontend/dist/assets/{Pagination-Dwko3g48.js → Pagination-CLUvn47x.js} +2 -2
  75. package/frontend/dist/assets/{Pagination-Dwko3g48.js.map → Pagination-CLUvn47x.js.map} +1 -1
  76. package/frontend/dist/assets/{PromptsPage-CJ-wYRAL.js → PromptsPage-CT0C0_BI.js} +2 -2
  77. package/frontend/dist/assets/{PromptsPage-CJ-wYRAL.js.map → PromptsPage-CT0C0_BI.js.map} +1 -1
  78. package/frontend/dist/assets/{ResourcesPage-DRV8gnZV.js → ResourcesPage-CBgu-N5m.js} +2 -2
  79. package/frontend/dist/assets/{ResourcesPage-DRV8gnZV.js.map → ResourcesPage-CBgu-N5m.js.map} +1 -1
  80. package/frontend/dist/assets/ServersPage-CvcP8LFv.js +39 -0
  81. package/frontend/dist/assets/ServersPage-CvcP8LFv.js.map +1 -0
  82. package/frontend/dist/assets/{SettingsPage-Czv4OhtK.js → SettingsPage-DDSVt_xk.js} +2 -2
  83. package/frontend/dist/assets/{SettingsPage-Czv4OhtK.js.map → SettingsPage-DDSVt_xk.js.map} +1 -1
  84. package/frontend/dist/assets/{StatusDot-CwpmIlJi.js → StatusDot-uIIW4IuH.js} +2 -2
  85. package/frontend/dist/assets/{StatusDot-CwpmIlJi.js.map → StatusDot-uIIW4IuH.js.map} +1 -1
  86. package/frontend/dist/assets/{ToggleGroup-1qFTkDqh.js → ToggleGroup-BToroeZy.js} +2 -2
  87. package/frontend/dist/assets/{ToggleGroup-1qFTkDqh.js.map → ToggleGroup-BToroeZy.js.map} +1 -1
  88. package/frontend/dist/assets/{UsersPage-CAMXgywh.js → UsersPage-BjgJjf6L.js} +2 -2
  89. package/frontend/dist/assets/{UsersPage-CAMXgywh.js.map → UsersPage-BjgJjf6L.js.map} +1 -1
  90. package/frontend/dist/assets/{framework-vendor-BHAQX05P.js → framework-vendor-_YL27Pfc.js} +2 -2
  91. package/frontend/dist/assets/{framework-vendor-BHAQX05P.js.map → framework-vendor-_YL27Pfc.js.map} +1 -1
  92. package/frontend/dist/assets/{i18n-vendor-CMcB7XUR.js → i18n-vendor-BMaj5qV8.js} +2 -2
  93. package/frontend/dist/assets/{i18n-vendor-CMcB7XUR.js.map → i18n-vendor-BMaj5qV8.js.map} +1 -1
  94. package/frontend/dist/assets/{icons-vendor-HZHHWeTa.js → icons-vendor-VW-UwEhw.js} +3 -3
  95. package/frontend/dist/assets/{icons-vendor-HZHHWeTa.js.map → icons-vendor-VW-UwEhw.js.map} +1 -1
  96. package/frontend/dist/assets/{index-DtrCVU1h.css → index-B3OqAhGz.css} +1 -1
  97. package/frontend/dist/assets/index-DjNOC0Ov.js +3 -0
  98. package/frontend/dist/assets/index-DjNOC0Ov.js.map +1 -0
  99. package/frontend/dist/assets/{resourceService-BTVgor9F.js → resourceService-sD9dxx3E.js} +2 -2
  100. package/frontend/dist/assets/{resourceService-BTVgor9F.js.map → resourceService-sD9dxx3E.js.map} +1 -1
  101. package/frontend/dist/assets/{useCostData-CDVGUx_e.js → useCostData-QoPbsqyD.js} +2 -2
  102. package/frontend/dist/assets/{useCostData-CDVGUx_e.js.map → useCostData-QoPbsqyD.js.map} +1 -1
  103. package/frontend/dist/assets/useSettingsData-C4JrLqDh.js +2 -0
  104. package/frontend/dist/assets/{useSettingsData-92nglN_X.js.map → useSettingsData-C4JrLqDh.js.map} +1 -1
  105. package/frontend/dist/assets/variableDetection-iFAhGAKs.js +16 -0
  106. package/frontend/dist/assets/variableDetection-iFAhGAKs.js.map +1 -0
  107. package/frontend/dist/index.html +5 -5
  108. package/package.json +4 -5
  109. package/frontend/dist/assets/GroupsPage-BgUHjkGb.js.map +0 -1
  110. package/frontend/dist/assets/ServersPage-CbtcAcVH.js +0 -39
  111. package/frontend/dist/assets/ServersPage-CbtcAcVH.js.map +0 -1
  112. package/frontend/dist/assets/index-Cb6IBpZR.js +0 -3
  113. package/frontend/dist/assets/index-Cb6IBpZR.js.map +0 -1
  114. package/frontend/dist/assets/useSettingsData-92nglN_X.js +0 -2
  115. package/frontend/dist/assets/variableDetection-BlOwsGsz.js +0 -16
  116. package/frontend/dist/assets/variableDetection-BlOwsGsz.js.map +0 -1
@@ -1,3 +1,8 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { PrincipalRuntimeService } from './principalRuntimeService.js';
3
+ import { UserContextService } from './userContextService.js';
4
+ import { deleteCredentialBindings, missingCredentialError } from './credentialBindingService.js';
5
+ import { CredentialBindingError, hasCredentialTemplate, validateCredentialTemplate } from '../utils/credentialTemplate.js';
1
6
  import os from 'os';
2
7
  import path from 'path';
3
8
  import fs from 'fs';
@@ -519,8 +524,14 @@ const normalizeResourceForCache = (resource) => {
519
524
  };
520
525
  // Store all server information
521
526
  let serverInfos = [];
527
+ // Bumped by every initializeClientsFromSettings pass. Connections are started
528
+ // fire-and-forget and settle long after the pass that started them returned, so
529
+ // a callback needs to know whether a later pass has run before it writes its
530
+ // result anywhere. See the `liveServerInfo` helper in that function.
531
+ let initGeneration = 0;
532
+ const principalServerScope = new AsyncLocalStorage();
522
533
  const getVisibleServerInfos = () => {
523
- return getDataService().filterData(serverInfos);
534
+ return getDataService().filterData(principalServerScope.getStore() ?? serverInfos);
524
535
  };
525
536
  const getVisibleServerByName = (name) => {
526
537
  return getVisibleServerInfos().find((serverInfo) => serverInfo.name === name);
@@ -656,7 +667,8 @@ export const summarizeServerConnections = (infos) => {
656
667
  };
657
668
  };
658
669
  export const getServerConnectionStats = () => {
659
- return summarizeServerConnections(serverInfos);
670
+ // Personal definitions have no shared connection whose readiness can be checked.
671
+ return summarizeServerConnections(serverInfos.filter((info) => !hasCredentialTemplate(info.config)));
660
672
  };
661
673
  // Returns true if all enabled servers are connected
662
674
  export const connected = () => {
@@ -665,6 +677,7 @@ export const connected = () => {
665
677
  };
666
678
  // Global cleanup function to close all connections
667
679
  export const cleanupAllServers = () => {
680
+ principalRuntimes.invalidate();
668
681
  for (const serverInfo of serverInfos) {
669
682
  try {
670
683
  if (serverInfo.client) {
@@ -927,8 +940,10 @@ export const createTransportFromConfig = async (name, conf) => {
927
940
  let transport;
928
941
  const env = {
929
942
  ...process.env,
930
- ...replaceEnvVars(conf.env || {}),
943
+ ...(hasCredentialTemplate(conf) ? conf.env : replaceEnvVars(conf.env || {})),
931
944
  };
945
+ // The encryption key belongs only to the hub, never to a child process.
946
+ delete env.MCPHUB_CREDENTIAL_ENCRYPTION_KEY;
932
947
  // SSRF guard: block URL/streamable-http transports from reaching
933
948
  // loopback / RFC1918 / link-local targets (e.g. cloud metadata service).
934
949
  // Admin-owned servers may legitimately target internal services, so they
@@ -941,11 +956,11 @@ export const createTransportFromConfig = async (name, conf) => {
941
956
  }
942
957
  if (conf.type === 'streamable-http') {
943
958
  const options = {};
944
- let headers = conf.headers ? replaceEnvVars(conf.headers, env) : {};
959
+ let headers = conf.headers ? (hasCredentialTemplate(conf) ? conf.headers : replaceEnvVars(conf.headers, env)) : {};
945
960
  const baseFetch = createAbortIsolatingFetch(createFetchWithProxy(getProxyConfigFromEnv(env)));
946
961
  const requestAwareFetch = createRedirectValidatingFetch(createRequestContextAwareFetch(baseFetch, conf.passthroughHeaders), allowInternal);
947
962
  // Create OAuth provider if configured - SDK will handle authentication automatically
948
- const authProvider = await createOAuthProvider(name, conf);
963
+ const authProvider = hasCredentialTemplate(conf) ? undefined : await createOAuthProvider(name, conf);
949
964
  if (authProvider) {
950
965
  options.authProvider = authProvider;
951
966
  // When the OAuth provider is active, strip any static Authorization
@@ -968,11 +983,11 @@ export const createTransportFromConfig = async (name, conf) => {
968
983
  else if (conf.url) {
969
984
  // SSE transport
970
985
  const options = {};
971
- let headers = conf.headers ? replaceEnvVars(conf.headers, env) : {};
986
+ let headers = conf.headers ? (hasCredentialTemplate(conf) ? conf.headers : replaceEnvVars(conf.headers, env)) : {};
972
987
  const baseFetch = createAbortIsolatingFetch(createFetchWithProxy(getProxyConfigFromEnv(env)));
973
988
  const requestAwareFetch = createRedirectValidatingFetch(createRequestContextAwareFetch(baseFetch, conf.passthroughHeaders), allowInternal);
974
989
  // Create OAuth provider if configured - SDK will handle authentication automatically
975
- const authProvider = await createOAuthProvider(name, conf);
990
+ const authProvider = hasCredentialTemplate(conf) ? undefined : await createOAuthProvider(name, conf);
976
991
  if (authProvider) {
977
992
  options.authProvider = authProvider;
978
993
  // Drop static Authorization header when OAuth manages auth (see above).
@@ -1032,7 +1047,10 @@ export const createTransportFromConfig = async (name, conf) => {
1032
1047
  env: env,
1033
1048
  stderr: 'pipe',
1034
1049
  });
1035
- if (transport.stderr) {
1050
+ if (transport.stderr && hasCredentialTemplate(conf)) {
1051
+ transport.stderr.on('data', () => undefined); // Personal upstream diagnostics can contain raw credentials.
1052
+ }
1053
+ else if (transport.stderr) {
1036
1054
  observeStdioStderr(transport, transport.stderr, (message) => {
1037
1055
  logger.log('Upstream server stderr', JSON.stringify({
1038
1056
  serverName: name,
@@ -1056,6 +1074,9 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1056
1074
  if (!client) {
1057
1075
  throw new Error(`Client not found for server: ${serverInfo.name}`);
1058
1076
  }
1077
+ if (hasCredentialTemplate(serverInfo.config)) {
1078
+ return client.callTool(toolParams, undefined, options || {});
1079
+ }
1059
1080
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1060
1081
  try {
1061
1082
  const result = await client.callTool(toolParams, undefined, options || {});
@@ -1183,6 +1204,7 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1183
1204
  const allServers = await getServerDao().findAll();
1184
1205
  const existingServerInfos = serverInfos;
1185
1206
  const nextServerInfos = [];
1207
+ const generation = ++initGeneration;
1186
1208
  try {
1187
1209
  for (const conf of allServers) {
1188
1210
  const { name } = conf;
@@ -1215,6 +1237,16 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1215
1237
  });
1216
1238
  continue;
1217
1239
  }
1240
+ if (hasCredentialTemplate(expandedConf)) {
1241
+ validateCredentialTemplate(expandedConf);
1242
+ nextServerInfos.push({
1243
+ name, owner: expandedConf.owner, visibility: expandedConf.visibility,
1244
+ sharedWithUsers: expandedConf.sharedWithUsers, enabled: true,
1245
+ status: 'disconnected', error: null, tools: [], prompts: [], resources: [],
1246
+ createTime: Date.now(), config: expandedConf,
1247
+ });
1248
+ continue;
1249
+ }
1218
1250
  // On-demand stdio servers: skip startup connect; spawn lazily on first
1219
1251
  // tool call. startOnDemand is a stdio-only optimisation - HTTP/SSE and
1220
1252
  // OpenAPI servers have no deferred child process, so they connect normally.
@@ -1222,23 +1254,25 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1222
1254
  // connection) so the tool list stays visible to agents even while sleeping.
1223
1255
  if (expandedConf.startOnDemand === true && isStdioServer(expandedConf)) {
1224
1256
  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
- }),
1257
+ // Active calls, spawn promises and idle callbacks must share the same
1258
+ // runtime object across reloads, not copies of its mutable state.
1259
+ const onDemandInfo = existingOnDemand ?? {
1260
+ name,
1261
+ status: 'disconnected',
1262
+ error: null,
1263
+ tools: [],
1264
+ prompts: [],
1265
+ resources: [],
1266
+ createTime: Date.now(),
1267
+ };
1268
+ Object.assign(onDemandInfo, {
1236
1269
  owner: expandedConf.owner,
1237
1270
  visibility: expandedConf.visibility,
1238
1271
  sharedWithUsers: expandedConf.sharedWithUsers,
1239
1272
  enabled: true,
1240
1273
  config: expandedConf,
1241
1274
  });
1275
+ nextServerInfos.push(onDemandInfo);
1242
1276
  logger.log(`Skipping startup connect for on-demand server: ${name}`);
1243
1277
  continue;
1244
1278
  }
@@ -1371,7 +1405,36 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1371
1405
  }
1372
1406
  }
1373
1407
  else {
1374
- transport = await createTransportFromConfig(name, expandedConf);
1408
+ try {
1409
+ transport = await createTransportFromConfig(name, expandedConf);
1410
+ }
1411
+ catch (error) {
1412
+ // Fail this server only. Letting the error escape the loop hits the
1413
+ // outer catch, which restores `serverInfos` to its previous value -
1414
+ // empty at startup - so a single malformed config (an unparseable URL
1415
+ // rejected by assertSafeUrl, say) would drop every server from
1416
+ // getServersInfo and leave the whole fleet reporting 'connecting'.
1417
+ // The OpenAPI branch above already isolates its failures this way.
1418
+ logger.error('Failed to create transport for server', {
1419
+ serverName: name,
1420
+ error: summarizeErrorForLogging(error),
1421
+ });
1422
+ nextServerInfos.push({
1423
+ name,
1424
+ owner: expandedConf.owner,
1425
+ visibility: expandedConf.visibility,
1426
+ sharedWithUsers: expandedConf.sharedWithUsers,
1427
+ status: 'disconnected',
1428
+ error: `Failed to create transport: ${formatErrorForLogging(error)}`,
1429
+ tools: [],
1430
+ prompts: [],
1431
+ resources: [],
1432
+ createTime: Date.now(),
1433
+ enabled: true,
1434
+ config: expandedConf,
1435
+ });
1436
+ continue;
1437
+ }
1375
1438
  }
1376
1439
  const serverInfoRef = {};
1377
1440
  const client = createUpstreamMcpClient(name, () => serverInfoRef.current);
@@ -1417,12 +1480,39 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1417
1480
  };
1418
1481
  }
1419
1482
  nextServerInfos.push(serverInfo);
1483
+ // The connect below settles long after this loop returns. If another
1484
+ // initialization pass has run by then, `serverInfo` may no longer be the
1485
+ // object `serverInfos` holds for this server - even when the reuse guard
1486
+ // "preserved" it, because preservation spreads the entry into a fresh
1487
+ // object. Writing the result into a replaced object leaves the server
1488
+ // stuck on 'connecting' for good while the log reports a successful
1489
+ // connect and a full tool list.
1490
+ //
1491
+ // While this pass is still the newest one, `serverInfo` is the entry that
1492
+ // will be published, so it is the right target even before the swap. Once
1493
+ // a later pass has run, resolve the live entry by name and only apply the
1494
+ // result if it still owns this client - which is exactly the case where
1495
+ // the later pass preserved this server rather than reconnecting it.
1496
+ const liveServerInfo = () => {
1497
+ if (generation === initGeneration)
1498
+ return serverInfo;
1499
+ const current = serverInfos.find((si) => si.name === name);
1500
+ return current && current.client === client ? current : undefined;
1501
+ };
1420
1502
  connectClientWithDiagnostics(client, transport, initRequestOptions || requestOptions)
1421
1503
  .then(() => {
1504
+ const info = liveServerInfo();
1505
+ if (!info) {
1506
+ // Nothing will ever read this connection again, so shut it down
1507
+ // rather than leaving its stdio child process running.
1508
+ logger.log(`Discarding superseded connection result for server: ${name}`);
1509
+ closeServerRuntime(serverInfo);
1510
+ return;
1511
+ }
1422
1512
  logger.log(`Successfully connected client for server: ${name}`);
1423
1513
  const serverVersion = client.getServerVersion?.();
1424
- serverInfo.version = serverVersion?.version;
1425
- serverInfo.instructions = client.getInstructions?.();
1514
+ info.version = serverVersion?.version;
1515
+ info.instructions = client.getInstructions?.();
1426
1516
  const capabilities = client.getServerCapabilities();
1427
1517
  logger.log('Server capabilities', JSON.stringify(capabilities));
1428
1518
  let dataError = null;
@@ -1430,8 +1520,11 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1430
1520
  client
1431
1521
  .listTools({}, initRequestOptions || requestOptions)
1432
1522
  .then((tools) => {
1523
+ const target = liveServerInfo();
1524
+ if (!target)
1525
+ return;
1433
1526
  logger.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
1434
- updateServerToolsCache(serverInfo, tools.tools, {
1527
+ updateServerToolsCache(target, tools.tools, {
1435
1528
  reportEmbeddingProgress: options?.reportEmbeddingProgress === true && serverName === name,
1436
1529
  });
1437
1530
  // Broadcast only after tools are actually loaded into the cache.
@@ -1452,8 +1545,11 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1452
1545
  client
1453
1546
  .listPrompts({}, initRequestOptions || requestOptions)
1454
1547
  .then((prompts) => {
1548
+ const target = liveServerInfo();
1549
+ if (!target)
1550
+ return;
1455
1551
  logger.log(`Successfully listed ${prompts.prompts.length} prompts for server: ${name}`);
1456
- updateServerPromptsCache(serverInfo, prompts.prompts);
1552
+ updateServerPromptsCache(target, prompts.prompts);
1457
1553
  broadcastPromptListChanged();
1458
1554
  })
1459
1555
  .catch((error) => {
@@ -1468,8 +1564,11 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1468
1564
  client
1469
1565
  .listResources({}, initRequestOptions || requestOptions)
1470
1566
  .then((resources) => {
1567
+ const target = liveServerInfo();
1568
+ if (!target)
1569
+ return;
1471
1570
  logger.log(`Successfully listed ${resources.resources.length} resources for server: ${name}`);
1472
- updateServerResourcesCache(serverInfo, resources.resources);
1571
+ updateServerResourcesCache(target, resources.resources);
1473
1572
  broadcastResourceListChanged();
1474
1573
  })
1475
1574
  .catch((error) => {
@@ -1481,18 +1580,24 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1481
1580
  });
1482
1581
  }
1483
1582
  if (!dataError) {
1484
- serverInfo.status = 'connected';
1485
- serverInfo.error = null;
1583
+ info.status = 'connected';
1584
+ info.error = null;
1486
1585
  // Set up keep-alive ping for SSE connections via shared service
1487
- setupServerKeepAlive(serverInfo, expandedConf);
1586
+ setupServerKeepAlive(info, expandedConf);
1488
1587
  }
1489
1588
  else {
1490
- serverInfo.status = 'disconnected';
1491
- serverInfo.error = `Failed to list data: ${formatErrorForLogging(dataError)}`;
1492
- setupServerKeepAlive(serverInfo, expandedConf);
1589
+ info.status = 'disconnected';
1590
+ info.error = `Failed to list data: ${formatErrorForLogging(dataError)}`;
1591
+ setupServerKeepAlive(info, expandedConf);
1493
1592
  }
1494
1593
  })
1495
1594
  .catch(async (error) => {
1595
+ const info = liveServerInfo();
1596
+ if (!info) {
1597
+ logger.log(`Discarding superseded connection failure for server: ${name}`);
1598
+ closeServerRuntime(serverInfo);
1599
+ return;
1600
+ }
1496
1601
  // Check if this is an OAuth authorization error
1497
1602
  const isOAuthError = error?.message?.includes('OAuth authorization required') ||
1498
1603
  error?.message?.includes('Authorization required');
@@ -1501,10 +1606,10 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1501
1606
  // and stored the authorization URL in serverInfo.oauth
1502
1607
  logger.log(`OAuth authorization required for server ${name}. Status should be set to 'oauth_required'.`);
1503
1608
  // Make sure status is set correctly
1504
- if (serverInfo.status !== 'oauth_required') {
1505
- serverInfo.status = 'oauth_required';
1609
+ if (info.status !== 'oauth_required') {
1610
+ info.status = 'oauth_required';
1506
1611
  }
1507
- serverInfo.error = null;
1612
+ info.error = null;
1508
1613
  }
1509
1614
  else {
1510
1615
  logger.error('Failed to connect client for server', {
@@ -1512,9 +1617,9 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1512
1617
  error: summarizeErrorForLogging(error),
1513
1618
  });
1514
1619
  // Other connection errors
1515
- serverInfo.status = 'disconnected';
1516
- serverInfo.error = `Failed to connect: ${formatErrorForLogging(error)}`;
1517
- setupServerKeepAlive(serverInfo, expandedConf);
1620
+ info.status = 'disconnected';
1621
+ info.error = `Failed to connect: ${formatErrorForLogging(error)}`;
1622
+ setupServerKeepAlive(info, expandedConf);
1518
1623
  }
1519
1624
  });
1520
1625
  logger.log(`Initialized client for server: ${name}`);
@@ -1596,7 +1701,33 @@ export const getServersInfo = async (page, limit, user) => {
1596
1701
  const filterServerInfos = shouldApplyUserFilter && dataService.filterData
1597
1702
  ? dataService.filterData(filteredServerInfos, user)
1598
1703
  : filteredServerInfos;
1599
- const infos = filterServerInfos
1704
+ const principal = (user ||
1705
+ UserContextService.getInstance().getCurrentUser());
1706
+ const presentedInfos = await Promise.all(filterServerInfos.map(async (info) => {
1707
+ if (!hasCredentialTemplate(info.config) ||
1708
+ info.enabled === false ||
1709
+ !principal?.username ||
1710
+ principal.credentialEligible === false)
1711
+ return info;
1712
+ try {
1713
+ const lease = await principalRuntimes.acquire(info.name, principal);
1714
+ try {
1715
+ return { ...lease.info, config: info.config };
1716
+ }
1717
+ finally {
1718
+ lease.release();
1719
+ }
1720
+ }
1721
+ catch (error) {
1722
+ return {
1723
+ ...info,
1724
+ error: error instanceof CredentialBindingError
1725
+ ? error.message
1726
+ : 'Unable to resolve personal credentials',
1727
+ };
1728
+ }
1729
+ }));
1730
+ const infos = presentedInfos
1600
1731
  .filter((info) => requestedServerNames.has(info.name)) // Only include requested servers
1601
1732
  .map(({ name, version, instructions, owner, visibility, status, tools, prompts, resources, createTime, error, oauth, }) => {
1602
1733
  const serverConfig = allServers.find((server) => server.name === name);
@@ -1653,6 +1784,7 @@ export const getServersInfo = async (page, limit, user) => {
1653
1784
  config: resolvedType || serverConfig?.description || serverConfig?.command
1654
1785
  ? {
1655
1786
  ...(resolvedType ? { type: resolvedType } : {}),
1787
+ ...(hasCredentialTemplate(serverConfig) ? { credentialTemplate: validateCredentialTemplate(serverConfig) } : {}),
1656
1788
  ...(serverConfig?.description ? { description: serverConfig.description } : {}),
1657
1789
  // Expose command so the frontend can determine if reinstall is
1658
1790
  // supported (npx/uvx only). This is not a secret — it's the
@@ -1679,6 +1811,7 @@ export const getServerByOAuthState = (state) => {
1679
1811
  */
1680
1812
  export const reconnectServer = async (serverName) => {
1681
1813
  logger.log(`Reconnecting server: ${serverName}`);
1814
+ principalRuntimes.invalidate({ serverName });
1682
1815
  const serverInfo = getServerByName(serverName);
1683
1816
  if (!serverInfo) {
1684
1817
  throw new Error(`Server not found: ${serverName}`);
@@ -1804,6 +1937,7 @@ const classifyUnavailableReason = (qualifiedName) => {
1804
1937
  };
1805
1938
  // Add new server
1806
1939
  export const addServer = async (name, config) => {
1940
+ validateCredentialTemplate(config);
1807
1941
  const server = { name, ...config };
1808
1942
  const result = await getServerDao().create(server);
1809
1943
  if (result) {
@@ -1824,6 +1958,7 @@ export const removeServer = async (name) => {
1824
1958
  // via npx / npm exec outlives the request and becomes an unkillable orphan
1825
1959
  // that leaks memory until the container is restarted.
1826
1960
  closeServer(name);
1961
+ await deleteCredentialBindings({ serverName: name });
1827
1962
  try {
1828
1963
  await removeServerToolEmbeddings(name);
1829
1964
  }
@@ -1836,6 +1971,7 @@ export const removeServer = async (name) => {
1836
1971
  // Add or update server (supports overriding existing servers for MCPB)
1837
1972
  export const addOrUpdateServer = async (name, config, allowOverride = false) => {
1838
1973
  try {
1974
+ validateCredentialTemplate(config);
1839
1975
  const exists = await getServerDao().exists(name);
1840
1976
  if (exists && !allowOverride) {
1841
1977
  return { success: false, message: 'Server name already exists' };
@@ -1883,7 +2019,14 @@ function checkAuthError(result) {
1883
2019
  }
1884
2020
  }
1885
2021
  }
2022
+ const clearIdleShutdown = (serverInfo) => {
2023
+ if (serverInfo.idleTimeoutId) {
2024
+ clearTimeout(serverInfo.idleTimeoutId);
2025
+ serverInfo.idleTimeoutId = undefined;
2026
+ }
2027
+ };
1886
2028
  const closeServerRuntime = (serverInfo) => {
2029
+ clearIdleShutdown(serverInfo);
1887
2030
  if (serverInfo.keepAliveIntervalId) {
1888
2031
  clearInterval(serverInfo.keepAliveIntervalId);
1889
2032
  serverInfo.keepAliveIntervalId = undefined;
@@ -1917,6 +2060,7 @@ const closeServerRuntime = (serverInfo) => {
1917
2060
  // Close server client and transport (keeps the entry in serverInfos; the next
1918
2061
  // initializeClientsFromSettings rebuild drops it if it is no longer configured).
1919
2062
  export function closeServer(name) {
2063
+ principalRuntimes.invalidate({ serverName: name });
1920
2064
  const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
1921
2065
  if (serverInfo) {
1922
2066
  closeServerRuntime(serverInfo);
@@ -1931,10 +2075,9 @@ export function closeServer(name) {
1931
2075
  * the server offers and trigger a cold-start on the next tool call.
1932
2076
  */
1933
2077
  const shutdownOnDemandServer = (serverInfo) => {
1934
- if (serverInfo.idleTimeoutId) {
1935
- clearTimeout(serverInfo.idleTimeoutId);
1936
- serverInfo.idleTimeoutId = undefined;
1937
- }
2078
+ // Discovery priming may finish while a tool call is using the same child.
2079
+ if (serverInfo.activeToolCalls)
2080
+ return;
1938
2081
  // Close runtime (kills process) but intentionally keep tools/prompts/resources
1939
2082
  closeServerRuntime(serverInfo);
1940
2083
  serverInfo.status = 'disconnected';
@@ -1942,14 +2085,17 @@ const shutdownOnDemandServer = (serverInfo) => {
1942
2085
  `${serverInfo.tools.length} tools cached for next wake-up`);
1943
2086
  };
1944
2087
  /**
1945
- * Resets (or starts) the idle-shutdown timer for an on-demand server.
1946
- * Call this after every successful tool invocation.
2088
+ * Starts the idle period once the last active call has finished.
1947
2089
  */
1948
2090
  const scheduleIdleShutdown = (serverInfo) => {
1949
- if (!serverInfo.config?.startOnDemand)
2091
+ clearIdleShutdown(serverInfo);
2092
+ if (!serverInfo.config?.startOnDemand ||
2093
+ !isStdioServer(serverInfo.config) ||
2094
+ hasCredentialTemplate(serverInfo.config) ||
2095
+ serverInfo.status !== 'connected' ||
2096
+ !serverInfo.client ||
2097
+ serverInfo.activeToolCalls) {
1950
2098
  return;
1951
- if (serverInfo.idleTimeoutId) {
1952
- clearTimeout(serverInfo.idleTimeoutId);
1953
2099
  }
1954
2100
  const idleMs = serverInfo.config.idleTimeoutMs ?? 300000; // default 5 minutes
1955
2101
  serverInfo.idleTimeoutId = setTimeout(() => {
@@ -1958,6 +2104,19 @@ const scheduleIdleShutdown = (serverInfo) => {
1958
2104
  }
1959
2105
  }, idleMs);
1960
2106
  };
2107
+ const beginOnDemandToolCall = (serverInfo) => {
2108
+ if (!serverInfo.config?.startOnDemand ||
2109
+ !isStdioServer(serverInfo.config) ||
2110
+ hasCredentialTemplate(serverInfo.config)) {
2111
+ return undefined;
2112
+ }
2113
+ clearIdleShutdown(serverInfo);
2114
+ serverInfo.activeToolCalls = (serverInfo.activeToolCalls ?? 0) + 1;
2115
+ return () => {
2116
+ serverInfo.activeToolCalls = serverInfo.activeToolCalls - 1;
2117
+ scheduleIdleShutdown(serverInfo);
2118
+ };
2119
+ };
1961
2120
  /**
1962
2121
  * Ensures an on-demand server is connected before a tool call proceeds.
1963
2122
  *
@@ -2094,6 +2253,7 @@ const isStdioServer = (conf) => !!conf && (conf.type === 'stdio' || (!conf.type
2094
2253
  */
2095
2254
  const primeOnDemandServers = () => {
2096
2255
  const targets = serverInfos.filter((si) => si.config?.startOnDemand === true &&
2256
+ !hasCredentialTemplate(si.config) &&
2097
2257
  isStdioServer(si.config) &&
2098
2258
  si.tools.length === 0 &&
2099
2259
  si.enabled !== false);
@@ -2103,9 +2263,9 @@ const primeOnDemandServers = () => {
2103
2263
  return Promise.allSettled(targets.map(async (si) => {
2104
2264
  try {
2105
2265
  await ensureServerReady(si);
2106
- // Sleep the child but keep the cached tool list visible to agents.
2266
+ // Sleep the child unless a tool call is using it; keep the cached tool list.
2107
2267
  shutdownOnDemandServer(si);
2108
- logger.log('On-demand server primed and asleep');
2268
+ logger.log('On-demand server tool cache primed');
2109
2269
  }
2110
2270
  catch (error) {
2111
2271
  logger.warn('Failed to prime on-demand server', {
@@ -2330,12 +2490,18 @@ const assertToolAvailableForRoute = (tool, appsRouteContext) => {
2330
2490
  throw new Error(`Tool '${tool.name}' is only available to MCP Apps`);
2331
2491
  }
2332
2492
  };
2333
- const resolveToolInGroup = async (group, toolName, allowRawName) => {
2493
+ const resolveToolInGroup = async (group, toolName) => {
2334
2494
  const lookupGroup = getGroupLookupName(group);
2335
2495
  if (!lookupGroup) {
2336
2496
  return undefined;
2337
2497
  }
2338
2498
  const { filteredServerInfos, serverConfigsByName } = await getFilteredServerInfosForGroup(lookupGroup);
2499
+ // Accept raw names only on single-upstream routes, even without client
2500
+ // capabilities after session rebuild. Count configured servers as well so
2501
+ // a disabled or disconnected peer cannot make a group appear unambiguous.
2502
+ const allowRawName = !isSmartRoutingGroup(group) &&
2503
+ serverConfigsByName.size <= 1 &&
2504
+ filteredServerInfos.length === 1;
2339
2505
  for (const serverInfo of filteredServerInfos) {
2340
2506
  // A disconnected on-demand server may still have a cached tool list from a
2341
2507
  // previous wake-up; allow it through so it can be selected and woken by the
@@ -2402,7 +2568,7 @@ const projectToolForDownstream = (serverName, tool, appsRouteContext, serverConf
2402
2568
  name: projectNameForGroup(projectedTool.name, serverName, serverConfig),
2403
2569
  };
2404
2570
  };
2405
- export const handleListToolsRequest = async (_, extra) => {
2571
+ const handleListToolsRequestImpl = async (_, extra) => {
2406
2572
  const sessionId = extra.sessionId || '';
2407
2573
  const group = getGroup(sessionId);
2408
2574
  logger.log(`Handling ListToolsRequest for group: ${group}`);
@@ -2446,7 +2612,7 @@ export const handleListToolsRequest = async (_, extra) => {
2446
2612
  tools: allTools,
2447
2613
  };
2448
2614
  };
2449
- export const handleCallToolRequest = async (request, extra) => {
2615
+ const handleCallToolRequestImpl = async (request, extra) => {
2450
2616
  logger.log('Handling CallToolRequest for tool', summarizeToolRequestForLogging(request.params));
2451
2617
  const startTime = Date.now();
2452
2618
  const activityLogger = getActivityLoggingService();
@@ -2478,6 +2644,7 @@ export const handleCallToolRequest = async (request, extra) => {
2478
2644
  const keyName = bearerKeyContext.keyName || extra?.keyName || undefined;
2479
2645
  const sourceIp = requestContextService.getRequestContext()?.remoteAddress || undefined;
2480
2646
  let hostedReservation = null;
2647
+ let releaseOnDemandToolCall;
2481
2648
  const reserveHostedIfNeeded = async (serverName, toolName) => {
2482
2649
  const hostedAuth = requestContextService.getHostedAuthContext();
2483
2650
  assertHostedToolAllowed(hostedAuth, serverName, toolName);
@@ -2527,7 +2694,7 @@ export const handleCallToolRequest = async (request, extra) => {
2527
2694
  targetServerInfo = getVisibleServerByName(extra.server);
2528
2695
  }
2529
2696
  else if (getGroupLookupName(group)) {
2530
- const groupTool = await resolveToolInGroup(group, toolName, false);
2697
+ const groupTool = await resolveToolInGroup(group, toolName);
2531
2698
  if (groupTool) {
2532
2699
  targetServerInfo = groupTool.serverInfo;
2533
2700
  targetToolName = groupTool.toolName;
@@ -2547,6 +2714,7 @@ export const handleCallToolRequest = async (request, extra) => {
2547
2714
  }
2548
2715
  // If the target is an on-demand server that is not yet running, wake it up now.
2549
2716
  // Concurrent callers are serialised via ensureServerReady's singleton promise.
2717
+ releaseOnDemandToolCall = beginOnDemandToolCall(targetServerInfo);
2550
2718
  if (targetServerInfo.config?.startOnDemand && targetServerInfo.status !== 'connected') {
2551
2719
  await ensureServerReady(targetServerInfo);
2552
2720
  // Re-read status from the mutated serverInfo after async spawn
@@ -2626,6 +2794,7 @@ export const handleCallToolRequest = async (request, extra) => {
2626
2794
  keyId,
2627
2795
  keyName,
2628
2796
  sourceIp,
2797
+ hasCredentialTemplate: hasCredentialTemplate(targetServerInfo.config),
2629
2798
  });
2630
2799
  return await maybeCompressToolResult({
2631
2800
  content: [
@@ -2643,7 +2812,7 @@ export const handleCallToolRequest = async (request, extra) => {
2643
2812
  // Call the tool on the target server (MCP servers)
2644
2813
  // For servers with perSessionClient: true, use a per-session dedicated client
2645
2814
  let isolatedCtx;
2646
- if (targetServerInfo.config?.perSessionClient && sessionId) {
2815
+ if (targetServerInfo.config?.perSessionClient && !hasCredentialTemplate(targetServerInfo.config) && sessionId) {
2647
2816
  const isolated = await getOrCreateIsolatedClient(sessionId, targetServerInfo);
2648
2817
  isolatedCtx = { sessionId, client: isolated.client, transport: isolated.transport };
2649
2818
  }
@@ -2674,8 +2843,6 @@ export const handleCallToolRequest = async (request, extra) => {
2674
2843
  toolName: cleanToolName,
2675
2844
  result: summarizeToolResultForLogging(result),
2676
2845
  });
2677
- // Reset idle-shutdown timer for on-demand servers after each successful call
2678
- scheduleIdleShutdown(targetServerInfo);
2679
2846
  // Log successful activity
2680
2847
  const duration = Date.now() - startTime;
2681
2848
  await activityLogger.logToolCall({
@@ -2690,6 +2857,7 @@ export const handleCallToolRequest = async (request, extra) => {
2690
2857
  keyId,
2691
2858
  keyName,
2692
2859
  sourceIp,
2860
+ hasCredentialTemplate: hasCredentialTemplate(targetServerInfo.config),
2693
2861
  errorMessage: result.isError ? 'Tool returned error response' : undefined,
2694
2862
  });
2695
2863
  return await maybeCompressToolResult(result, {
@@ -2708,7 +2876,7 @@ export const handleCallToolRequest = async (request, extra) => {
2708
2876
  // (assertToolAvailableForRoute below still gates on appsRouteContext.enabled).
2709
2877
  const singleServerAppsRoute = !!appsRouteContext.serverInfo;
2710
2878
  const groupTool = !singleServerAppsRoute && lookupGroup
2711
- ? await resolveToolInGroup(lookupGroup, request.params.name, false)
2879
+ ? await resolveToolInGroup(group, request.params.name)
2712
2880
  : undefined;
2713
2881
  const serverInfo = singleServerAppsRoute
2714
2882
  ? appsRouteContext.serverInfo
@@ -2723,6 +2891,7 @@ export const handleCallToolRequest = async (request, extra) => {
2723
2891
  // Wake the on-demand server before invoking the tool. It may be asleep with
2724
2892
  // a cached tool list (visible above) but no live client. Mirrors the
2725
2893
  // $smart call_tool path. See #1029.
2894
+ releaseOnDemandToolCall = beginOnDemandToolCall(serverInfo);
2726
2895
  if (serverInfo.config?.startOnDemand && serverInfo.status !== 'connected') {
2727
2896
  await ensureServerReady(serverInfo);
2728
2897
  }
@@ -2789,6 +2958,7 @@ export const handleCallToolRequest = async (request, extra) => {
2789
2958
  keyId,
2790
2959
  keyName,
2791
2960
  sourceIp,
2961
+ hasCredentialTemplate: hasCredentialTemplate(serverInfo.config),
2792
2962
  });
2793
2963
  return await maybeCompressToolResult({
2794
2964
  content: [
@@ -2806,7 +2976,7 @@ export const handleCallToolRequest = async (request, extra) => {
2806
2976
  // Handle MCP servers
2807
2977
  // For servers with perSessionClient: true, use a per-session dedicated client
2808
2978
  let isolatedCtx;
2809
- if (serverInfo.config?.perSessionClient && sessionId) {
2979
+ if (serverInfo.config?.perSessionClient && !hasCredentialTemplate(serverInfo.config) && sessionId) {
2810
2980
  const isolated = await getOrCreateIsolatedClient(sessionId, serverInfo);
2811
2981
  isolatedCtx = { sessionId, client: isolated.client, transport: isolated.transport };
2812
2982
  }
@@ -2826,8 +2996,6 @@ export const handleCallToolRequest = async (request, extra) => {
2826
2996
  toolName: cleanToolName,
2827
2997
  result: summarizeToolResultForLogging(result),
2828
2998
  });
2829
- // Reset idle-shutdown timer for on-demand servers after each successful call
2830
- scheduleIdleShutdown(serverInfo);
2831
2999
  // Log successful activity
2832
3000
  const duration = Date.now() - startTime;
2833
3001
  await activityLogger.logToolCall({
@@ -2842,6 +3010,7 @@ export const handleCallToolRequest = async (request, extra) => {
2842
3010
  keyId,
2843
3011
  keyName,
2844
3012
  sourceIp,
3013
+ hasCredentialTemplate: hasCredentialTemplate(serverInfo.config),
2845
3014
  errorMessage: result.isError ? 'Tool returned error response' : undefined,
2846
3015
  });
2847
3016
  return await maybeCompressToolResult(result, {
@@ -2880,6 +3049,7 @@ export const handleCallToolRequest = async (request, extra) => {
2880
3049
  keyId,
2881
3050
  keyName,
2882
3051
  sourceIp,
3052
+ hasCredentialTemplate: hasCredentialTemplate(serverInfo?.config),
2883
3053
  // The reason is an internal diagnostic; the caller-facing message stays
2884
3054
  // identical for hidden and nonexistent targets.
2885
3055
  errorMessage: unavailable
@@ -2899,8 +3069,11 @@ export const handleCallToolRequest = async (request, extra) => {
2899
3069
  isError: true,
2900
3070
  };
2901
3071
  }
3072
+ finally {
3073
+ releaseOnDemandToolCall?.();
3074
+ }
2902
3075
  };
2903
- export const handleGetPromptRequest = async (request, extra) => {
3076
+ const handleGetPromptRequestImpl = async (request, extra) => {
2904
3077
  try {
2905
3078
  const { name, arguments: promptArgs } = request.params;
2906
3079
  const sessionId = extra?.sessionId || '';
@@ -2989,7 +3162,7 @@ export const handleGetPromptRequest = async (request, extra) => {
2989
3162
  };
2990
3163
  }
2991
3164
  };
2992
- export const handleListPromptsRequest = async (_, extra) => {
3165
+ const handleListPromptsRequestImpl = async (_, extra) => {
2993
3166
  const sessionId = extra.sessionId || '';
2994
3167
  const group = getGroup(sessionId);
2995
3168
  const lookupGroup = getGroupLookupName(group);
@@ -3033,7 +3206,7 @@ export const handleListPromptsRequest = async (_, extra) => {
3033
3206
  prompts: allPrompts,
3034
3207
  };
3035
3208
  };
3036
- export const handleListResourcesRequest = async (_, extra) => {
3209
+ const handleListResourcesRequestImpl = async (_, extra) => {
3037
3210
  const sessionId = extra.sessionId || '';
3038
3211
  const group = getGroup(sessionId);
3039
3212
  const lookupGroup = getGroupLookupName(group);
@@ -3078,7 +3251,7 @@ export const handleListResourcesRequest = async (_, extra) => {
3078
3251
  resources: allResources,
3079
3252
  };
3080
3253
  };
3081
- export const handleListResourceTemplatesRequest = async (_, extra) => {
3254
+ const handleListResourceTemplatesRequestImpl = async (_, extra) => {
3082
3255
  const sessionId = extra.sessionId || '';
3083
3256
  const group = getGroup(sessionId);
3084
3257
  const lookupGroup = getGroupLookupName(group);
@@ -3101,7 +3274,7 @@ export const handleListResourceTemplatesRequest = async (_, extra) => {
3101
3274
  resourceTemplates: results.flatMap((result) => result.status === 'fulfilled' ? result.value : []),
3102
3275
  };
3103
3276
  };
3104
- export const handleReadResourceRequest = async (request, extra) => {
3277
+ const handleReadResourceRequestImpl = async (request, extra) => {
3105
3278
  try {
3106
3279
  const { uri } = request.params;
3107
3280
  const sessionId = extra.sessionId || '';
@@ -3198,6 +3371,197 @@ export const handleReadResourceRequest = async (request, extra) => {
3198
3371
  };
3199
3372
  }
3200
3373
  };
3374
+ // Personal runtimes never enter serverInfos, config exports, or shared embedding caches.
3375
+ const createPrincipalRuntime = async (name, resolvedConfig) => {
3376
+ const info = {
3377
+ name,
3378
+ owner: resolvedConfig.owner,
3379
+ visibility: resolvedConfig.visibility,
3380
+ sharedWithUsers: resolvedConfig.sharedWithUsers,
3381
+ enabled: true,
3382
+ config: resolvedConfig,
3383
+ options: resolvedConfig.options,
3384
+ status: 'connecting',
3385
+ error: null,
3386
+ tools: [],
3387
+ prompts: [],
3388
+ resources: [],
3389
+ createTime: Date.now(),
3390
+ };
3391
+ const safeMethods = new Set([
3392
+ 'callTool',
3393
+ 'getPrompt',
3394
+ 'readResource',
3395
+ 'listTools',
3396
+ 'listPrompts',
3397
+ 'listResources',
3398
+ 'listResourceTemplates',
3399
+ ]);
3400
+ const protectClient = (client) => new Proxy(client, {
3401
+ get(target, property) {
3402
+ const value = Reflect.get(target, property);
3403
+ if (typeof value !== 'function')
3404
+ return value;
3405
+ if (!safeMethods.has(String(property)))
3406
+ return value.bind(target);
3407
+ return async (...args) => {
3408
+ try {
3409
+ return await value.apply(target, args);
3410
+ }
3411
+ catch {
3412
+ info.status = 'disconnected';
3413
+ throw new CredentialBindingError(`Personal credential request failed for '${name}'. Check your binding in Credentials.`);
3414
+ }
3415
+ };
3416
+ },
3417
+ });
3418
+ try {
3419
+ if (resolvedConfig.type === 'openapi') {
3420
+ const client = new OpenAPIClient(resolvedConfig);
3421
+ await client.initialize();
3422
+ info.openApiClient = protectClient(client);
3423
+ info.tools = client
3424
+ .getTools()
3425
+ .map((tool) => ({
3426
+ ...tool,
3427
+ name: `${name}${getNameSeparator()}${tool.name}`,
3428
+ inputSchema: cleanInputSchema(tool.inputSchema),
3429
+ }));
3430
+ }
3431
+ else {
3432
+ const transport = await createTransportFromConfig(name, resolvedConfig);
3433
+ const client = new Client({ name: `mcp-client-${name}`, version: '1.0.0' }, {
3434
+ capabilities: MCP_APPS_CAPABILITIES,
3435
+ jsonSchemaValidator: new ResilientJsonSchemaValidator(),
3436
+ });
3437
+ info.transport = transport;
3438
+ info.client = protectClient(client);
3439
+ await client.connect(transport, resolvedConfig.options || {});
3440
+ const capabilities = client.getServerCapabilities();
3441
+ if (capabilities?.tools)
3442
+ info.tools = (await client.listTools({}, resolvedConfig.options)).tools.map((tool) => normalizeToolForCache(name, tool));
3443
+ if (capabilities?.prompts)
3444
+ info.prompts = (await client.listPrompts({}, resolvedConfig.options)).prompts.map((prompt) => normalizePromptForCache(name, prompt));
3445
+ if (capabilities?.resources)
3446
+ info.resources = (await client.listResources({}, resolvedConfig.options)).resources.map(normalizeResourceForCache);
3447
+ info.version = client.getServerVersion()?.version;
3448
+ client.onclose = () => {
3449
+ info.status = 'disconnected';
3450
+ };
3451
+ }
3452
+ info.status = 'connected';
3453
+ return info;
3454
+ }
3455
+ catch {
3456
+ closeServerRuntime(info);
3457
+ throw new CredentialBindingError(`Unable to connect '${name}' with your personal credentials. Check your binding in Credentials.`);
3458
+ }
3459
+ };
3460
+ const principalRuntimes = new PrincipalRuntimeService(createPrincipalRuntime, closeServerRuntime);
3461
+ const withPrincipalServers = (handler, operation) => async (request, extra) => {
3462
+ if (!serverInfos.some((info) => hasCredentialTemplate(info.config)))
3463
+ return handler(request, extra);
3464
+ // Built-in content does not require an upstream credential.
3465
+ if (operation === 'prompt') {
3466
+ const builtin = await getBuiltinPromptDao().findByName(request.params?.name);
3467
+ if (builtin && builtin.enabled !== false)
3468
+ return handler(request, extra);
3469
+ }
3470
+ else if (operation === 'resource') {
3471
+ const builtin = await getBuiltinResourceDao().findByUri(request.params?.uri);
3472
+ if (builtin && builtin.enabled !== false)
3473
+ return handler(request, extra);
3474
+ }
3475
+ const group = RequestContextService.getInstance().getGroupContext() ||
3476
+ extra?.group ||
3477
+ getGroup(extra?.sessionId || '') ||
3478
+ undefined;
3479
+ const { filteredServerInfos: candidates, serverConfigsByName } = await getFilteredServerInfosForGroup(getGroupLookupName(group));
3480
+ const requestedName = ['call_tool', 'describe_tool'].includes(request?.params?.name)
3481
+ ? request.params.arguments?.toolName
3482
+ : request?.params?.name;
3483
+ const target = candidates.find((info) => {
3484
+ const exposedName = getExposedServerName(info.name, serverConfigsByName.get(info.name));
3485
+ return (extra?.server === info.name ||
3486
+ (typeof requestedName === 'string' &&
3487
+ requestedName.startsWith(`${exposedName}${getNameSeparator()}`)));
3488
+ });
3489
+ // A call to a known target must not spawn or keep unrelated personal children alive.
3490
+ const selected = target && (operation === 'tool' || operation === 'prompt') ? [target] : candidates;
3491
+ const templated = selected.filter((info) => hasCredentialTemplate(info.config));
3492
+ if (!templated.length)
3493
+ return handler(request, extra);
3494
+ const user = UserContextService.getInstance().getCurrentUser();
3495
+ const releases = [];
3496
+ const scoped = new Map(serverInfos.map((info) => [info.name, info]));
3497
+ const failures = new Map();
3498
+ try {
3499
+ await Promise.all(templated.map(async (info) => {
3500
+ try {
3501
+ if (!user?.username ||
3502
+ user.credentialEligible === false ||
3503
+ RequestContextService.getInstance().getKeyKindContext() === 'system')
3504
+ throw missingCredentialError(info.name);
3505
+ const lease = await principalRuntimes.acquire(info.name, user);
3506
+ releases.push(lease.release);
3507
+ scoped.set(info.name, lease.info);
3508
+ }
3509
+ catch (error) {
3510
+ failures.set(info.name, error instanceof CredentialBindingError
3511
+ ? error
3512
+ : new CredentialBindingError('Unable to resolve personal credentials'));
3513
+ }
3514
+ }));
3515
+ const requiredTarget = target ||
3516
+ (candidates.length === 1 && operation !== 'list' && requestedName !== 'search_tools'
3517
+ ? templated[0]
3518
+ : undefined);
3519
+ const failure = requiredTarget ? failures.get(requiredTarget.name) : undefined;
3520
+ if (failure) {
3521
+ if (operation === 'resource')
3522
+ return {
3523
+ contents: [
3524
+ { uri: request.params?.uri || '', mimeType: 'text/plain', text: failure.message },
3525
+ ],
3526
+ };
3527
+ return { content: [{ type: 'text', text: failure.message }], isError: true };
3528
+ }
3529
+ // Dashboard/OpenAPI callers may address a tool by its raw name plus server.
3530
+ let resolvedRequest = request;
3531
+ if (operation === 'tool' &&
3532
+ extra?.server &&
3533
+ hasCredentialTemplate(scoped.get(extra.server)?.config)) {
3534
+ const prefix = `${extra.server}${getNameSeparator()}`;
3535
+ if (typeof requestedName === 'string' && !requestedName.startsWith(prefix)) {
3536
+ resolvedRequest =
3537
+ request.params.name === 'call_tool'
3538
+ ? {
3539
+ ...request,
3540
+ params: {
3541
+ ...request.params,
3542
+ arguments: {
3543
+ ...request.params.arguments,
3544
+ toolName: `${prefix}${requestedName}`,
3545
+ },
3546
+ },
3547
+ }
3548
+ : { ...request, params: { ...request.params, name: `${prefix}${requestedName}` } };
3549
+ }
3550
+ }
3551
+ return await principalServerScope.run([...scoped.values()], () => handler(resolvedRequest, extra));
3552
+ }
3553
+ finally {
3554
+ for (const release of releases)
3555
+ release();
3556
+ }
3557
+ };
3558
+ export const handleListToolsRequest = withPrincipalServers(handleListToolsRequestImpl, 'list');
3559
+ export const handleCallToolRequest = withPrincipalServers(handleCallToolRequestImpl, 'tool');
3560
+ export const handleGetPromptRequest = withPrincipalServers(handleGetPromptRequestImpl, 'prompt');
3561
+ export const handleListPromptsRequest = withPrincipalServers(handleListPromptsRequestImpl, 'list');
3562
+ export const handleListResourcesRequest = withPrincipalServers(handleListResourcesRequestImpl, 'list');
3563
+ export const handleListResourceTemplatesRequest = withPrincipalServers(handleListResourceTemplatesRequestImpl, 'list');
3564
+ export const handleReadResourceRequest = withPrincipalServers(handleReadResourceRequestImpl, 'resource');
3201
3565
  export const createMcpServer = (name, version, options) => {
3202
3566
  const normalizedOptions = typeof options === 'string' ? { group: options } : (options ?? {});
3203
3567
  // Determine server name based on routing type