@samanhappy/mcphub 1.0.27 → 1.0.29

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 (69) hide show
  1. package/README.fr.md +1 -1
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/dist/clients/openapi.js +194 -15
  5. package/dist/clients/openapi.js.map +1 -1
  6. package/dist/controllers/oauthCallbackController.js +0 -1
  7. package/dist/controllers/oauthCallbackController.js.map +1 -1
  8. package/dist/controllers/serverController.js +34 -2
  9. package/dist/controllers/serverController.js.map +1 -1
  10. package/dist/server.js +12 -9
  11. package/dist/server.js.map +1 -1
  12. package/dist/services/mcpOAuthProvider.js +24 -5
  13. package/dist/services/mcpOAuthProvider.js.map +1 -1
  14. package/dist/services/mcpService.js +208 -15
  15. package/dist/services/mcpService.js.map +1 -1
  16. package/dist/utils/jsonSchemaValidator.js +26 -0
  17. package/dist/utils/jsonSchemaValidator.js.map +1 -0
  18. package/dist/utils/serverConfigPersistence.js +10 -0
  19. package/dist/utils/serverConfigPersistence.js.map +1 -1
  20. package/dist/utils/serverShutdown.js +27 -0
  21. package/dist/utils/serverShutdown.js.map +1 -0
  22. package/frontend/dist/assets/{ActivityPage-Ci3V0MRG.js → ActivityPage-F14wJ7k4.js} +2 -2
  23. package/frontend/dist/assets/{ActivityPage-Ci3V0MRG.js.map → ActivityPage-F14wJ7k4.js.map} +1 -1
  24. package/frontend/dist/assets/{Dashboard-CugghcDJ.js → Dashboard-CDd51FL-.js} +2 -2
  25. package/frontend/dist/assets/{Dashboard-CugghcDJ.js.map → Dashboard-CDd51FL-.js.map} +1 -1
  26. package/frontend/dist/assets/{EndpointCopy-BXZo8Cty.js → EndpointCopy-AZCY_Rwi.js} +2 -2
  27. package/frontend/dist/assets/{EndpointCopy-BXZo8Cty.js.map → EndpointCopy-AZCY_Rwi.js.map} +1 -1
  28. package/frontend/dist/assets/{GroupsPage-W6RjpC0i.js → GroupsPage-CaJKH8Lx.js} +2 -2
  29. package/frontend/dist/assets/{GroupsPage-W6RjpC0i.js.map → GroupsPage-CaJKH8Lx.js.map} +1 -1
  30. package/frontend/dist/assets/{LoginPage-CeK6PaNa.js → LoginPage-DeHCwVAG.js} +2 -2
  31. package/frontend/dist/assets/{LoginPage-CeK6PaNa.js.map → LoginPage-DeHCwVAG.js.map} +1 -1
  32. package/frontend/dist/assets/{LogsPage--YJi3jrU.js → LogsPage-CnCc36oX.js} +2 -2
  33. package/frontend/dist/assets/{LogsPage--YJi3jrU.js.map → LogsPage-CnCc36oX.js.map} +1 -1
  34. package/frontend/dist/assets/{MarketPage-CM_ywdX2.js → MarketPage-BfhuTkze.js} +2 -2
  35. package/frontend/dist/assets/{MarketPage-CM_ywdX2.js.map → MarketPage-BfhuTkze.js.map} +1 -1
  36. package/frontend/dist/assets/{PromptsPage-Bt7UumtQ.js → PromptsPage-Dq-mlu1T.js} +2 -2
  37. package/frontend/dist/assets/{PromptsPage-Bt7UumtQ.js.map → PromptsPage-Dq-mlu1T.js.map} +1 -1
  38. package/frontend/dist/assets/{ResourcesPage-DhB3ycyY.js → ResourcesPage-CDquGhgr.js} +2 -2
  39. package/frontend/dist/assets/{ResourcesPage-DhB3ycyY.js.map → ResourcesPage-CDquGhgr.js.map} +1 -1
  40. package/frontend/dist/assets/ServersPage-z4d-9c6s.js +39 -0
  41. package/frontend/dist/assets/ServersPage-z4d-9c6s.js.map +1 -0
  42. package/frontend/dist/assets/{SettingsPage-DfCWu2KX.js → SettingsPage-BrpO0gki.js} +2 -2
  43. package/frontend/dist/assets/{SettingsPage-DfCWu2KX.js.map → SettingsPage-BrpO0gki.js.map} +1 -1
  44. package/frontend/dist/assets/{StatusDot-HBipO3HS.js → StatusDot-BznpIDQ4.js} +2 -2
  45. package/frontend/dist/assets/{StatusDot-HBipO3HS.js.map → StatusDot-BznpIDQ4.js.map} +1 -1
  46. package/frontend/dist/assets/{ToggleGroup-pYyiJRvx.js → ToggleGroup-DRYZvM0V.js} +2 -2
  47. package/frontend/dist/assets/{ToggleGroup-pYyiJRvx.js.map → ToggleGroup-DRYZvM0V.js.map} +1 -1
  48. package/frontend/dist/assets/{UsersPage-BNoq4Qio.js → UsersPage-Bbb4nPBC.js} +2 -2
  49. package/frontend/dist/assets/{UsersPage-BNoq4Qio.js.map → UsersPage-Bbb4nPBC.js.map} +1 -1
  50. package/frontend/dist/assets/{contextCost-DcQ1PVMm.js → contextCost-BFsl4S2x.js} +2 -2
  51. package/frontend/dist/assets/{contextCost-DcQ1PVMm.js.map → contextCost-BFsl4S2x.js.map} +1 -1
  52. package/frontend/dist/assets/index-CgcdOGDO.js +3 -0
  53. package/frontend/dist/assets/{index-CZJVfOO6.js.map → index-CgcdOGDO.js.map} +1 -1
  54. package/frontend/dist/assets/index-DEAy4k6z.css +1 -0
  55. package/frontend/dist/assets/{resourceService-CJciejgX.js → resourceService-Ck6rpYB-.js} +2 -2
  56. package/frontend/dist/assets/{resourceService-CJciejgX.js.map → resourceService-Ck6rpYB-.js.map} +1 -1
  57. package/frontend/dist/assets/useSettingsData-Dkw2yET1.js +2 -0
  58. package/frontend/dist/assets/{useSettingsData-hgvZTFTk.js.map → useSettingsData-Dkw2yET1.js.map} +1 -1
  59. package/frontend/dist/assets/variableDetection-BnH3Qdq5.js +16 -0
  60. package/frontend/dist/assets/variableDetection-BnH3Qdq5.js.map +1 -0
  61. package/frontend/dist/index.html +2 -2
  62. package/package.json +4 -3
  63. package/frontend/dist/assets/ServersPage-BFwvEzGn.js +0 -37
  64. package/frontend/dist/assets/ServersPage-BFwvEzGn.js.map +0 -1
  65. package/frontend/dist/assets/index-BfEBXfi0.css +0 -1
  66. package/frontend/dist/assets/index-CZJVfOO6.js +0 -3
  67. package/frontend/dist/assets/useSettingsData-hgvZTFTk.js +0 -2
  68. package/frontend/dist/assets/variableDetection-DPNQ6jqO.js +0 -16
  69. package/frontend/dist/assets/variableDetection-DPNQ6jqO.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';
@@ -287,6 +288,11 @@ const cleanupIsolatedSession = (sessionId) => {
287
288
  }
288
289
  console.log(`Cleaned up isolated clients for session: ${sessionId}`);
289
290
  }
291
+ // Drop per-session OpenAPI cookie jars so authenticated state does not
292
+ // outlive the downstream session.
293
+ for (const serverInfo of serverInfos) {
294
+ serverInfo.openApiClient?.clearSessionCookies?.(sessionId);
295
+ }
290
296
  };
291
297
  /** Helper to write an isolated session upstream client into the tracking map. */
292
298
  const setSessionIsolatedClient = (sessionId, serverName, client, transport) => {
@@ -543,6 +549,7 @@ const createUpstreamMcpClient = (name, getServerInfo) => {
543
549
  version: '1.0.0',
544
550
  }, {
545
551
  capabilities: MCP_APPS_CAPABILITIES,
552
+ jsonSchemaValidator: new ResilientJsonSchemaValidator(),
546
553
  listChanged: {
547
554
  tools: {
548
555
  onChanged: (error, tools) => {
@@ -1169,10 +1176,12 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1169
1176
  });
1170
1177
  continue;
1171
1178
  }
1172
- // On-demand servers: skip startup connect; spawn lazily on first tool call.
1179
+ // On-demand stdio servers: skip startup connect; spawn lazily on first
1180
+ // tool call. startOnDemand is a stdio-only optimisation - HTTP/SSE and
1181
+ // OpenAPI servers have no deferred child process, so they connect normally.
1173
1182
  // Preserve existing runtime state (tools/prompts populated from a previous
1174
1183
  // connection) so the tool list stays visible to agents even while sleeping.
1175
- if (expandedConf.startOnDemand === true) {
1184
+ if (expandedConf.startOnDemand === true && isStdioServer(expandedConf)) {
1176
1185
  const existingOnDemand = existingServerInfos.find((s) => s.name === name);
1177
1186
  nextServerInfos.push({
1178
1187
  // Carry over cached tools/prompts/resources if the server was previously connected
@@ -1360,7 +1369,6 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1360
1369
  serverInfo.oauth = {
1361
1370
  authorizationUrl: pendingAuth.authorizationUrl,
1362
1371
  state: pendingAuth.state,
1363
- codeVerifier: pendingAuth.codeVerifier,
1364
1372
  };
1365
1373
  }
1366
1374
  nextServerInfos.push(serverInfo);
@@ -1473,6 +1481,19 @@ export const initializeClientsFromSettings = async (isInit, serverName, options)
1473
1481
  throw error;
1474
1482
  }
1475
1483
  serverInfos = nextServerInfos;
1484
+ // Populate the tool cache for on-demand stdio servers so their tools are
1485
+ // visible to agents, then put them back to sleep. Running here (rather than
1486
+ // only at startup) also covers servers added/enabled/reloaded after startup.
1487
+ const primePromise = primeOnDemandServers();
1488
+ // At full startup (no serverName) keep prime fire-and-forget so a slow or
1489
+ // broken on-demand server does not block init. For a targeted reload/edit
1490
+ // (serverName set), await it so the caller - and the dashboard refresh that
1491
+ // follows the HTTP response - sees the cached tools immediately instead of an
1492
+ // empty list that only fills in after the fire-and-forget prime completes and
1493
+ // the next poll picks it up. See #1032.
1494
+ if (serverName) {
1495
+ await primePromise;
1496
+ }
1476
1497
  return serverInfos;
1477
1498
  };
1478
1499
  // Register all MCP tools
@@ -1549,6 +1570,14 @@ export const getServersInfo = async (page, limit, user) => {
1549
1570
  enabled: promptConfig?.enabled !== false, // Default to true if not explicitly disabled
1550
1571
  };
1551
1572
  });
1573
+ const resourcesWithEnabled = resources.map((resource) => {
1574
+ const resourceConfig = serverConfig?.resources?.[resource.uri];
1575
+ return {
1576
+ ...resource,
1577
+ description: resolveDescriptionOverride(resource.description, resourceConfig),
1578
+ enabled: resourceConfig?.enabled !== false,
1579
+ };
1580
+ });
1552
1581
  return {
1553
1582
  name,
1554
1583
  version,
@@ -1559,7 +1588,7 @@ export const getServersInfo = async (page, limit, user) => {
1559
1588
  error,
1560
1589
  tools: toolsWithEnabled,
1561
1590
  prompts: promptsWithEnabled,
1562
- resources,
1591
+ resources: resourcesWithEnabled,
1563
1592
  createTime,
1564
1593
  enabled,
1565
1594
  oauth: oauth || oauthConnected
@@ -1568,6 +1597,7 @@ export const getServersInfo = async (page, limit, user) => {
1568
1597
  ? {
1569
1598
  authorizationUrl: oauth.authorizationUrl,
1570
1599
  state: oauth.state,
1600
+ ...(oauth.clientIdConfigured ? { clientIdConfigured: true } : {}),
1571
1601
  // Don't expose codeVerifier to frontend for security
1572
1602
  }
1573
1603
  : {}),
@@ -1688,7 +1718,7 @@ const filterToolsByConfig = async (serverName, tools) => {
1688
1718
  };
1689
1719
  // Get server by tool name
1690
1720
  const getServerByTool = (toolName) => {
1691
- return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
1721
+ return serverInfos.find((serverInfo) => serverInfo.enabled !== false && serverInfo.tools.some((tool) => tool.name === toolName));
1692
1722
  };
1693
1723
  // Add new server
1694
1724
  export const addServer = async (name, config) => {
@@ -1847,30 +1877,160 @@ const scheduleIdleShutdown = (serverInfo) => {
1847
1877
  };
1848
1878
  /**
1849
1879
  * Ensures an on-demand server is connected before a tool call proceeds.
1850
- * Uses a singleton promise so concurrent callers wait for the same spawn
1851
- * instead of triggering duplicate process launches.
1880
+ *
1881
+ * Performs a direct, awaited wake-up that mutates the existing `serverInfo`
1882
+ * in place (preserving the reference callers hold), rather than going through
1883
+ * `reconnectServer` -> `initializeClientsFromSettings`. The latter skips the
1884
+ * connect for on-demand servers, resolves before the fire-and-forget handshake
1885
+ * completes, and rebuilds `serverInfos` (leaving callers with a stale object) -
1886
+ * so it could never actually wake a sleeping server. See #1029.
1887
+ *
1888
+ * Concurrent callers coalesce on `spawningPromise` so only one spawn runs.
1852
1889
  */
1853
1890
  const ensureServerReady = async (serverInfo) => {
1854
1891
  if (serverInfo.status === 'connected')
1855
1892
  return;
1856
- // Another call is already spawning wait for it
1893
+ // Another call is already spawning - wait for it
1857
1894
  if (serverInfo.spawningPromise) {
1858
1895
  await serverInfo.spawningPromise;
1859
1896
  return;
1860
1897
  }
1861
- console.log(`[${serverInfo.name}] Cold-starting on-demand server…`);
1862
1898
  const spawnStart = Date.now();
1863
- const spawnPromise = reconnectServer(serverInfo.name).then(() => {
1864
- console.log(`[${serverInfo.name}] On-demand server ready in ${Date.now() - spawnStart}ms`);
1865
- });
1899
+ const spawnPromise = (async () => {
1900
+ // Load the config fresh from the DAO for the expanded transport config and
1901
+ // to read the server name. See #1029.
1902
+ const rawConfig = await getServerDao().findById(serverInfo.name);
1903
+ if (!rawConfig) {
1904
+ throw new Error('Server configuration not found for on-demand wake');
1905
+ }
1906
+ if (rawConfig.enabled === false) {
1907
+ throw new Error(`Cannot start disabled on-demand server: ${rawConfig.name}`);
1908
+ }
1909
+ const expandedConf = replaceEnvVars(rawConfig);
1910
+ // startOnDemand is a stdio-only optimisation; OpenAPI/HTTP servers have no
1911
+ // deferred process to spawn, so there is nothing to wake.
1912
+ if (expandedConf.type === 'openapi') {
1913
+ return;
1914
+ }
1915
+ const name = rawConfig.name;
1916
+ console.log(`[${name}] Cold-starting on-demand server…`);
1917
+ const transport = await createTransportFromConfig(name, expandedConf);
1918
+ const client = createUpstreamMcpClient(name, () => serverInfo);
1919
+ const serverRequestOptions = expandedConf.options || {};
1920
+ const defaultRequestTimeout = Number(process.env.DEFAULT_REQUEST_TIMEOUT) || 60000;
1921
+ const requestOptions = {
1922
+ timeout: serverRequestOptions.timeout || defaultRequestTimeout,
1923
+ resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress ?? true,
1924
+ maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
1925
+ };
1926
+ await connectClientWithDiagnostics(client, transport, requestOptions);
1927
+ // Mutate in place so every holder of this serverInfo (resolution paths,
1928
+ // the caller about to invoke the tool) sees the live client/status.
1929
+ serverInfo.client = client;
1930
+ serverInfo.transport = transport;
1931
+ serverInfo.options = requestOptions;
1932
+ serverInfo.version = client.getServerVersion?.()?.version;
1933
+ serverInfo.instructions = client.getInstructions?.();
1934
+ serverInfo.config = expandedConf;
1935
+ serverInfo.status = 'connected';
1936
+ serverInfo.error = null;
1937
+ let toolCount = 0;
1938
+ let promptCount = 0;
1939
+ let resourceCount = 0;
1940
+ const capabilities = client.getServerCapabilities?.();
1941
+ if (capabilities?.tools) {
1942
+ try {
1943
+ const tools = await client.listTools({}, requestOptions);
1944
+ toolCount = tools.tools.length;
1945
+ updateServerToolsCache(serverInfo, tools.tools);
1946
+ broadcastToolListChanged();
1947
+ }
1948
+ catch (error) {
1949
+ console.warn(`[${name}] Failed to list tools during wake`, {
1950
+ error: summarizeErrorForLogging(error),
1951
+ });
1952
+ }
1953
+ }
1954
+ if (capabilities?.prompts) {
1955
+ try {
1956
+ const prompts = await client.listPrompts({}, requestOptions);
1957
+ promptCount = prompts.prompts.length;
1958
+ updateServerPromptsCache(serverInfo, prompts.prompts);
1959
+ }
1960
+ catch (error) {
1961
+ console.warn(`[${name}] Failed to list prompts during wake`, {
1962
+ error: summarizeErrorForLogging(error),
1963
+ });
1964
+ }
1965
+ }
1966
+ if (capabilities?.resources) {
1967
+ try {
1968
+ const resources = await client.listResources({}, requestOptions);
1969
+ resourceCount = resources.resources.length;
1970
+ updateServerResourcesCache(serverInfo, resources.resources);
1971
+ }
1972
+ catch (error) {
1973
+ console.warn(`[${name}] Failed to list resources during wake`, {
1974
+ error: summarizeErrorForLogging(error),
1975
+ });
1976
+ }
1977
+ }
1978
+ // No-op for stdio transports; only SSE/streamable-http opt in via enableKeepAlive.
1979
+ setupServerKeepAlive(serverInfo, expandedConf);
1980
+ console.log(`[${name}] On-demand server ready in ${Date.now() - spawnStart}ms` +
1981
+ ` (${toolCount} tools, ${promptCount} prompts, ${resourceCount} resources)`);
1982
+ })();
1866
1983
  serverInfo.spawningPromise = spawnPromise;
1867
1984
  try {
1868
1985
  await spawnPromise;
1869
1986
  }
1987
+ catch (error) {
1988
+ serverInfo.status = 'disconnected';
1989
+ serverInfo.error = `Failed to start on-demand server: ${formatErrorForLogging(error)}`;
1990
+ throw error;
1991
+ }
1870
1992
  finally {
1871
1993
  serverInfo.spawningPromise = undefined;
1872
1994
  }
1873
1995
  };
1996
+ // stdio servers are the only ones that benefit from deferred spawning; HTTP/SSE
1997
+ // and OpenAPI servers have no long-lived child process to skip.
1998
+ const isStdioServer = (conf) => !!conf && (conf.type === 'stdio' || (!conf.type && !!conf.command));
1999
+ /**
2000
+ * Connects each on-demand stdio server once to populate its tool cache, then
2001
+ * shuts the child back down (keeping the cache). Run after startup init so the
2002
+ * tools are advertised immediately and a later `tools/call` can wake the server.
2003
+ *
2004
+ * Returns a promise so a targeted reload/edit can await it (via
2005
+ * initializeClientsFromSettings) and surface the populated tool cache before
2006
+ * the HTTP response - otherwise the dashboard refresh that follows the edit
2007
+ * sees an empty list until the next poll. At full startup the caller still
2008
+ * ignores the promise (fire-and-forget) so a slow or broken on-demand server
2009
+ * does not block init. Each server is isolated so one failure does not affect
2010
+ * the others. See #1029 / #1032.
2011
+ */
2012
+ const primeOnDemandServers = () => {
2013
+ const targets = serverInfos.filter((si) => si.config?.startOnDemand === true &&
2014
+ isStdioServer(si.config) &&
2015
+ si.tools.length === 0 &&
2016
+ si.enabled !== false);
2017
+ if (targets.length === 0)
2018
+ return Promise.resolve();
2019
+ console.log(`Priming ${targets.length} on-demand server(s) for tool discovery…`);
2020
+ return Promise.allSettled(targets.map(async (si) => {
2021
+ try {
2022
+ await ensureServerReady(si);
2023
+ // Sleep the child but keep the cached tool list visible to agents.
2024
+ shutdownOnDemandServer(si);
2025
+ console.log('On-demand server primed and asleep');
2026
+ }
2027
+ catch (error) {
2028
+ console.warn('Failed to prime on-demand server', {
2029
+ error: summarizeErrorForLogging(error),
2030
+ });
2031
+ }
2032
+ })).then(() => undefined);
2033
+ };
1874
2034
  export const resetServerOAuthConnection = (name) => {
1875
2035
  const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
1876
2036
  if (!serverInfo) {
@@ -2063,7 +2223,11 @@ const resolveToolInGroup = async (group, toolName, allowRawName) => {
2063
2223
  }
2064
2224
  const { filteredServerInfos, serverConfigsByName } = await getFilteredServerInfosForGroup(lookupGroup);
2065
2225
  for (const serverInfo of filteredServerInfos) {
2066
- if (serverInfo.status !== 'connected' || serverInfo.enabled === false) {
2226
+ // A disconnected on-demand server may still have a cached tool list from a
2227
+ // previous wake-up; allow it through so it can be selected and woken by the
2228
+ // caller. Other disconnected servers cannot serve the request. See #1029.
2229
+ if ((serverInfo.status !== 'connected' && !serverInfo.config?.startOnDemand) ||
2230
+ serverInfo.enabled === false) {
2067
2231
  continue;
2068
2232
  }
2069
2233
  const serverConfig = serverConfigsByName.get(serverInfo.name);
@@ -2135,6 +2299,14 @@ export const handleListToolsRequest = async (_, extra) => {
2135
2299
  }
2136
2300
  const { filteredServerInfos, serverConfigsByName } = await getFilteredServerInfosForGroup(group);
2137
2301
  const appsRouteContext = await getMcpAppsRouteContext(sessionId, group);
2302
+ // If the startup prime of an on-demand server is still in flight, wait for it
2303
+ // so this list reflects the freshly cached tools instead of returning empty.
2304
+ // No wake is triggered from list itself; the prime handles that. See #1029.
2305
+ await Promise.allSettled(filteredServerInfos
2306
+ .filter((si) => si.config?.startOnDemand === true &&
2307
+ si.tools.length === 0 &&
2308
+ si.spawningPromise)
2309
+ .map((si) => si.spawningPromise));
2138
2310
  const allTools = [];
2139
2311
  for (const serverInfo of filteredServerInfos) {
2140
2312
  if (serverInfo.tools && serverInfo.tools.length > 0) {
@@ -2170,6 +2342,18 @@ export const handleCallToolRequest = async (request, extra) => {
2170
2342
  const requestContextService = RequestContextService.getInstance();
2171
2343
  const bearerKeyContext = requestContextService.getBearerKeyContext();
2172
2344
  const sessionId = extra.sessionId || '';
2345
+ // For OpenAPI cookie-session isolation, use a real per-caller session id.
2346
+ // Direct API controllers fall back to shared synthetic ids ('api-session' /
2347
+ // 'openapi-session') when x-session-id is absent, which would leak cookies
2348
+ // across callers, so only accept an explicitly-provided session header or a
2349
+ // non-synthetic extra.sessionId. Fail-safe (undefined) otherwise.
2350
+ const isSyntheticSessionFallback = (id) => id === 'api-session' || id === 'openapi-session';
2351
+ const explicitXSessionId = extra?.headers?.['x-session-id'];
2352
+ const cookieSessionId = [
2353
+ requestContextService.getSessionId(),
2354
+ typeof explicitXSessionId === 'string' ? explicitXSessionId : undefined,
2355
+ typeof extra?.sessionId === 'string' ? extra.sessionId : undefined,
2356
+ ].find((id) => typeof id === 'string' && id.length > 0 && !isSyntheticSessionFallback(id));
2173
2357
  // Extract group and key info from request context (set by SSE/HTTP handlers)
2174
2358
  // Fallback to extra for backward compatibility (e.g., direct API calls)
2175
2359
  const group = requestContextService.getGroupContext() || extra?.group || getGroup(sessionId) || undefined;
@@ -2305,7 +2489,7 @@ export const handleCallToolRequest = async (request, extra) => {
2305
2489
  }
2306
2490
  }
2307
2491
  await reserveHostedIfNeeded(targetServerInfo.name, cleanToolName);
2308
- const result = await openApiClient.callTool(cleanToolName, finalArgs, passthroughHeaders);
2492
+ const result = await openApiClient.callTool(cleanToolName, finalArgs, passthroughHeaders, false, cookieSessionId);
2309
2493
  await settleHostedIfNeeded({
2310
2494
  success: true,
2311
2495
  requestContent: finalArgs,
@@ -2427,6 +2611,13 @@ export const handleCallToolRequest = async (request, extra) => {
2427
2611
  throw new Error(`Server not found: ${request.params.name}`);
2428
2612
  }
2429
2613
  assertToolAvailableForRoute(tool, appsRouteContext);
2614
+ // Wake the on-demand server before invoking the tool. It may be asleep with
2615
+ // a cached tool list (visible above) but no live client. Mirrors the
2616
+ // $smart call_tool path. See #1029.
2617
+ if (serverInfo.config?.startOnDemand && serverInfo.status !== 'connected') {
2618
+ await ensureServerReady(serverInfo);
2619
+ }
2620
+ serverInfo.lastUsedAt = Date.now();
2430
2621
  // Handle OpenAPI servers differently
2431
2622
  if (serverInfo.openApiClient) {
2432
2623
  // For OpenAPI servers, use the OpenAPI client
@@ -2464,7 +2655,7 @@ export const handleCallToolRequest = async (request, extra) => {
2464
2655
  }
2465
2656
  const finalArgs = request.params.arguments || {};
2466
2657
  await reserveHostedIfNeeded(serverInfo.name, cleanToolName);
2467
- const result = await openApiClient.callTool(cleanToolName, finalArgs, passthroughHeaders);
2658
+ const result = await openApiClient.callTool(cleanToolName, finalArgs, passthroughHeaders, false, cookieSessionId);
2468
2659
  await settleHostedIfNeeded({
2469
2660
  success: true,
2470
2661
  requestContent: finalArgs,
@@ -2526,6 +2717,8 @@ export const handleCallToolRequest = async (request, extra) => {
2526
2717
  toolName: cleanToolName,
2527
2718
  result: summarizeToolResultForLogging(result),
2528
2719
  });
2720
+ // Reset idle-shutdown timer for on-demand servers after each successful call
2721
+ scheduleIdleShutdown(serverInfo);
2529
2722
  // Log successful activity
2530
2723
  const duration = Date.now() - startTime;
2531
2724
  await activityLogger.logToolCall({