@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.
- package/dist/controllers/oauthCallbackController.js +2 -3
- package/dist/controllers/oauthCallbackController.js.map +1 -1
- package/dist/controllers/serverController.js +13 -1
- package/dist/controllers/serverController.js.map +1 -1
- package/dist/services/mcpOAuthProvider.js +27 -1
- package/dist/services/mcpOAuthProvider.js.map +1 -1
- package/dist/services/mcpService.js +413 -48
- package/dist/services/mcpService.js.map +1 -1
- package/dist/utils/jsonSchemaValidator.js +26 -0
- package/dist/utils/jsonSchemaValidator.js.map +1 -0
- package/dist/utils/serialization.js +7 -1
- package/dist/utils/serialization.js.map +1 -1
- package/dist/utils/serverConfigPersistence.js +7 -0
- package/dist/utils/serverConfigPersistence.js.map +1 -1
- package/dist/utils/stdioDiagnostics.js +51 -0
- package/dist/utils/stdioDiagnostics.js.map +1 -0
- package/frontend/dist/assets/{ActivityPage-CobinJLb.js → ActivityPage-D0SFqsRe.js} +2 -2
- package/frontend/dist/assets/{ActivityPage-CobinJLb.js.map → ActivityPage-D0SFqsRe.js.map} +1 -1
- package/frontend/dist/assets/{Dashboard-eGc4eQi3.js → Dashboard-DzWkQnht.js} +2 -2
- package/frontend/dist/assets/{Dashboard-eGc4eQi3.js.map → Dashboard-DzWkQnht.js.map} +1 -1
- package/frontend/dist/assets/{EndpointCopy-B3Ki0wIh.js → EndpointCopy-Bqjt3lGe.js} +2 -2
- package/frontend/dist/assets/{EndpointCopy-B3Ki0wIh.js.map → EndpointCopy-Bqjt3lGe.js.map} +1 -1
- package/frontend/dist/assets/{GroupsPage-DwjX8q3N.js → GroupsPage-DsmZMSK0.js} +2 -2
- package/frontend/dist/assets/{GroupsPage-DwjX8q3N.js.map → GroupsPage-DsmZMSK0.js.map} +1 -1
- package/frontend/dist/assets/{LoginPage-BnBoYrJw.js → LoginPage-_Gr1ere-.js} +2 -2
- package/frontend/dist/assets/{LoginPage-BnBoYrJw.js.map → LoginPage-_Gr1ere-.js.map} +1 -1
- package/frontend/dist/assets/{LogsPage-DWCpx2XK.js → LogsPage-D2vByutC.js} +2 -2
- package/frontend/dist/assets/{LogsPage-DWCpx2XK.js.map → LogsPage-D2vByutC.js.map} +1 -1
- package/frontend/dist/assets/{MarketPage-DyCByUSu.js → MarketPage-BhyrxwBv.js} +2 -2
- package/frontend/dist/assets/{MarketPage-DyCByUSu.js.map → MarketPage-BhyrxwBv.js.map} +1 -1
- package/frontend/dist/assets/{PromptsPage-h-stDyZt.js → PromptsPage-BLn95Icp.js} +2 -2
- package/frontend/dist/assets/{PromptsPage-h-stDyZt.js.map → PromptsPage-BLn95Icp.js.map} +1 -1
- package/frontend/dist/assets/{ResourcesPage-DDqx0Mwe.js → ResourcesPage-B27GVOO6.js} +2 -2
- package/frontend/dist/assets/{ResourcesPage-DDqx0Mwe.js.map → ResourcesPage-B27GVOO6.js.map} +1 -1
- package/frontend/dist/assets/ServersPage-BuoWpLAq.js +39 -0
- package/frontend/dist/assets/ServersPage-BuoWpLAq.js.map +1 -0
- package/frontend/dist/assets/{SettingsPage-BOFDPIg2.js → SettingsPage-BzCwUvx7.js} +2 -2
- package/frontend/dist/assets/{SettingsPage-BOFDPIg2.js.map → SettingsPage-BzCwUvx7.js.map} +1 -1
- package/frontend/dist/assets/StatusDot-BUWJw3dA.js +2 -0
- package/frontend/dist/assets/StatusDot-BUWJw3dA.js.map +1 -0
- package/frontend/dist/assets/{ToggleGroup-BsTXYycl.js → ToggleGroup-BPAIOm5B.js} +2 -2
- package/frontend/dist/assets/{ToggleGroup-BsTXYycl.js.map → ToggleGroup-BPAIOm5B.js.map} +1 -1
- package/frontend/dist/assets/{UsersPage-DiGhqYGi.js → UsersPage-SZrL0bKd.js} +2 -2
- package/frontend/dist/assets/{UsersPage-DiGhqYGi.js.map → UsersPage-SZrL0bKd.js.map} +1 -1
- package/frontend/dist/assets/{contextCost-DbYdVAeZ.js → contextCost-BxT09q-Y.js} +2 -2
- package/frontend/dist/assets/{contextCost-DbYdVAeZ.js.map → contextCost-BxT09q-Y.js.map} +1 -1
- package/frontend/dist/assets/framework-vendor-I9Y6AgD5.js.map +1 -1
- package/frontend/dist/assets/index-Bz_euXvO.js +3 -0
- package/frontend/dist/assets/index-Bz_euXvO.js.map +1 -0
- package/frontend/dist/assets/index-DEAy4k6z.css +1 -0
- package/frontend/dist/assets/{resourceService-DmZBoHuX.js → resourceService-CWKSpa0p.js} +2 -2
- package/frontend/dist/assets/{resourceService-DmZBoHuX.js.map → resourceService-CWKSpa0p.js.map} +1 -1
- package/frontend/dist/assets/useSettingsData-0sbahDtL.js +2 -0
- package/frontend/dist/assets/{useSettingsData-pjp30ZrF.js.map → useSettingsData-0sbahDtL.js.map} +1 -1
- package/frontend/dist/assets/variableDetection-DfkBZ0gv.js +16 -0
- package/frontend/dist/assets/variableDetection-DfkBZ0gv.js.map +1 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +3 -3
- package/frontend/dist/assets/ServersPage-CAcLHWYC.js +0 -37
- package/frontend/dist/assets/ServersPage-CAcLHWYC.js.map +0 -1
- package/frontend/dist/assets/StatusDot-CMaJRs2P.js +0 -2
- package/frontend/dist/assets/StatusDot-CMaJRs2P.js.map +0 -1
- package/frontend/dist/assets/index-B0TJChIq.js +0 -3
- package/frontend/dist/assets/index-B0TJChIq.js.map +0 -1
- package/frontend/dist/assets/index-BfEBXfi0.css +0 -1
- package/frontend/dist/assets/useSettingsData-pjp30ZrF.js +0 -2
- package/frontend/dist/assets/variableDetection-CbAQkv1j.js +0 -16
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
979
|
-
|
|
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
|
|
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
|
-
|
|
1885
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
2107
|
-
|
|
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,
|
|
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
|
-
|
|
2255
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
2661
|
-
|
|
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 (
|
|
2664
|
-
|
|
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
|
-
|
|
2667
|
-
|
|
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
|