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