@samanhappy/mcphub 1.1.2 → 1.1.4

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 +6 -0
  2. package/README.md +6 -0
  3. package/README.zh.md +4 -0
  4. package/dist/cli/http.js +54 -1
  5. package/dist/cli/http.js.map +1 -1
  6. package/dist/controllers/mcpbController.js +6 -2
  7. package/dist/controllers/mcpbController.js.map +1 -1
  8. package/dist/controllers/oauthCallbackController.js +11 -16
  9. package/dist/controllers/oauthCallbackController.js.map +1 -1
  10. package/dist/controllers/oauthServerController.js +10 -7
  11. package/dist/controllers/oauthServerController.js.map +1 -1
  12. package/dist/controllers/personalOAuthController.js +119 -0
  13. package/dist/controllers/personalOAuthController.js.map +1 -0
  14. package/dist/controllers/serverController.js +17 -2
  15. package/dist/controllers/serverController.js.map +1 -1
  16. package/dist/controllers/smartRoutingController.js +4 -4
  17. package/dist/controllers/smartRoutingController.js.map +1 -1
  18. package/dist/dao/CredentialBindingDao.js +8 -0
  19. package/dist/dao/CredentialBindingDao.js.map +1 -1
  20. package/dist/dao/CredentialBindingDaoDbImpl.js +14 -0
  21. package/dist/dao/CredentialBindingDaoDbImpl.js.map +1 -1
  22. package/dist/dao/SystemConfigDaoDbImpl.js +3 -0
  23. package/dist/dao/SystemConfigDaoDbImpl.js.map +1 -1
  24. package/dist/db/entities/SystemConfig.js +4 -0
  25. package/dist/db/entities/SystemConfig.js.map +1 -1
  26. package/dist/routes/index.js +8 -1
  27. package/dist/routes/index.js.map +1 -1
  28. package/dist/services/cimdClientService.js +2 -1
  29. package/dist/services/cimdClientService.js.map +1 -1
  30. package/dist/services/credentialBindingService.js +14 -8
  31. package/dist/services/credentialBindingService.js.map +1 -1
  32. package/dist/services/mcpOAuthProvider.js +40 -24
  33. package/dist/services/mcpOAuthProvider.js.map +1 -1
  34. package/dist/services/mcpService.js +120 -50
  35. package/dist/services/mcpService.js.map +1 -1
  36. package/dist/services/oauthService.js +3 -1
  37. package/dist/services/oauthService.js.map +1 -1
  38. package/dist/services/personalOAuthService.js +235 -0
  39. package/dist/services/personalOAuthService.js.map +1 -0
  40. package/dist/services/principalRuntimeService.js +67 -45
  41. package/dist/services/principalRuntimeService.js.map +1 -1
  42. package/dist/services/serverConfigPresenter.js +11 -1
  43. package/dist/services/serverConfigPresenter.js.map +1 -1
  44. package/dist/services/sseService.js +10 -30
  45. package/dist/services/sseService.js.map +1 -1
  46. package/dist/services/upstreamOAuthDisconnectService.js +2 -2
  47. package/dist/services/upstreamOAuthDisconnectService.js.map +1 -1
  48. package/dist/utils/credentialTemplate.js +37 -6
  49. package/dist/utils/credentialTemplate.js.map +1 -1
  50. package/dist/utils/migration.js +1 -0
  51. package/dist/utils/migration.js.map +1 -1
  52. package/dist/utils/protectedResource.js +44 -0
  53. package/dist/utils/protectedResource.js.map +1 -0
  54. package/dist/utils/serverConfigPersistence.js +13 -0
  55. package/dist/utils/serverConfigPersistence.js.map +1 -1
  56. package/dist/utils/ssrf.js +47 -12
  57. package/dist/utils/ssrf.js.map +1 -1
  58. package/dist/utils/upstreamError.js +8 -1
  59. package/dist/utils/upstreamError.js.map +1 -1
  60. package/frontend/dist/assets/{ActivityPage-DFLMe7o2.js → ActivityPage-Cmpa6PNN.js} +2 -2
  61. package/frontend/dist/assets/{ActivityPage-DFLMe7o2.js.map → ActivityPage-Cmpa6PNN.js.map} +1 -1
  62. package/frontend/dist/assets/{CopyClientConfigDialog-DukyyXVp.js → CopyClientConfigDialog-CAqbh1Uh.js} +3 -3
  63. package/frontend/dist/assets/{CopyClientConfigDialog-DukyyXVp.js.map → CopyClientConfigDialog-CAqbh1Uh.js.map} +1 -1
  64. package/frontend/dist/assets/{CredentialsPage-BpBCEmEE.js → CredentialsPage-Cwr2rAVa.js} +2 -2
  65. package/frontend/dist/assets/{CredentialsPage-BpBCEmEE.js.map → CredentialsPage-Cwr2rAVa.js.map} +1 -1
  66. package/frontend/dist/assets/{Dashboard-DjZkbAPt.js → Dashboard-7XIOjzwF.js} +2 -2
  67. package/frontend/dist/assets/{Dashboard-DjZkbAPt.js.map → Dashboard-7XIOjzwF.js.map} +1 -1
  68. package/frontend/dist/assets/EndpointCopy-CZVjSaZJ.js +2 -0
  69. package/frontend/dist/assets/{EndpointCopy-Bv-_FPmR.js.map → EndpointCopy-CZVjSaZJ.js.map} +1 -1
  70. package/frontend/dist/assets/{GroupsPage-B8BWRIWv.js → GroupsPage-BpHUw2qs.js} +3 -3
  71. package/frontend/dist/assets/{GroupsPage-B8BWRIWv.js.map → GroupsPage-BpHUw2qs.js.map} +1 -1
  72. package/frontend/dist/assets/{LoginPage-OH73zcbT.js → LoginPage-ClSBXFLy.js} +2 -2
  73. package/frontend/dist/assets/{LoginPage-OH73zcbT.js.map → LoginPage-ClSBXFLy.js.map} +1 -1
  74. package/frontend/dist/assets/{LogsPage-B6P_uQvL.js → LogsPage-CVuaA5oX.js} +2 -2
  75. package/frontend/dist/assets/{LogsPage-B6P_uQvL.js.map → LogsPage-CVuaA5oX.js.map} +1 -1
  76. package/frontend/dist/assets/{MarketPage-CKjkgZpe.js → MarketPage-BveXMx84.js} +2 -2
  77. package/frontend/dist/assets/{MarketPage-CKjkgZpe.js.map → MarketPage-BveXMx84.js.map} +1 -1
  78. package/frontend/dist/assets/{OAuthConsentPage-DjvtbLnm.js → OAuthConsentPage-B7kOLjXw.js} +2 -2
  79. package/frontend/dist/assets/{OAuthConsentPage-DjvtbLnm.js.map → OAuthConsentPage-B7kOLjXw.js.map} +1 -1
  80. package/frontend/dist/assets/{PromptsPage-CCMb9OH2.js → PromptsPage-DFH6bLuP.js} +2 -2
  81. package/frontend/dist/assets/{PromptsPage-CCMb9OH2.js.map → PromptsPage-DFH6bLuP.js.map} +1 -1
  82. package/frontend/dist/assets/{ResourcesPage-BERaKn0d.js → ResourcesPage-Dsjlzp3t.js} +2 -2
  83. package/frontend/dist/assets/{ResourcesPage-BERaKn0d.js.map → ResourcesPage-Dsjlzp3t.js.map} +1 -1
  84. package/frontend/dist/assets/ServersPage-BXEDxyLa.js +39 -0
  85. package/frontend/dist/assets/ServersPage-BXEDxyLa.js.map +1 -0
  86. package/frontend/dist/assets/SettingsPage-B1zU66rQ.js +14 -0
  87. package/frontend/dist/assets/SettingsPage-B1zU66rQ.js.map +1 -0
  88. package/frontend/dist/assets/{StatusDot-IWYCjv_t.js → StatusDot-Cp0G2KUZ.js} +2 -2
  89. package/frontend/dist/assets/{StatusDot-IWYCjv_t.js.map → StatusDot-Cp0G2KUZ.js.map} +1 -1
  90. package/frontend/dist/assets/{ToggleGroup-B_NGGU8p.js → ToggleGroup-BoQ324GW.js} +2 -2
  91. package/frontend/dist/assets/{ToggleGroup-B_NGGU8p.js.map → ToggleGroup-BoQ324GW.js.map} +1 -1
  92. package/frontend/dist/assets/{UsersPage-D9QdH-ma.js → UsersPage-B27T44p9.js} +2 -2
  93. package/frontend/dist/assets/{UsersPage-D9QdH-ma.js.map → UsersPage-B27T44p9.js.map} +1 -1
  94. package/frontend/dist/assets/{clipboard-CFeeSZ4D.js → clipboard-ChKqfVYn.js} +2 -2
  95. package/frontend/dist/assets/{clipboard-CFeeSZ4D.js.map → clipboard-ChKqfVYn.js.map} +1 -1
  96. package/frontend/dist/assets/{icons-vendor-CEwDZ1lN.js → icons-vendor-CkcU1LMA.js} +2 -2
  97. package/frontend/dist/assets/{icons-vendor-CEwDZ1lN.js.map → icons-vendor-CkcU1LMA.js.map} +1 -1
  98. package/frontend/dist/assets/index-dQlYMTl9.js +3 -0
  99. package/frontend/dist/assets/index-dQlYMTl9.js.map +1 -0
  100. package/frontend/dist/assets/{resourceService-BoJqsssR.js → resourceService-CmNa5REv.js} +2 -2
  101. package/frontend/dist/assets/{resourceService-BoJqsssR.js.map → resourceService-CmNa5REv.js.map} +1 -1
  102. package/frontend/dist/assets/{useCostData-Dld-MisV.js → useCostData-BrV9Dqf7.js} +2 -2
  103. package/frontend/dist/assets/{useCostData-Dld-MisV.js.map → useCostData-BrV9Dqf7.js.map} +1 -1
  104. package/frontend/dist/assets/variableDetection-sA1AjKyF.js +16 -0
  105. package/frontend/dist/assets/variableDetection-sA1AjKyF.js.map +1 -0
  106. package/frontend/dist/index.html +2 -2
  107. package/package.json +1 -1
  108. package/frontend/dist/assets/EndpointCopy-Bv-_FPmR.js +0 -2
  109. package/frontend/dist/assets/ServersPage-BNsFiZez.js +0 -39
  110. package/frontend/dist/assets/ServersPage-BNsFiZez.js.map +0 -1
  111. package/frontend/dist/assets/SettingsPage-PiL7o9b9.js +0 -12
  112. package/frontend/dist/assets/SettingsPage-PiL7o9b9.js.map +0 -1
  113. package/frontend/dist/assets/index-RTKBtgGw.js +0 -3
  114. package/frontend/dist/assets/index-RTKBtgGw.js.map +0 -1
  115. package/frontend/dist/assets/variableDetection-C1abuc-p.js +0 -16
  116. package/frontend/dist/assets/variableDetection-C1abuc-p.js.map +0 -1
@@ -1,15 +1,17 @@
1
+ import { hasPersonalOAuth, PersonalOAuthContext, personalOAuthContexts, personalAuthorizationRequired, } from './personalOAuthService.js';
1
2
  import { preserveListFreshness, combineListFreshness, remainingListTtl, } from '../utils/listFreshness.js';
2
3
  import { expandServerConfig, getServerEnvironment } from './serverConfigEnvironment.js';
3
4
  import { getMcpRequestGroup } from '../utils/mcpRequestGroup.js';
4
5
  import { LegacyMcpClient } from '../clients/legacyMcpClient.js';
5
6
  import { LEGACY_PROTOCOL_VERSIONS } from '../utils/mcpProtocol.js';
6
7
  import { AsyncLocalStorage } from 'node:async_hooks';
8
+ import { createHash } from 'node:crypto';
7
9
  import { canAccessGroupRoute } from '../utils/groupAccess.js';
8
10
  import { canServeToolRequests } from '../utils/serverAvailability.js';
9
11
  import { PrincipalRuntimeService } from './principalRuntimeService.js';
10
12
  import { UserContextService } from './userContextService.js';
11
13
  import { deleteCredentialBindings, missingCredentialError } from './credentialBindingService.js';
12
- import { CredentialBindingError, hasCredentialTemplate, validateCredentialTemplate, } from '../utils/credentialTemplate.js';
14
+ import { CredentialBindingError, hasCredentialTemplate, hasPersonalAuthentication, resolveCredentialArguments, validateCredentialTemplate, } from '../utils/credentialTemplate.js';
13
15
  import os from 'os';
14
16
  import path from 'path';
15
17
  import fs from 'fs';
@@ -171,13 +173,13 @@ const sanitizeArgs = (args) => {
171
173
  return args.map((arg) => {
172
174
  // Remove shell metacharacters that could be used for injection
173
175
  // Allow common flags (-f, --flag, -vvv) and paths
174
- if (/^[a-zA-Z0-9._/\\:@=+,-]+$/.test(arg)) {
176
+ if (/^[a-zA-Z0-9._/\\:@=+,-]+$/.test(arg.replace(/\{\{args\.[A-Za-z_][A-Za-z0-9_]*\}\}/g, 'slot'))) {
175
177
  return arg;
176
178
  }
177
179
  // For arguments with special characters, log a warning
178
180
  // Also block redirection operators (>, <, >>) and newlines
179
181
  logger.warn(`[proxychains] Potentially unsafe argument blocked: ${arg}`);
180
- return arg.replace(/[;&|`$(){}[\]!><\n\r]/g, '');
182
+ return arg.replace(/\{\{args\.[A-Za-z_][A-Za-z0-9_]*\}\}|[;&|`$(){}[\]!><\n\r]/g, (match) => match.startsWith('{{args.') ? match : '');
181
183
  });
182
184
  };
183
185
  /**
@@ -721,16 +723,28 @@ const removeClientState = (id) => {
721
723
  for (const client of state.cookieClients)
722
724
  client.clearSessionCookies(id);
723
725
  };
724
- const acquireClientState = (serverInfo) => {
726
+ const acquireClientState = (serverInfo, openAiSession) => {
725
727
  const contextService = RequestContextService.getInstance();
726
728
  const scope = contextService.getRequestContext()?.clientStateScope;
727
- const handle = contextService.getHeader('x-mcphub-state-id');
728
- if (!scope ||
729
- typeof handle !== 'string' ||
730
- !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(handle)) {
731
- throw new Error('Stateful tools on stateless MCP require authenticated bearer access and X-MCPHub-State-Id (UUID v4)');
729
+ const explicitHandle = contextService.getHeader('x-mcphub-state-id');
730
+ if (!scope) {
731
+ throw new Error('Stateful tools on stateless MCP require authenticated bearer access and an application state handle');
732
+ }
733
+ let handle;
734
+ if (explicitHandle !== undefined) {
735
+ if (typeof explicitHandle !== 'string' ||
736
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(explicitHandle)) {
737
+ throw new Error('X-MCPHub-State-Id must be a UUID v4');
738
+ }
739
+ handle = explicitHandle.toLowerCase();
740
+ }
741
+ else if (typeof openAiSession === 'string' && openAiSession.trim()) {
742
+ handle = `openai:${createHash('sha256').update(openAiSession).digest('hex')}`;
743
+ }
744
+ else {
745
+ throw new Error('Stateful tools on stateless MCP require X-MCPHub-State-Id (UUID v4) or _meta["openai/session"]');
732
746
  }
733
- const id = `client-state:${scope}:${handle.toLowerCase()}`;
747
+ const id = `client-state:${scope}:${handle}`;
734
748
  let state = clientStates.get(id);
735
749
  if (state && state.active === 0 && Date.now() - state.lastUsed >= CLIENT_STATE_IDLE_MS) {
736
750
  removeClientState(id);
@@ -895,7 +909,7 @@ export const summarizeServerConnections = (infos) => {
895
909
  };
896
910
  export const getServerConnectionStats = () => {
897
911
  // Personal definitions have no shared connection whose readiness can be checked.
898
- return summarizeServerConnections(serverInfos.filter((info) => !hasCredentialTemplate(info.config)));
912
+ return summarizeServerConnections(serverInfos.filter((info) => !hasPersonalAuthentication(info.config)));
899
913
  };
900
914
  // Returns true if all enabled servers are connected
901
915
  export const connected = () => {
@@ -1194,7 +1208,7 @@ export const createTransportFromConfig = async (name, conf) => {
1194
1208
  if (conf.type === 'streamable-http') {
1195
1209
  const options = {};
1196
1210
  let headers = conf.headers
1197
- ? hasCredentialTemplate(conf)
1211
+ ? hasPersonalAuthentication(conf)
1198
1212
  ? conf.headers
1199
1213
  : replaceEnvVars(conf.headers, env)
1200
1214
  : {};
@@ -1220,14 +1234,15 @@ export const createTransportFromConfig = async (name, conf) => {
1220
1234
  headers,
1221
1235
  };
1222
1236
  }
1223
- options.fetch = requestAwareFetch;
1237
+ options.fetch =
1238
+ personalOAuthContexts.get(conf)?.wrapFetch(requestAwareFetch) || requestAwareFetch;
1224
1239
  transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
1225
1240
  }
1226
1241
  else if (conf.url) {
1227
1242
  // SSE transport
1228
1243
  const options = {};
1229
1244
  let headers = conf.headers
1230
- ? hasCredentialTemplate(conf)
1245
+ ? hasPersonalAuthentication(conf)
1231
1246
  ? conf.headers
1232
1247
  : replaceEnvVars(conf.headers, env)
1233
1248
  : {};
@@ -1246,7 +1261,7 @@ export const createTransportFromConfig = async (name, conf) => {
1246
1261
  if (Object.keys(headers).length > 0) {
1247
1262
  options.eventSourceInit = {
1248
1263
  headers,
1249
- fetch: requestAwareFetch,
1264
+ fetch: personalOAuthContexts.get(conf)?.wrapFetch(requestAwareFetch) || requestAwareFetch,
1250
1265
  };
1251
1266
  options.requestInit = {
1252
1267
  headers,
@@ -1254,10 +1269,11 @@ export const createTransportFromConfig = async (name, conf) => {
1254
1269
  }
1255
1270
  else {
1256
1271
  options.eventSourceInit = {
1257
- fetch: requestAwareFetch,
1272
+ fetch: personalOAuthContexts.get(conf)?.wrapFetch(requestAwareFetch) || requestAwareFetch,
1258
1273
  };
1259
1274
  }
1260
- options.fetch = requestAwareFetch;
1275
+ options.fetch =
1276
+ personalOAuthContexts.get(conf)?.wrapFetch(requestAwareFetch) || requestAwareFetch;
1261
1277
  transport = new SSEClientTransport(new URL(conf.url), options);
1262
1278
  }
1263
1279
  else if (conf.command) {
@@ -1292,12 +1308,13 @@ export const createTransportFromConfig = async (name, conf) => {
1292
1308
  transport = new StdioClientTransport({
1293
1309
  cwd: process.cwd(),
1294
1310
  command: finalCommand,
1295
- args: finalArgs,
1311
+ // Substitute after expansion and proxy argument cleaning so secrets stay literal.
1312
+ args: resolveCredentialArguments(conf, finalArgs),
1296
1313
  env: env,
1297
1314
  stderr: 'pipe',
1298
1315
  maxBufferSize: validateMaxBufferSize(conf.options?.maxBufferSize),
1299
1316
  });
1300
- if (transport.stderr && hasCredentialTemplate(conf)) {
1317
+ if (transport.stderr && hasPersonalAuthentication(conf)) {
1301
1318
  transport.stderr.on('data', () => undefined); // Personal upstream diagnostics can contain raw credentials.
1302
1319
  }
1303
1320
  else if (transport.stderr) {
@@ -1324,7 +1341,7 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1324
1341
  if (!client) {
1325
1342
  throw new Error(`Client not found for server: ${serverInfo.name}`);
1326
1343
  }
1327
- if (hasCredentialTemplate(serverInfo.config)) {
1344
+ if (hasPersonalAuthentication(serverInfo.config)) {
1328
1345
  return client.callTool(toolParams, options || {});
1329
1346
  }
1330
1347
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -1501,7 +1518,7 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1501
1518
  });
1502
1519
  continue;
1503
1520
  }
1504
- if (hasCredentialTemplate(expandedConf)) {
1521
+ if (hasPersonalAuthentication(expandedConf)) {
1505
1522
  validateCredentialTemplate(expandedConf);
1506
1523
  nextServerInfos.push({
1507
1524
  name,
@@ -2010,15 +2027,26 @@ export const getServersInfo = async (page, limit, user) => {
2010
2027
  const principal = (user ||
2011
2028
  UserContextService.getInstance().getCurrentUser());
2012
2029
  const presentedInfos = await Promise.all(filterServerInfos.map(async (info) => {
2013
- if (!hasCredentialTemplate(info.config) ||
2030
+ if (!hasPersonalAuthentication(info.config) ||
2014
2031
  info.enabled === false ||
2015
2032
  !principal?.username ||
2016
2033
  principal.credentialEligible === false)
2017
2034
  return info;
2035
+ let ownOAuth;
2018
2036
  try {
2037
+ const personal = hasPersonalOAuth(info.config)
2038
+ ? await PersonalOAuthContext.load(info.name, principal.username, (await getServerDao().findById(info.name)))
2039
+ : undefined;
2040
+ ownOAuth = personal
2041
+ ? {
2042
+ identityMode: 'personal',
2043
+ connected: Boolean(personal.config.oauth.accessToken),
2044
+ authorizationUrl: personal.authorizationUrl,
2045
+ }
2046
+ : undefined;
2019
2047
  const lease = await principalRuntimes.acquire(info.name, principal);
2020
2048
  try {
2021
- return { ...lease.info, config: info.config };
2049
+ return { ...lease.info, config: info.config, ...(ownOAuth ? { oauth: ownOAuth } : {}) };
2022
2050
  }
2023
2051
  finally {
2024
2052
  lease.release();
@@ -2027,6 +2055,7 @@ export const getServersInfo = async (page, limit, user) => {
2027
2055
  catch (error) {
2028
2056
  return {
2029
2057
  ...info,
2058
+ ...(ownOAuth ? { status: 'oauth_required', oauth: ownOAuth } : {}),
2030
2059
  error: error instanceof CredentialBindingError || error instanceof UpstreamRequestError
2031
2060
  ? error.message
2032
2061
  : 'Unable to prepare upstream server',
@@ -2089,6 +2118,8 @@ export const getServersInfo = async (page, limit, user) => {
2089
2118
  ? {
2090
2119
  ...(oauth
2091
2120
  ? {
2121
+ ...(oauth.identityMode ? { identityMode: oauth.identityMode } : {}),
2122
+ ...(oauth.connected ? { connected: true } : {}),
2092
2123
  authorizationUrl: oauth.authorizationUrl,
2093
2124
  state: oauth.state,
2094
2125
  ...(oauth.clientIdConfigured ? { clientIdConfigured: true } : {}),
@@ -2101,9 +2132,12 @@ export const getServersInfo = async (page, limit, user) => {
2101
2132
  config: resolvedType || serverConfig?.description || serverConfig?.command
2102
2133
  ? {
2103
2134
  ...(resolvedType ? { type: resolvedType } : {}),
2104
- ...(hasCredentialTemplate(serverConfig)
2135
+ ...(hasPersonalAuthentication(serverConfig)
2105
2136
  ? { credentialTemplate: validateCredentialTemplate(serverConfig) }
2106
2137
  : {}),
2138
+ ...(hasPersonalOAuth(serverConfig)
2139
+ ? { oauth: { identityMode: 'personal' } }
2140
+ : {}),
2107
2141
  ...(serverConfig?.description ? { description: serverConfig.description } : {}),
2108
2142
  // Expose command so the frontend can determine if reinstall is
2109
2143
  // supported (npx/uvx only). This is not a secret — it's the
@@ -2493,7 +2527,7 @@ const scheduleIdleShutdown = (serverInfo) => {
2493
2527
  clearIdleShutdown(serverInfo);
2494
2528
  if (!serverInfo.config?.startOnDemand ||
2495
2529
  !isStdioServer(serverInfo.config) ||
2496
- hasCredentialTemplate(serverInfo.config) ||
2530
+ hasPersonalAuthentication(serverInfo.config) ||
2497
2531
  serverInfo.status !== 'connected' ||
2498
2532
  !serverInfo.client ||
2499
2533
  serverInfo.activeToolCalls) {
@@ -2509,7 +2543,7 @@ const scheduleIdleShutdown = (serverInfo) => {
2509
2543
  const beginOnDemandToolCall = (serverInfo) => {
2510
2544
  if (!serverInfo.config?.startOnDemand ||
2511
2545
  !isStdioServer(serverInfo.config) ||
2512
- hasCredentialTemplate(serverInfo.config)) {
2546
+ hasPersonalAuthentication(serverInfo.config)) {
2513
2547
  return undefined;
2514
2548
  }
2515
2549
  clearIdleShutdown(serverInfo);
@@ -2657,7 +2691,7 @@ const isStdioServer = (conf) => !!conf && (conf.type === 'stdio' || (!conf.type
2657
2691
  */
2658
2692
  const primeOnDemandServers = (gate) => {
2659
2693
  const targets = serverInfos.filter((si) => si.config?.startOnDemand === true &&
2660
- !hasCredentialTemplate(si.config) &&
2694
+ !hasPersonalAuthentication(si.config) &&
2661
2695
  isStdioServer(si.config) &&
2662
2696
  si.tools.length === 0 &&
2663
2697
  si.enabled !== false);
@@ -2865,12 +2899,17 @@ const getMcpAppsRouteContext = async (sessionId, group) => {
2865
2899
  const eligible = (pinned?.pinnedServerInfos ?? []).filter(canServeToolRequests);
2866
2900
  return eligible.length > 0 ? { enabled: true, serverInfos: eligible } : { enabled: false };
2867
2901
  }
2868
- const { filteredServerInfos } = await getFilteredServerInfosForGroup(group);
2902
+ const { filteredServerInfos, serverConfigsByName } = await getFilteredServerInfosForGroup(group);
2869
2903
  const connectedServerInfos = filteredServerInfos.filter((serverInfo) => serverInfo.status === 'connected' && !!serverInfo.client);
2870
2904
  if (connectedServerInfos.length === 0) {
2871
2905
  return { enabled: false };
2872
2906
  }
2873
- if (connectedServerInfos.length === 1) {
2907
+ // Connection availability must not turn an aggregate route into a raw-name
2908
+ // route: OpenAPI uses openApiClient, and tool calls acquire only the target
2909
+ // personal runtime. Match resolveToolInGroup's configured-route ambiguity check.
2910
+ if (connectedServerInfos.length === 1 &&
2911
+ filteredServerInfos.length === 1 &&
2912
+ serverConfigsByName.size <= 1) {
2874
2913
  return {
2875
2914
  enabled: true,
2876
2915
  serverInfo: connectedServerInfos[0],
@@ -3010,7 +3049,7 @@ const projectToolForDownstream = (serverName, tool, appsRouteContext, serverConf
3010
3049
  return undefined;
3011
3050
  }
3012
3051
  const projectedTool = appsRouteContext.enabled ? tool : stripMcpAppsMetadata(tool);
3013
- // Single connected server: keep the existing raw (unqualified) name so
3052
+ // Single configured server: keep the existing raw (unqualified) name so
3014
3053
  // widgets that call back with the bare tool name continue to work.
3015
3054
  if (appsRouteContext.serverInfo) {
3016
3055
  return {
@@ -3018,7 +3057,7 @@ const projectToolForDownstream = (serverName, tool, appsRouteContext, serverConf
3018
3057
  name: normalizeToolNameForServer(serverName, projectedTool.name),
3019
3058
  };
3020
3059
  }
3021
- // Multiple connected servers (Apps-enabled) or Apps disabled: qualify the
3060
+ // Aggregate routes (Apps-enabled) or Apps disabled: qualify the
3022
3061
  // name so it can still be routed to the right server unambiguously.
3023
3062
  return {
3024
3063
  ...projectedTool,
@@ -3162,7 +3201,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3162
3201
  const getToolStateId = (info, legacyId) => {
3163
3202
  if (!requestContextService.getRequestContext()?.stateless)
3164
3203
  return legacyId;
3165
- clientStateLease ?? (clientStateLease = acquireClientState(info));
3204
+ clientStateLease ?? (clientStateLease = acquireClientState(info, request.params?._meta?.['openai/session']));
3166
3205
  return clientStateLease.id;
3167
3206
  };
3168
3207
  const reserveHostedIfNeeded = async (serverName, toolName) => {
@@ -3320,7 +3359,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3320
3359
  keyId,
3321
3360
  keyName,
3322
3361
  sourceIp,
3323
- hasCredentialTemplate: hasCredentialTemplate(targetServerInfo.config),
3362
+ hasCredentialTemplate: hasPersonalAuthentication(targetServerInfo.config),
3324
3363
  });
3325
3364
  return await maybeCompressToolResult({
3326
3365
  content: [
@@ -3339,7 +3378,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3339
3378
  // For servers with perSessionClient: true, use a per-session dedicated client
3340
3379
  let isolatedCtx;
3341
3380
  if (targetServerInfo.config?.perSessionClient &&
3342
- !hasCredentialTemplate(targetServerInfo.config)) {
3381
+ !hasPersonalAuthentication(targetServerInfo.config)) {
3343
3382
  const stateId = getToolStateId(targetServerInfo, sessionId);
3344
3383
  if (!stateId) {
3345
3384
  throw new Error(`Server '${targetServerInfo.name}' requires per-session client isolation and is not yet available on stateless MCP requests`);
@@ -3392,7 +3431,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3392
3431
  keyId,
3393
3432
  keyName,
3394
3433
  sourceIp,
3395
- hasCredentialTemplate: hasCredentialTemplate(targetServerInfo.config),
3434
+ hasCredentialTemplate: hasPersonalAuthentication(targetServerInfo.config),
3396
3435
  errorMessage: result.isError ? 'Tool returned error response' : undefined,
3397
3436
  });
3398
3437
  return await maybeCompressToolResult(result, {
@@ -3499,7 +3538,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3499
3538
  keyId,
3500
3539
  keyName,
3501
3540
  sourceIp,
3502
- hasCredentialTemplate: hasCredentialTemplate(serverInfo.config),
3541
+ hasCredentialTemplate: hasPersonalAuthentication(serverInfo.config),
3503
3542
  });
3504
3543
  return await maybeCompressToolResult({
3505
3544
  content: [
@@ -3517,7 +3556,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3517
3556
  // Handle MCP servers
3518
3557
  // For servers with perSessionClient: true, use a per-session dedicated client
3519
3558
  let isolatedCtx;
3520
- if (serverInfo.config?.perSessionClient && !hasCredentialTemplate(serverInfo.config)) {
3559
+ if (serverInfo.config?.perSessionClient && !hasPersonalAuthentication(serverInfo.config)) {
3521
3560
  const stateId = getToolStateId(serverInfo, sessionId);
3522
3561
  if (!stateId) {
3523
3562
  throw new Error(`Server '${serverInfo.name}' requires per-session client isolation and is not yet available on stateless MCP requests`);
@@ -3555,7 +3594,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3555
3594
  keyId,
3556
3595
  keyName,
3557
3596
  sourceIp,
3558
- hasCredentialTemplate: hasCredentialTemplate(serverInfo.config),
3597
+ hasCredentialTemplate: hasPersonalAuthentication(serverInfo.config),
3559
3598
  errorMessage: result.isError ? 'Tool returned error response' : undefined,
3560
3599
  });
3561
3600
  return await maybeCompressToolResult(result, {
@@ -3594,7 +3633,7 @@ const handleCallToolRequestImpl = async (request, extra) => {
3594
3633
  keyId,
3595
3634
  keyName,
3596
3635
  sourceIp,
3597
- hasCredentialTemplate: hasCredentialTemplate(serverInfo?.config),
3636
+ hasCredentialTemplate: hasPersonalAuthentication(serverInfo?.config),
3598
3637
  // The reason is an internal diagnostic; the caller-facing message stays
3599
3638
  // identical for hidden and nonexistent targets.
3600
3639
  errorMessage: unavailable
@@ -3867,9 +3906,19 @@ const handleReadResourceRequestImpl = async (request, extra) => {
3867
3906
  ? { cacheMode: 'bypass' }
3868
3907
  : undefined;
3869
3908
  if (server?.client) {
3870
- result = readOptions
3871
- ? await server.client.readResource({ uri }, readOptions)
3872
- : await server.client.readResource({ uri });
3909
+ // A widget URI the upstream also lists in resources/list is read here, not in
3910
+ // the fallback loop below, so it needs the same idle guard: otherwise an
3911
+ // idle timer armed by an earlier read can close an on-demand upstream while
3912
+ // this read is still in flight.
3913
+ const releaseOnDemandRead = beginOnDemandToolCall(server);
3914
+ try {
3915
+ result = readOptions
3916
+ ? await server.client.readResource({ uri }, readOptions)
3917
+ : await server.client.readResource({ uri });
3918
+ }
3919
+ finally {
3920
+ releaseOnDemandRead?.();
3921
+ }
3873
3922
  if (!result || !Array.isArray(result.contents)) {
3874
3923
  throw new Error(`Failed to read resource: ${uri}`);
3875
3924
  }
@@ -3885,10 +3934,23 @@ const handleReadResourceRequestImpl = async (request, extra) => {
3885
3934
  ? [appsRouteContext.serverInfo]
3886
3935
  : (appsRouteContext.serverInfos ?? []);
3887
3936
  for (const candidate of candidates) {
3888
- if (!candidate.client) {
3889
- continue;
3890
- }
3937
+ // An on-demand candidate may be idle yet still advertise its cached widget
3938
+ // link, so a host can read the ui:// resource before any tool call has
3939
+ // woken it. Hold off its idle shutdown for every read, awake or not, so an
3940
+ // armed idle timer or an overlapping read cannot close the server mid-read,
3941
+ // then wake it the same way the call path does if it is not running.
3942
+ let releaseOnDemandRead;
3891
3943
  try {
3944
+ if (candidate.config?.startOnDemand && candidate.enabled !== false) {
3945
+ releaseOnDemandRead = beginOnDemandToolCall(candidate);
3946
+ if (!candidate.client) {
3947
+ await ensureServerReady(candidate);
3948
+ }
3949
+ candidate.lastUsedAt = Date.now();
3950
+ }
3951
+ if (!candidate.client) {
3952
+ continue;
3953
+ }
3892
3954
  const candidateResult = readOptions
3893
3955
  ? await candidate.client.readResource({ uri }, readOptions)
3894
3956
  : await candidate.client.readResource({ uri });
@@ -3898,7 +3960,10 @@ const handleReadResourceRequestImpl = async (request, extra) => {
3898
3960
  }
3899
3961
  }
3900
3962
  catch {
3901
- // This candidate doesn't own the resource; try the next one.
3963
+ // This candidate doesn't own the resource (or failed to wake); try the next one.
3964
+ }
3965
+ finally {
3966
+ releaseOnDemandRead?.();
3902
3967
  }
3903
3968
  }
3904
3969
  if (!result) {
@@ -4029,6 +4094,9 @@ const createPrincipalRuntime = async (name, resolvedConfig) => {
4029
4094
  }
4030
4095
  catch (error) {
4031
4096
  closeServerRuntime(info);
4097
+ if (hasPersonalOAuth(resolvedConfig) &&
4098
+ personalOAuthContexts.get(resolvedConfig)?.config.oauth.pendingAuthorization)
4099
+ throw personalAuthorizationRequired(name);
4032
4100
  const failure = createUpstreamRequestError(name, 'connect', error, resolvedConfig);
4033
4101
  logger.error(failure.message);
4034
4102
  throw failure;
@@ -4058,7 +4126,7 @@ export const getServerToolsForPrincipal = async (serverName, principal) => {
4058
4126
  }
4059
4127
  };
4060
4128
  const withPrincipalServers = (handler, operation) => async (request, extra) => {
4061
- if (!serverInfos.some((info) => hasCredentialTemplate(info.config)))
4129
+ if (!serverInfos.some((info) => hasPersonalAuthentication(info.config)))
4062
4130
  return handler(request, extra);
4063
4131
  // Built-in content does not require an upstream credential.
4064
4132
  if (operation === 'prompt') {
@@ -4095,7 +4163,7 @@ const withPrincipalServers = (handler, operation) => async (request, extra) => {
4095
4163
  }
4096
4164
  // A call to a known target must not spawn or keep unrelated personal children alive.
4097
4165
  const selected = target && (operation === 'tool' || operation === 'prompt') ? [target] : candidates;
4098
- const templated = selected.filter((info) => hasCredentialTemplate(info.config));
4166
+ const templated = selected.filter((info) => hasPersonalAuthentication(info.config));
4099
4167
  if (!templated.length)
4100
4168
  return handler(request, extra);
4101
4169
  const user = UserContextService.getInstance().getCurrentUser();
@@ -4108,7 +4176,9 @@ const withPrincipalServers = (handler, operation) => async (request, extra) => {
4108
4176
  if (!user?.username ||
4109
4177
  user.credentialEligible === false ||
4110
4178
  RequestContextService.getInstance().getKeyKindContext() === 'system')
4111
- throw missingCredentialError(info.name);
4179
+ throw hasPersonalOAuth(info.config)
4180
+ ? personalAuthorizationRequired(info.name)
4181
+ : missingCredentialError(info.name);
4112
4182
  const lease = await principalRuntimes.acquire(info.name, user);
4113
4183
  releases.push(lease.release);
4114
4184
  scoped.set(info.name, lease.info);
@@ -4137,7 +4207,7 @@ const withPrincipalServers = (handler, operation) => async (request, extra) => {
4137
4207
  let resolvedRequest = request;
4138
4208
  if (operation === 'tool' &&
4139
4209
  extra?.server &&
4140
- hasCredentialTemplate(scoped.get(extra.server)?.config)) {
4210
+ hasPersonalAuthentication(scoped.get(extra.server)?.config)) {
4141
4211
  const prefix = `${extra.server}${getNameSeparator()}`;
4142
4212
  if (typeof requestedName === 'string' && !requestedName.startsWith(prefix)) {
4143
4213
  resolvedRequest =