@samanhappy/mcphub 1.0.26 → 1.0.28

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 (68) hide show
  1. package/dist/controllers/oauthCallbackController.js +2 -3
  2. package/dist/controllers/oauthCallbackController.js.map +1 -1
  3. package/dist/controllers/serverController.js +13 -1
  4. package/dist/controllers/serverController.js.map +1 -1
  5. package/dist/services/mcpOAuthProvider.js +27 -1
  6. package/dist/services/mcpOAuthProvider.js.map +1 -1
  7. package/dist/services/mcpService.js +413 -48
  8. package/dist/services/mcpService.js.map +1 -1
  9. package/dist/utils/jsonSchemaValidator.js +26 -0
  10. package/dist/utils/jsonSchemaValidator.js.map +1 -0
  11. package/dist/utils/serialization.js +7 -1
  12. package/dist/utils/serialization.js.map +1 -1
  13. package/dist/utils/serverConfigPersistence.js +7 -0
  14. package/dist/utils/serverConfigPersistence.js.map +1 -1
  15. package/dist/utils/stdioDiagnostics.js +51 -0
  16. package/dist/utils/stdioDiagnostics.js.map +1 -0
  17. package/frontend/dist/assets/{ActivityPage-CobinJLb.js → ActivityPage-D0SFqsRe.js} +2 -2
  18. package/frontend/dist/assets/{ActivityPage-CobinJLb.js.map → ActivityPage-D0SFqsRe.js.map} +1 -1
  19. package/frontend/dist/assets/{Dashboard-eGc4eQi3.js → Dashboard-DzWkQnht.js} +2 -2
  20. package/frontend/dist/assets/{Dashboard-eGc4eQi3.js.map → Dashboard-DzWkQnht.js.map} +1 -1
  21. package/frontend/dist/assets/{EndpointCopy-B3Ki0wIh.js → EndpointCopy-Bqjt3lGe.js} +2 -2
  22. package/frontend/dist/assets/{EndpointCopy-B3Ki0wIh.js.map → EndpointCopy-Bqjt3lGe.js.map} +1 -1
  23. package/frontend/dist/assets/{GroupsPage-DwjX8q3N.js → GroupsPage-DsmZMSK0.js} +2 -2
  24. package/frontend/dist/assets/{GroupsPage-DwjX8q3N.js.map → GroupsPage-DsmZMSK0.js.map} +1 -1
  25. package/frontend/dist/assets/{LoginPage-BnBoYrJw.js → LoginPage-_Gr1ere-.js} +2 -2
  26. package/frontend/dist/assets/{LoginPage-BnBoYrJw.js.map → LoginPage-_Gr1ere-.js.map} +1 -1
  27. package/frontend/dist/assets/{LogsPage-DWCpx2XK.js → LogsPage-D2vByutC.js} +2 -2
  28. package/frontend/dist/assets/{LogsPage-DWCpx2XK.js.map → LogsPage-D2vByutC.js.map} +1 -1
  29. package/frontend/dist/assets/{MarketPage-DyCByUSu.js → MarketPage-BhyrxwBv.js} +2 -2
  30. package/frontend/dist/assets/{MarketPage-DyCByUSu.js.map → MarketPage-BhyrxwBv.js.map} +1 -1
  31. package/frontend/dist/assets/{PromptsPage-h-stDyZt.js → PromptsPage-BLn95Icp.js} +2 -2
  32. package/frontend/dist/assets/{PromptsPage-h-stDyZt.js.map → PromptsPage-BLn95Icp.js.map} +1 -1
  33. package/frontend/dist/assets/{ResourcesPage-DDqx0Mwe.js → ResourcesPage-B27GVOO6.js} +2 -2
  34. package/frontend/dist/assets/{ResourcesPage-DDqx0Mwe.js.map → ResourcesPage-B27GVOO6.js.map} +1 -1
  35. package/frontend/dist/assets/ServersPage-BuoWpLAq.js +39 -0
  36. package/frontend/dist/assets/ServersPage-BuoWpLAq.js.map +1 -0
  37. package/frontend/dist/assets/{SettingsPage-BOFDPIg2.js → SettingsPage-BzCwUvx7.js} +2 -2
  38. package/frontend/dist/assets/{SettingsPage-BOFDPIg2.js.map → SettingsPage-BzCwUvx7.js.map} +1 -1
  39. package/frontend/dist/assets/StatusDot-BUWJw3dA.js +2 -0
  40. package/frontend/dist/assets/StatusDot-BUWJw3dA.js.map +1 -0
  41. package/frontend/dist/assets/{ToggleGroup-BsTXYycl.js → ToggleGroup-BPAIOm5B.js} +2 -2
  42. package/frontend/dist/assets/{ToggleGroup-BsTXYycl.js.map → ToggleGroup-BPAIOm5B.js.map} +1 -1
  43. package/frontend/dist/assets/{UsersPage-DiGhqYGi.js → UsersPage-SZrL0bKd.js} +2 -2
  44. package/frontend/dist/assets/{UsersPage-DiGhqYGi.js.map → UsersPage-SZrL0bKd.js.map} +1 -1
  45. package/frontend/dist/assets/{contextCost-DbYdVAeZ.js → contextCost-BxT09q-Y.js} +2 -2
  46. package/frontend/dist/assets/{contextCost-DbYdVAeZ.js.map → contextCost-BxT09q-Y.js.map} +1 -1
  47. package/frontend/dist/assets/framework-vendor-I9Y6AgD5.js.map +1 -1
  48. package/frontend/dist/assets/index-Bz_euXvO.js +3 -0
  49. package/frontend/dist/assets/index-Bz_euXvO.js.map +1 -0
  50. package/frontend/dist/assets/index-DEAy4k6z.css +1 -0
  51. package/frontend/dist/assets/{resourceService-DmZBoHuX.js → resourceService-CWKSpa0p.js} +2 -2
  52. package/frontend/dist/assets/{resourceService-DmZBoHuX.js.map → resourceService-CWKSpa0p.js.map} +1 -1
  53. package/frontend/dist/assets/useSettingsData-0sbahDtL.js +2 -0
  54. package/frontend/dist/assets/{useSettingsData-pjp30ZrF.js.map → useSettingsData-0sbahDtL.js.map} +1 -1
  55. package/frontend/dist/assets/variableDetection-DfkBZ0gv.js +16 -0
  56. package/frontend/dist/assets/variableDetection-DfkBZ0gv.js.map +1 -0
  57. package/frontend/dist/index.html +2 -2
  58. package/package.json +3 -3
  59. package/frontend/dist/assets/ServersPage-CAcLHWYC.js +0 -37
  60. package/frontend/dist/assets/ServersPage-CAcLHWYC.js.map +0 -1
  61. package/frontend/dist/assets/StatusDot-CMaJRs2P.js +0 -2
  62. package/frontend/dist/assets/StatusDot-CMaJRs2P.js.map +0 -1
  63. package/frontend/dist/assets/index-B0TJChIq.js +0 -3
  64. package/frontend/dist/assets/index-B0TJChIq.js.map +0 -1
  65. package/frontend/dist/assets/index-BfEBXfi0.css +0 -1
  66. package/frontend/dist/assets/useSettingsData-pjp30ZrF.js +0 -2
  67. package/frontend/dist/assets/variableDetection-CbAQkv1j.js +0 -16
  68. package/frontend/dist/assets/variableDetection-CbAQkv1j.js.map +0 -1
@@ -11,6 +11,7 @@ import { StreamableHTTPClientTransport, } from '@modelcontextprotocol/sdk/client
11
11
  import { normalizeHeaders } from '@modelcontextprotocol/sdk/shared/transport.js';
12
12
  import { createFetchWithProxy, getProxyConfigFromEnv } from './proxy.js';
13
13
  import { assertSafeUrl, createRedirectValidatingFetch } from '../utils/ssrf.js';
14
+ import { ResilientJsonSchemaValidator } from '../utils/jsonSchemaValidator.js';
14
15
  import { getUserDao } from '../dao/index.js';
15
16
  import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
16
17
  import config from '../config/index.js';
@@ -28,6 +29,7 @@ import { getActivityLoggingService } from './activityLoggingService.js';
28
29
  import { maybeCompressToolResult } from './toolResultCompressionService.js';
29
30
  import { assertHostedToolAllowed, filterHostedTools, reserveHostedToolCall, settleHostedToolCall, } from './hostedAuthService.js';
30
31
  import { formatErrorForLogging, sanitizeStringForLogging, summarizeErrorForLogging, } from '../utils/serialization.js';
32
+ import { addStdioErrorContext, observeStdioStderr } from '../utils/stdioDiagnostics.js';
31
33
  import { MCP_APPS_CAPABILITIES, filterModelVisibleTools, hasMcpAppsCapability, isAppOnlyTool, stripMcpAppsMetadata, } from '../utils/mcpApps.js';
32
34
  import { supportsCacheRefresh, injectRefreshFlag, clearRunnerCache } from '../utils/cacheUtils.js';
33
35
  const servers = {};
@@ -336,7 +338,7 @@ const getOrCreateIsolatedClient = async (sessionId, serverInfo) => {
336
338
  const transport = await createTransportFromConfig(serverInfo.name, serverConfig);
337
339
  const client = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
338
340
  try {
339
- await client.connect(transport, serverInfo.options || {});
341
+ await connectClientWithDiagnostics(client, transport, serverInfo.options || {});
340
342
  }
341
343
  catch (connectError) {
342
344
  // Connect failed — close the client/transport and kill any spawned
@@ -500,6 +502,14 @@ let serverInfos = [];
500
502
  const sessionIsolatedClients = new Map();
501
503
  // Locks to prevent concurrent creation of the same isolated client
502
504
  const isolatedClientCreationLocks = new Map();
505
+ export const connectClientWithDiagnostics = async (client, transport, options) => {
506
+ try {
507
+ await client.connect(transport, options);
508
+ }
509
+ catch (error) {
510
+ throw addStdioErrorContext(error, transport);
511
+ }
512
+ };
503
513
  // Track servers pending a cache-refresh reinstall.
504
514
  // Consumed once by createTransportFromConfig on the next reconnect.
505
515
  const pendingReinstalls = new Set();
@@ -534,6 +544,7 @@ const createUpstreamMcpClient = (name, getServerInfo) => {
534
544
  version: '1.0.0',
535
545
  }, {
536
546
  capabilities: MCP_APPS_CAPABILITIES,
547
+ jsonSchemaValidator: new ResilientJsonSchemaValidator(),
537
548
  listChanged: {
538
549
  tools: {
539
550
  onChanged: (error, tools) => {
@@ -873,6 +884,17 @@ export const createRequestContextAwareFetch = (baseFetch, passthroughHeaderNames
873
884
  };
874
885
  };
875
886
  // Helper function to create transport based on server configuration
887
+ /**
888
+ * Remove any Authorization header (case-insensitive) from a headers map.
889
+ *
890
+ * Used when an OAuth authProvider manages authentication for a transport. The
891
+ * MCP SDK injects the OAuth Bearer token first and then spreads the configured
892
+ * requestInit headers on top, so a leftover static Authorization value would
893
+ * override the valid OAuth access token and produce 401 authentication loops.
894
+ */
895
+ const stripAuthorizationHeader = (headers) => {
896
+ return Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'authorization'));
897
+ };
876
898
  export const createTransportFromConfig = async (name, conf) => {
877
899
  let transport;
878
900
  const env = {
@@ -893,29 +915,44 @@ export const createTransportFromConfig = async (name, conf) => {
893
915
  }
894
916
  if (conf.type === 'streamable-http') {
895
917
  const options = {};
896
- const headers = conf.headers ? replaceEnvVars(conf.headers, env) : {};
918
+ let headers = conf.headers ? replaceEnvVars(conf.headers, env) : {};
897
919
  const baseFetch = createFetchWithProxy(getProxyConfigFromEnv(env));
898
920
  const requestAwareFetch = createRedirectValidatingFetch(createRequestContextAwareFetch(baseFetch, conf.passthroughHeaders), allowInternal);
899
- if (Object.keys(headers).length > 0) {
900
- options.requestInit = {
901
- headers,
902
- };
903
- }
904
921
  // Create OAuth provider if configured - SDK will handle authentication automatically
905
922
  const authProvider = await createOAuthProvider(name, conf);
906
923
  if (authProvider) {
907
924
  options.authProvider = authProvider;
925
+ // When the OAuth provider is active, strip any static Authorization
926
+ // header before passing the configured headers to the SDK. The SDK's
927
+ // transport builds common headers by adding the OAuth Bearer token first
928
+ // and then spreading requestInit headers on top, so a stale static
929
+ // Authorization value would override the valid access token and cause
930
+ // 401 re-authorization loops.
931
+ headers = stripAuthorizationHeader(headers);
908
932
  console.log(`OAuth provider configured for server: ${name}`);
909
933
  }
934
+ if (Object.keys(headers).length > 0) {
935
+ options.requestInit = {
936
+ headers,
937
+ };
938
+ }
910
939
  options.fetch = requestAwareFetch;
911
940
  transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
912
941
  }
913
942
  else if (conf.url) {
914
943
  // SSE transport
915
944
  const options = {};
916
- const headers = conf.headers ? replaceEnvVars(conf.headers, env) : {};
945
+ let headers = conf.headers ? replaceEnvVars(conf.headers, env) : {};
917
946
  const baseFetch = createFetchWithProxy(getProxyConfigFromEnv(env));
918
947
  const requestAwareFetch = createRedirectValidatingFetch(createRequestContextAwareFetch(baseFetch, conf.passthroughHeaders), allowInternal);
948
+ // Create OAuth provider if configured - SDK will handle authentication automatically
949
+ const authProvider = await createOAuthProvider(name, conf);
950
+ if (authProvider) {
951
+ options.authProvider = authProvider;
952
+ // Drop static Authorization header when OAuth manages auth (see above).
953
+ headers = stripAuthorizationHeader(headers);
954
+ console.log(`OAuth provider configured for server: ${name}`);
955
+ }
919
956
  if (Object.keys(headers).length > 0) {
920
957
  options.eventSourceInit = {
921
958
  headers,
@@ -930,12 +967,6 @@ export const createTransportFromConfig = async (name, conf) => {
930
967
  fetch: requestAwareFetch,
931
968
  };
932
969
  }
933
- // Create OAuth provider if configured - SDK will handle authentication automatically
934
- const authProvider = await createOAuthProvider(name, conf);
935
- if (authProvider) {
936
- options.authProvider = authProvider;
937
- console.log(`OAuth provider configured for server: ${name}`);
938
- }
939
970
  options.fetch = requestAwareFetch;
940
971
  transport = new SSEClientTransport(new URL(conf.url), options);
941
972
  }
@@ -975,9 +1006,14 @@ export const createTransportFromConfig = async (name, conf) => {
975
1006
  env: env,
976
1007
  stderr: 'pipe',
977
1008
  });
978
- transport.stderr?.on('data', (data) => {
979
- console.log(`[${name}] [child] ${data}`);
980
- });
1009
+ if (transport.stderr) {
1010
+ observeStdioStderr(transport, transport.stderr, (message) => {
1011
+ console.log('Upstream server stderr', {
1012
+ serverName: name,
1013
+ message,
1014
+ });
1015
+ });
1016
+ }
981
1017
  }
982
1018
  else {
983
1019
  throw new Error(`Unable to create transport for server: ${name}`);
@@ -1016,7 +1052,7 @@ const callToolWithReconnect = async (serverInfo, toolParams, options, maxRetries
1016
1052
  const newTransport = await createTransportFromConfig(serverInfo.name, server);
1017
1053
  const newClient = createUpstreamMcpClient(serverInfo.name, () => serverInfo);
1018
1054
  // Reconnect with new transport
1019
- await newClient.connect(newTransport, serverInfo.options || {});
1055
+ await connectClientWithDiagnostics(newClient, newTransport, serverInfo.options || {});
1020
1056
  if (isolated) {
1021
1057
  // Isolated path: close only this session's stale connection and
1022
1058
  // replace its entry in the per-session map. Never touch the shared
@@ -1135,6 +1171,32 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1135
1171
  });
1136
1172
  continue;
1137
1173
  }
1174
+ // On-demand stdio servers: skip startup connect; spawn lazily on first
1175
+ // tool call. startOnDemand is a stdio-only optimisation - HTTP/SSE and
1176
+ // OpenAPI servers have no deferred child process, so they connect normally.
1177
+ // Preserve existing runtime state (tools/prompts populated from a previous
1178
+ // connection) so the tool list stays visible to agents even while sleeping.
1179
+ if (expandedConf.startOnDemand === true && isStdioServer(expandedConf)) {
1180
+ const existingOnDemand = existingServerInfos.find((s) => s.name === name);
1181
+ nextServerInfos.push({
1182
+ // Carry over cached tools/prompts/resources if the server was previously connected
1183
+ ...(existingOnDemand ?? {
1184
+ name,
1185
+ status: 'disconnected',
1186
+ error: null,
1187
+ tools: [],
1188
+ prompts: [],
1189
+ resources: [],
1190
+ createTime: Date.now(),
1191
+ }),
1192
+ owner: expandedConf.owner,
1193
+ visibility: expandedConf.visibility,
1194
+ enabled: true,
1195
+ config: expandedConf,
1196
+ });
1197
+ console.log(`Skipping startup connect for on-demand server: ${name}`);
1198
+ continue;
1199
+ }
1138
1200
  // Reuse this server's existing runtime state instead of reconnecting when:
1139
1201
  // - a targeted reload/reconnect (serverName) was requested for a
1140
1202
  // *different* server — preserve its current state regardless of
@@ -1302,12 +1364,10 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1302
1364
  serverInfo.oauth = {
1303
1365
  authorizationUrl: pendingAuth.authorizationUrl,
1304
1366
  state: pendingAuth.state,
1305
- codeVerifier: pendingAuth.codeVerifier,
1306
1367
  };
1307
1368
  }
1308
1369
  nextServerInfos.push(serverInfo);
1309
- client
1310
- .connect(transport, initRequestOptions || requestOptions)
1370
+ connectClientWithDiagnostics(client, transport, initRequestOptions || requestOptions)
1311
1371
  .then(() => {
1312
1372
  console.log(`Successfully connected client for server: ${name}`);
1313
1373
  const serverVersion = client.getServerVersion?.();
@@ -1416,6 +1476,19 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1416
1476
  throw error;
1417
1477
  }
1418
1478
  serverInfos = nextServerInfos;
1479
+ // Populate the tool cache for on-demand stdio servers so their tools are
1480
+ // visible to agents, then put them back to sleep. Running here (rather than
1481
+ // only at startup) also covers servers added/enabled/reloaded after startup.
1482
+ const primePromise = primeOnDemandServers();
1483
+ // At full startup (no serverName) keep prime fire-and-forget so a slow or
1484
+ // broken on-demand server does not block init. For a targeted reload/edit
1485
+ // (serverName set), await it so the caller - and the dashboard refresh that
1486
+ // follows the HTTP response - sees the cached tools immediately instead of an
1487
+ // empty list that only fills in after the fire-and-forget prime completes and
1488
+ // the next poll picks it up. See #1032.
1489
+ if (serverName) {
1490
+ await primePromise;
1491
+ }
1419
1492
  return serverInfos;
1420
1493
  };
1421
1494
  // Register all MCP tools
@@ -1492,6 +1565,14 @@ export const getServersInfo = async (page, limit, user) => {
1492
1565
  enabled: promptConfig?.enabled !== false, // Default to true if not explicitly disabled
1493
1566
  };
1494
1567
  });
1568
+ const resourcesWithEnabled = resources.map((resource) => {
1569
+ const resourceConfig = serverConfig?.resources?.[resource.uri];
1570
+ return {
1571
+ ...resource,
1572
+ description: resolveDescriptionOverride(resource.description, resourceConfig),
1573
+ enabled: resourceConfig?.enabled !== false,
1574
+ };
1575
+ });
1495
1576
  return {
1496
1577
  name,
1497
1578
  version,
@@ -1502,7 +1583,7 @@ export const getServersInfo = async (page, limit, user) => {
1502
1583
  error,
1503
1584
  tools: toolsWithEnabled,
1504
1585
  prompts: promptsWithEnabled,
1505
- resources,
1586
+ resources: resourcesWithEnabled,
1506
1587
  createTime,
1507
1588
  enabled,
1508
1589
  oauth: oauth || oauthConnected
@@ -1511,6 +1592,7 @@ export const getServersInfo = async (page, limit, user) => {
1511
1592
  ? {
1512
1593
  authorizationUrl: oauth.authorizationUrl,
1513
1594
  state: oauth.state,
1595
+ ...(oauth.clientIdConfigured ? { clientIdConfigured: true } : {}),
1514
1596
  // Don't expose codeVerifier to frontend for security
1515
1597
  }
1516
1598
  : {}),
@@ -1631,7 +1713,7 @@ const filterToolsByConfig = async (serverName, tools) => {
1631
1713
  };
1632
1714
  // Get server by tool name
1633
1715
  const getServerByTool = (toolName) => {
1634
- return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
1716
+ return serverInfos.find((serverInfo) => serverInfo.enabled !== false && serverInfo.tools.some((tool) => tool.name === toolName));
1635
1717
  };
1636
1718
  // Add new server
1637
1719
  export const addServer = async (name, config) => {
@@ -1752,6 +1834,198 @@ function closeServer(name) {
1752
1834
  closeServerRuntime(serverInfo);
1753
1835
  }
1754
1836
  }
1837
+ // ---------------------------------------------------------------------------
1838
+ // On-demand spawning helpers
1839
+ // ---------------------------------------------------------------------------
1840
+ /**
1841
+ * Shuts down an on-demand stdio server without removing it from serverInfos.
1842
+ * The tool/prompt/resource lists are preserved so agents can still see what
1843
+ * the server offers and trigger a cold-start on the next tool call.
1844
+ */
1845
+ const shutdownOnDemandServer = (serverInfo) => {
1846
+ if (serverInfo.idleTimeoutId) {
1847
+ clearTimeout(serverInfo.idleTimeoutId);
1848
+ serverInfo.idleTimeoutId = undefined;
1849
+ }
1850
+ // Close runtime (kills process) but intentionally keep tools/prompts/resources
1851
+ closeServerRuntime(serverInfo);
1852
+ serverInfo.status = 'disconnected';
1853
+ console.log(`[${serverInfo.name}] On-demand server shut down after idle; ` +
1854
+ `${serverInfo.tools.length} tools cached for next wake-up`);
1855
+ };
1856
+ /**
1857
+ * Resets (or starts) the idle-shutdown timer for an on-demand server.
1858
+ * Call this after every successful tool invocation.
1859
+ */
1860
+ const scheduleIdleShutdown = (serverInfo) => {
1861
+ if (!serverInfo.config?.startOnDemand)
1862
+ return;
1863
+ if (serverInfo.idleTimeoutId) {
1864
+ clearTimeout(serverInfo.idleTimeoutId);
1865
+ }
1866
+ const idleMs = serverInfo.config.idleTimeoutMs ?? 300000; // default 5 minutes
1867
+ serverInfo.idleTimeoutId = setTimeout(() => {
1868
+ if (serverInfo.status === 'connected') {
1869
+ shutdownOnDemandServer(serverInfo);
1870
+ }
1871
+ }, idleMs);
1872
+ };
1873
+ /**
1874
+ * Ensures an on-demand server is connected before a tool call proceeds.
1875
+ *
1876
+ * Performs a direct, awaited wake-up that mutates the existing `serverInfo`
1877
+ * in place (preserving the reference callers hold), rather than going through
1878
+ * `reconnectServer` -> `initializeClientsFromSettings`. The latter skips the
1879
+ * connect for on-demand servers, resolves before the fire-and-forget handshake
1880
+ * completes, and rebuilds `serverInfos` (leaving callers with a stale object) -
1881
+ * so it could never actually wake a sleeping server. See #1029.
1882
+ *
1883
+ * Concurrent callers coalesce on `spawningPromise` so only one spawn runs.
1884
+ */
1885
+ const ensureServerReady = async (serverInfo) => {
1886
+ if (serverInfo.status === 'connected')
1887
+ return;
1888
+ // Another call is already spawning - wait for it
1889
+ if (serverInfo.spawningPromise) {
1890
+ await serverInfo.spawningPromise;
1891
+ return;
1892
+ }
1893
+ const spawnStart = Date.now();
1894
+ const spawnPromise = (async () => {
1895
+ // Load the config fresh from the DAO for the expanded transport config and
1896
+ // to read the server name. See #1029.
1897
+ const rawConfig = await getServerDao().findById(serverInfo.name);
1898
+ if (!rawConfig) {
1899
+ throw new Error('Server configuration not found for on-demand wake');
1900
+ }
1901
+ if (rawConfig.enabled === false) {
1902
+ throw new Error(`Cannot start disabled on-demand server: ${rawConfig.name}`);
1903
+ }
1904
+ const expandedConf = replaceEnvVars(rawConfig);
1905
+ // startOnDemand is a stdio-only optimisation; OpenAPI/HTTP servers have no
1906
+ // deferred process to spawn, so there is nothing to wake.
1907
+ if (expandedConf.type === 'openapi') {
1908
+ return;
1909
+ }
1910
+ const name = rawConfig.name;
1911
+ console.log(`[${name}] Cold-starting on-demand server…`);
1912
+ const transport = await createTransportFromConfig(name, expandedConf);
1913
+ const client = createUpstreamMcpClient(name, () => serverInfo);
1914
+ const serverRequestOptions = expandedConf.options || {};
1915
+ const defaultRequestTimeout = Number(process.env.DEFAULT_REQUEST_TIMEOUT) || 60000;
1916
+ const requestOptions = {
1917
+ timeout: serverRequestOptions.timeout || defaultRequestTimeout,
1918
+ resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress ?? true,
1919
+ maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
1920
+ };
1921
+ await connectClientWithDiagnostics(client, transport, requestOptions);
1922
+ // Mutate in place so every holder of this serverInfo (resolution paths,
1923
+ // the caller about to invoke the tool) sees the live client/status.
1924
+ serverInfo.client = client;
1925
+ serverInfo.transport = transport;
1926
+ serverInfo.options = requestOptions;
1927
+ serverInfo.version = client.getServerVersion?.()?.version;
1928
+ serverInfo.instructions = client.getInstructions?.();
1929
+ serverInfo.config = expandedConf;
1930
+ serverInfo.status = 'connected';
1931
+ serverInfo.error = null;
1932
+ let toolCount = 0;
1933
+ let promptCount = 0;
1934
+ let resourceCount = 0;
1935
+ const capabilities = client.getServerCapabilities?.();
1936
+ if (capabilities?.tools) {
1937
+ try {
1938
+ const tools = await client.listTools({}, requestOptions);
1939
+ toolCount = tools.tools.length;
1940
+ updateServerToolsCache(serverInfo, tools.tools);
1941
+ broadcastToolListChanged();
1942
+ }
1943
+ catch (error) {
1944
+ console.warn(`[${name}] Failed to list tools during wake`, {
1945
+ error: summarizeErrorForLogging(error),
1946
+ });
1947
+ }
1948
+ }
1949
+ if (capabilities?.prompts) {
1950
+ try {
1951
+ const prompts = await client.listPrompts({}, requestOptions);
1952
+ promptCount = prompts.prompts.length;
1953
+ updateServerPromptsCache(serverInfo, prompts.prompts);
1954
+ }
1955
+ catch (error) {
1956
+ console.warn(`[${name}] Failed to list prompts during wake`, {
1957
+ error: summarizeErrorForLogging(error),
1958
+ });
1959
+ }
1960
+ }
1961
+ if (capabilities?.resources) {
1962
+ try {
1963
+ const resources = await client.listResources({}, requestOptions);
1964
+ resourceCount = resources.resources.length;
1965
+ updateServerResourcesCache(serverInfo, resources.resources);
1966
+ }
1967
+ catch (error) {
1968
+ console.warn(`[${name}] Failed to list resources during wake`, {
1969
+ error: summarizeErrorForLogging(error),
1970
+ });
1971
+ }
1972
+ }
1973
+ // No-op for stdio transports; only SSE/streamable-http opt in via enableKeepAlive.
1974
+ setupServerKeepAlive(serverInfo, expandedConf);
1975
+ console.log(`[${name}] On-demand server ready in ${Date.now() - spawnStart}ms` +
1976
+ ` (${toolCount} tools, ${promptCount} prompts, ${resourceCount} resources)`);
1977
+ })();
1978
+ serverInfo.spawningPromise = spawnPromise;
1979
+ try {
1980
+ await spawnPromise;
1981
+ }
1982
+ catch (error) {
1983
+ serverInfo.status = 'disconnected';
1984
+ serverInfo.error = `Failed to start on-demand server: ${formatErrorForLogging(error)}`;
1985
+ throw error;
1986
+ }
1987
+ finally {
1988
+ serverInfo.spawningPromise = undefined;
1989
+ }
1990
+ };
1991
+ // stdio servers are the only ones that benefit from deferred spawning; HTTP/SSE
1992
+ // and OpenAPI servers have no long-lived child process to skip.
1993
+ const isStdioServer = (conf) => !!conf && (conf.type === 'stdio' || (!conf.type && !!conf.command));
1994
+ /**
1995
+ * Connects each on-demand stdio server once to populate its tool cache, then
1996
+ * shuts the child back down (keeping the cache). Run after startup init so the
1997
+ * tools are advertised immediately and a later `tools/call` can wake the server.
1998
+ *
1999
+ * Returns a promise so a targeted reload/edit can await it (via
2000
+ * initializeClientsFromSettings) and surface the populated tool cache before
2001
+ * the HTTP response - otherwise the dashboard refresh that follows the edit
2002
+ * sees an empty list until the next poll. At full startup the caller still
2003
+ * ignores the promise (fire-and-forget) so a slow or broken on-demand server
2004
+ * does not block init. Each server is isolated so one failure does not affect
2005
+ * the others. See #1029 / #1032.
2006
+ */
2007
+ const primeOnDemandServers = () => {
2008
+ const targets = serverInfos.filter((si) => si.config?.startOnDemand === true &&
2009
+ isStdioServer(si.config) &&
2010
+ si.tools.length === 0 &&
2011
+ si.enabled !== false);
2012
+ if (targets.length === 0)
2013
+ return Promise.resolve();
2014
+ console.log(`Priming ${targets.length} on-demand server(s) for tool discovery…`);
2015
+ return Promise.allSettled(targets.map(async (si) => {
2016
+ try {
2017
+ await ensureServerReady(si);
2018
+ // Sleep the child but keep the cached tool list visible to agents.
2019
+ shutdownOnDemandServer(si);
2020
+ console.log('On-demand server primed and asleep');
2021
+ }
2022
+ catch (error) {
2023
+ console.warn('Failed to prime on-demand server', {
2024
+ error: summarizeErrorForLogging(error),
2025
+ });
2026
+ }
2027
+ })).then(() => undefined);
2028
+ };
1755
2029
  export const resetServerOAuthConnection = (name) => {
1756
2030
  const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
1757
2031
  if (!serverInfo) {
@@ -1881,14 +2155,19 @@ const getMcpAppsRouteContext = async (sessionId, group) => {
1881
2155
  return { enabled: false };
1882
2156
  }
1883
2157
  const { filteredServerInfos } = await getFilteredServerInfosForGroup(group);
1884
- if (filteredServerInfos.length !== 1 ||
1885
- filteredServerInfos[0].status !== 'connected' ||
1886
- !filteredServerInfos[0].client) {
2158
+ const connectedServerInfos = filteredServerInfos.filter((serverInfo) => serverInfo.status === 'connected' && !!serverInfo.client);
2159
+ if (connectedServerInfos.length === 0) {
1887
2160
  return { enabled: false };
1888
2161
  }
2162
+ if (connectedServerInfos.length === 1) {
2163
+ return {
2164
+ enabled: true,
2165
+ serverInfo: connectedServerInfos[0],
2166
+ };
2167
+ }
1889
2168
  return {
1890
2169
  enabled: true,
1891
- serverInfo: filteredServerInfos[0],
2170
+ serverInfos: connectedServerInfos,
1892
2171
  };
1893
2172
  };
1894
2173
  const normalizeToolNameForServer = (serverName, toolName) => {
@@ -1939,7 +2218,11 @@ const resolveToolInGroup = async (group, toolName, allowRawName) => {
1939
2218
  }
1940
2219
  const { filteredServerInfos, serverConfigsByName } = await getFilteredServerInfosForGroup(lookupGroup);
1941
2220
  for (const serverInfo of filteredServerInfos) {
1942
- if (serverInfo.status !== 'connected' || serverInfo.enabled === false) {
2221
+ // A disconnected on-demand server may still have a cached tool list from a
2222
+ // previous wake-up; allow it through so it can be selected and woken by the
2223
+ // caller. Other disconnected servers cannot serve the request. See #1029.
2224
+ if ((serverInfo.status !== 'connected' && !serverInfo.config?.startOnDemand) ||
2225
+ serverInfo.enabled === false) {
1943
2226
  continue;
1944
2227
  }
1945
2228
  const serverConfig = serverConfigsByName.get(serverInfo.name);
@@ -1985,11 +2268,19 @@ const projectToolForDownstream = (serverName, tool, appsRouteContext, serverConf
1985
2268
  return undefined;
1986
2269
  }
1987
2270
  const projectedTool = appsRouteContext.enabled ? tool : stripMcpAppsMetadata(tool);
2271
+ // Single connected server: keep the existing raw (unqualified) name so
2272
+ // widgets that call back with the bare tool name continue to work.
2273
+ if (appsRouteContext.serverInfo) {
2274
+ return {
2275
+ ...projectedTool,
2276
+ name: normalizeToolNameForServer(serverName, projectedTool.name),
2277
+ };
2278
+ }
2279
+ // Multiple connected servers (Apps-enabled) or Apps disabled: qualify the
2280
+ // name so it can still be routed to the right server unambiguously.
1988
2281
  return {
1989
2282
  ...projectedTool,
1990
- name: appsRouteContext.enabled
1991
- ? normalizeToolNameForServer(serverName, projectedTool.name)
1992
- : projectNameForGroup(projectedTool.name, serverName, serverConfig),
2283
+ name: projectNameForGroup(projectedTool.name, serverName, serverConfig),
1993
2284
  };
1994
2285
  };
1995
2286
  export const handleListToolsRequest = async (_, extra) => {
@@ -2003,6 +2294,14 @@ export const handleListToolsRequest = async (_, extra) => {
2003
2294
  }
2004
2295
  const { filteredServerInfos, serverConfigsByName } = await getFilteredServerInfosForGroup(group);
2005
2296
  const appsRouteContext = await getMcpAppsRouteContext(sessionId, group);
2297
+ // If the startup prime of an on-demand server is still in flight, wait for it
2298
+ // so this list reflects the freshly cached tools instead of returning empty.
2299
+ // No wake is triggered from list itself; the prime handles that. See #1029.
2300
+ await Promise.allSettled(filteredServerInfos
2301
+ .filter((si) => si.config?.startOnDemand === true &&
2302
+ si.tools.length === 0 &&
2303
+ si.spawningPromise)
2304
+ .map((si) => si.spawningPromise));
2006
2305
  const allTools = [];
2007
2306
  for (const serverInfo of filteredServerInfos) {
2008
2307
  if (serverInfo.tools && serverInfo.tools.length > 0) {
@@ -2084,17 +2383,22 @@ export const handleCallToolRequest = async (request, extra) => {
2084
2383
  throw new Error('toolName parameter is required');
2085
2384
  }
2086
2385
  const { arguments: toolArgs } = request.params.arguments || {};
2386
+ // A single connected server on an Apps-enabled route is addressed by
2387
+ // its raw (unqualified) tool name, same as before. A multi-server
2388
+ // Apps-enabled route falls through to qualified-name group
2389
+ // resolution below, same as an ordinary (non-Apps) group route.
2390
+ const singleServerAppsRoute = !!appsRouteContext.serverInfo;
2087
2391
  let targetServerInfo;
2088
2392
  let targetToolName = toolName;
2089
2393
  let targetTool;
2090
- if (appsRouteContext.enabled) {
2394
+ if (singleServerAppsRoute) {
2091
2395
  targetServerInfo = appsRouteContext.serverInfo;
2092
2396
  }
2093
2397
  else if (extra && extra.server) {
2094
2398
  targetServerInfo = getServerByName(extra.server);
2095
2399
  }
2096
2400
  else if (getGroupLookupName(group)) {
2097
- const groupTool = await resolveToolInGroup(group, toolName, appsRouteContext.enabled);
2401
+ const groupTool = await resolveToolInGroup(group, toolName, false);
2098
2402
  if (groupTool) {
2099
2403
  targetServerInfo = groupTool.serverInfo;
2100
2404
  targetToolName = groupTool.toolName;
@@ -2102,16 +2406,30 @@ export const handleCallToolRequest = async (request, extra) => {
2102
2406
  }
2103
2407
  }
2104
2408
  else {
2105
- // Find the first server that has this tool
2106
- targetServerInfo = serverInfos.find((serverInfo) => serverInfo.status === 'connected' &&
2107
- serverInfo.enabled !== false &&
2409
+ // Find the first server that has this tool.
2410
+ // On-demand servers may be sleeping (disconnected) but still advertise
2411
+ // their cached tool list — include them as candidates.
2412
+ targetServerInfo = serverInfos.find((serverInfo) => serverInfo.enabled !== false &&
2413
+ (serverInfo.status === 'connected' || serverInfo.config?.startOnDemand === true) &&
2108
2414
  serverInfo.tools.some((tool) => tool.name === toolName));
2109
2415
  }
2110
2416
  if (!targetServerInfo) {
2111
2417
  throw new Error(`No available servers found with tool: ${toolName}`);
2112
2418
  }
2419
+ // If the target is an on-demand server that is not yet running, wake it up now.
2420
+ // Concurrent callers are serialised via ensureServerReady's singleton promise.
2421
+ if (targetServerInfo.config?.startOnDemand && targetServerInfo.status !== 'connected') {
2422
+ await ensureServerReady(targetServerInfo);
2423
+ // Re-read status from the mutated serverInfo after async spawn
2424
+ const freshStatus = targetServerInfo.status;
2425
+ if (freshStatus !== 'connected') {
2426
+ throw new Error(`Failed to start on-demand server '${targetServerInfo.name}' — check server logs`);
2427
+ }
2428
+ }
2429
+ // Record activity timestamp for on-demand servers
2430
+ targetServerInfo.lastUsedAt = Date.now();
2113
2431
  // Check if the tool exists on the server
2114
- const tool = targetTool ?? findToolOnServer(targetServerInfo, targetToolName, appsRouteContext.enabled);
2432
+ const tool = targetTool ?? findToolOnServer(targetServerInfo, targetToolName, singleServerAppsRoute);
2115
2433
  if (!tool) {
2116
2434
  throw new Error(`Tool '${toolName}' not found on server '${targetServerInfo.name}'`);
2117
2435
  }
@@ -2227,6 +2545,8 @@ export const handleCallToolRequest = async (request, extra) => {
2227
2545
  toolName: cleanToolName,
2228
2546
  result: summarizeToolResultForLogging(result),
2229
2547
  });
2548
+ // Reset idle-shutdown timer for on-demand servers after each successful call
2549
+ scheduleIdleShutdown(targetServerInfo);
2230
2550
  // Log successful activity
2231
2551
  const duration = Date.now() - startTime;
2232
2552
  await activityLogger.logToolCall({
@@ -2251,22 +2571,36 @@ export const handleCallToolRequest = async (request, extra) => {
2251
2571
  }
2252
2572
  // Regular tool handling
2253
2573
  const lookupGroup = getGroupLookupName(group);
2254
- const groupTool = !appsRouteContext.enabled && lookupGroup
2255
- ? await resolveToolInGroup(lookupGroup, request.params.name, appsRouteContext.enabled)
2574
+ // A single connected server on an Apps-enabled route is addressed
2575
+ // directly, using its raw (unqualified) tool name, same as before. A
2576
+ // multi-server Apps-enabled route resolves via the qualified-name
2577
+ // group lookup below, same as an ordinary (non-Apps) group route,
2578
+ // just without stripping Apps metadata or hiding app-only tools
2579
+ // (assertToolAvailableForRoute below still gates on appsRouteContext.enabled).
2580
+ const singleServerAppsRoute = !!appsRouteContext.serverInfo;
2581
+ const groupTool = !singleServerAppsRoute && lookupGroup
2582
+ ? await resolveToolInGroup(lookupGroup, request.params.name, false)
2256
2583
  : undefined;
2257
- const serverInfo = appsRouteContext.enabled
2584
+ const serverInfo = singleServerAppsRoute
2258
2585
  ? appsRouteContext.serverInfo
2259
2586
  : (groupTool?.serverInfo ??
2260
2587
  (lookupGroup ? undefined : getServerByTool(request.params.name)));
2261
2588
  const routeToolName = groupTool?.toolName ?? request.params.name;
2262
2589
  const tool = groupTool?.tool ??
2263
2590
  (serverInfo
2264
- ? findToolOnServer(serverInfo, routeToolName, appsRouteContext.enabled)
2591
+ ? findToolOnServer(serverInfo, routeToolName, singleServerAppsRoute)
2265
2592
  : undefined);
2266
2593
  if (!serverInfo || !tool) {
2267
2594
  throw new Error(`Server not found: ${request.params.name}`);
2268
2595
  }
2269
2596
  assertToolAvailableForRoute(tool, appsRouteContext);
2597
+ // Wake the on-demand server before invoking the tool. It may be asleep with
2598
+ // a cached tool list (visible above) but no live client. Mirrors the
2599
+ // $smart call_tool path. See #1029.
2600
+ if (serverInfo.config?.startOnDemand && serverInfo.status !== 'connected') {
2601
+ await ensureServerReady(serverInfo);
2602
+ }
2603
+ serverInfo.lastUsedAt = Date.now();
2270
2604
  // Handle OpenAPI servers differently
2271
2605
  if (serverInfo.openApiClient) {
2272
2606
  // For OpenAPI servers, use the OpenAPI client
@@ -2366,6 +2700,8 @@ export const handleCallToolRequest = async (request, extra) => {
2366
2700
  toolName: cleanToolName,
2367
2701
  result: summarizeToolResultForLogging(result),
2368
2702
  });
2703
+ // Reset idle-shutdown timer for on-demand servers after each successful call
2704
+ scheduleIdleShutdown(serverInfo);
2369
2705
  // Log successful activity
2370
2706
  const duration = Date.now() - startTime;
2371
2707
  await activityLogger.logToolCall({
@@ -2657,15 +2993,44 @@ export const handleReadResourceRequest = async (request, extra) => {
2657
2993
  break;
2658
2994
  }
2659
2995
  }
2660
- if (!server && appsRouteContext.enabled && uri.startsWith('ui://')) {
2661
- server = appsRouteContext.serverInfo;
2996
+ let result;
2997
+ if (server?.client) {
2998
+ result = await server.client.readResource({ uri });
2999
+ if (!result || !Array.isArray(result.contents)) {
3000
+ throw new Error(`Failed to read resource: ${uri}`);
3001
+ }
2662
3002
  }
2663
- if (!server?.client) {
2664
- throw new Error(`Resource not found: ${uri}`);
3003
+ else if (appsRouteContext.enabled && uri.startsWith('ui://')) {
3004
+ // Not pre-registered in resources/list — common for MCP Apps servers
3005
+ // that generate ui:// resources dynamically at tool-call time. A
3006
+ // single-server route goes straight to that server. A multi-server
3007
+ // route has no way to know which upstream owns the URI up front, so
3008
+ // probe each connected candidate in turn and use the first one that
3009
+ // answers.
3010
+ const candidates = appsRouteContext.serverInfo
3011
+ ? [appsRouteContext.serverInfo]
3012
+ : (appsRouteContext.serverInfos ?? []);
3013
+ for (const candidate of candidates) {
3014
+ if (!candidate.client) {
3015
+ continue;
3016
+ }
3017
+ try {
3018
+ const candidateResult = await candidate.client.readResource({ uri });
3019
+ if (candidateResult && Array.isArray(candidateResult.contents)) {
3020
+ result = candidateResult;
3021
+ break;
3022
+ }
3023
+ }
3024
+ catch {
3025
+ // This candidate doesn't own the resource; try the next one.
3026
+ }
3027
+ }
3028
+ if (!result) {
3029
+ throw new Error(`Resource not found: ${uri}`);
3030
+ }
2665
3031
  }
2666
- const result = await server.client.readResource({ uri });
2667
- if (!result || !Array.isArray(result.contents)) {
2668
- throw new Error(`Failed to read resource: ${uri}`);
3032
+ else {
3033
+ throw new Error(`Resource not found: ${uri}`);
2669
3034
  }
2670
3035
  return appsRouteContext.enabled
2671
3036
  ? result