@modelcontextprotocol/inspector 2.2.0 → 2.4.0
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.md +274 -22
- package/clients/cli/build/index.js +11593 -9967
- package/clients/launcher/build/index.js +6 -4
- package/clients/tui/build/index.js +3774 -362
- package/clients/web/build/index.js +2224 -300
- package/clients/web/dist/assets/{index-CiUpWldH.css → index-DQVN73FG.css} +1 -1
- package/clients/web/dist/assets/index-JCsLrWVa.js +1484 -0
- package/clients/web/dist/assets/{prism-light-CWdjx3zH.js → prism-light-HLcnHJHw.js} +1 -1
- package/clients/web/dist/assets/worker-json-8CDXFfik.js +1631 -0
- package/clients/web/dist/index.html +2 -2
- package/clients/web/static/sandbox_proxy.html +122 -10
- package/package.json +10 -6
- package/clients/tui/build/chunk-MCKGQKYU.js +0 -15
- package/clients/tui/build/undici-HXPKCIY3.js +0 -25789
- package/clients/web/build/chunk-MCKGQKYU.js +0 -15
- package/clients/web/build/undici-HXPKCIY3.js +0 -25789
- package/clients/web/dist/assets/index-BrCIxo3Q.js +0 -242
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import "./chunk-MCKGQKYU.js";
|
|
3
2
|
|
|
4
3
|
// src/index.ts
|
|
5
|
-
import { resolve as
|
|
4
|
+
import { resolve as resolve8 } from "path";
|
|
6
5
|
import { fileURLToPath as fileURLToPath7 } from "url";
|
|
7
6
|
|
|
8
7
|
// server/run-web.ts
|
|
9
|
-
import { resolve as
|
|
8
|
+
import { resolve as resolve7, join as join10, dirname as dirname9 } from "path";
|
|
10
9
|
import { fileURLToPath as fileURLToPath6 } from "url";
|
|
11
10
|
import { Command } from "commander";
|
|
12
11
|
|
|
@@ -116,6 +115,22 @@ var SECRET_FIELD_IDP_CLIENT_SECRET = "idp-client-secret";
|
|
|
116
115
|
var envSecretField = (envKey) => `env:${envKey}`;
|
|
117
116
|
|
|
118
117
|
// ../../core/json/jsonUtils.ts
|
|
118
|
+
function isSerializableJson(value) {
|
|
119
|
+
if (value === null) return true;
|
|
120
|
+
switch (typeof value) {
|
|
121
|
+
case "string":
|
|
122
|
+
case "boolean":
|
|
123
|
+
return true;
|
|
124
|
+
case "number":
|
|
125
|
+
return Number.isFinite(value);
|
|
126
|
+
case "object":
|
|
127
|
+
return Array.isArray(value) ? value.every(isSerializableJson) : Object.values(value).every(
|
|
128
|
+
isSerializableJson
|
|
129
|
+
);
|
|
130
|
+
default:
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
119
134
|
function toRecord(value) {
|
|
120
135
|
return value;
|
|
121
136
|
}
|
|
@@ -180,6 +195,100 @@ function envPairsToRecord(pairs) {
|
|
|
180
195
|
}
|
|
181
196
|
return out;
|
|
182
197
|
}
|
|
198
|
+
function stdioConfigFieldsFromSettings(settings) {
|
|
199
|
+
const env = envPairsToRecord(settings.env);
|
|
200
|
+
const cwd = settings.cwd?.trim();
|
|
201
|
+
return {
|
|
202
|
+
env: Object.keys(env).length > 0 ? env : void 0,
|
|
203
|
+
cwd: cwd ? cwd : void 0
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function normalizeStoredMetadata(metadata) {
|
|
207
|
+
if (metadata === void 0 || metadata === null) return {};
|
|
208
|
+
if (Array.isArray(metadata)) {
|
|
209
|
+
const out = {};
|
|
210
|
+
for (const entry of metadata) {
|
|
211
|
+
if (typeof entry !== "object" || entry === null || typeof entry.key !== "string") {
|
|
212
|
+
console.warn(
|
|
213
|
+
"Ignoring malformed legacy metadata entry (expected `{ key, value }`)."
|
|
214
|
+
);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const { key, value } = entry;
|
|
218
|
+
if (key.trim() === "") continue;
|
|
219
|
+
const resolved2 = value === void 0 ? "" : value;
|
|
220
|
+
if (!keepSerializable(key, resolved2)) continue;
|
|
221
|
+
defineMetadataKey(out, key, resolved2);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
if (typeof metadata === "object") {
|
|
226
|
+
const out = {};
|
|
227
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
228
|
+
if (keepSerializable(key, value)) defineMetadataKey(out, key, value);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
console.warn(
|
|
233
|
+
`Ignoring malformed \`metadata\` (expected a JSON object, got ${describeJsonType(metadata)}).`
|
|
234
|
+
);
|
|
235
|
+
return {};
|
|
236
|
+
}
|
|
237
|
+
function defineMetadataKey(out, key, value) {
|
|
238
|
+
Object.defineProperty(out, key, {
|
|
239
|
+
value,
|
|
240
|
+
writable: true,
|
|
241
|
+
enumerable: true,
|
|
242
|
+
configurable: true
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function keepSerializable(key, value) {
|
|
246
|
+
if (isSerializableJson(value)) return true;
|
|
247
|
+
console.warn(
|
|
248
|
+
`Ignoring metadata key "${key}" \u2014 its value is not JSON that can be sent (got ${describeJsonType(value)}; note \`1e400\` parses to Infinity).`
|
|
249
|
+
);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
function describeJsonType(value) {
|
|
253
|
+
if (value === null) return "null";
|
|
254
|
+
if (Array.isArray(value)) return "array";
|
|
255
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
256
|
+
return Number.isNaN(value) ? "NaN" : "a non-finite number";
|
|
257
|
+
}
|
|
258
|
+
return typeof value;
|
|
259
|
+
}
|
|
260
|
+
function cleanAuthorizationParams(params) {
|
|
261
|
+
if (params === void 0) return void 0;
|
|
262
|
+
if (typeof params !== "object" || params === null || Array.isArray(params)) {
|
|
263
|
+
console.warn(
|
|
264
|
+
"Ignoring `oauth.authorizationParams`: expected an object of string values, got",
|
|
265
|
+
Array.isArray(params) ? "array" : typeof params
|
|
266
|
+
);
|
|
267
|
+
return void 0;
|
|
268
|
+
}
|
|
269
|
+
const out = {};
|
|
270
|
+
for (const [key, value] of Object.entries(params)) {
|
|
271
|
+
if (typeof value !== "string") {
|
|
272
|
+
console.warn(
|
|
273
|
+
`Dropping \`oauth.authorizationParams.${key}\`: expected a string value, got ${typeof value}.`
|
|
274
|
+
);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (key.trim() === "") continue;
|
|
278
|
+
out[key] = value;
|
|
279
|
+
}
|
|
280
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
281
|
+
}
|
|
282
|
+
function cleanEndpointOverride(value, field) {
|
|
283
|
+
if (value === void 0) return void 0;
|
|
284
|
+
if (typeof value !== "string") {
|
|
285
|
+
console.warn(
|
|
286
|
+
`Ignoring \`oauth.${field}\`: expected a string, got ${typeof value}.`
|
|
287
|
+
);
|
|
288
|
+
return void 0;
|
|
289
|
+
}
|
|
290
|
+
return value.trim() || void 0;
|
|
291
|
+
}
|
|
183
292
|
function storedFieldsToInspectorSettings(stored) {
|
|
184
293
|
const hasAny = stored.headers !== void 0 || stored.metadata !== void 0 || stored.connectionTimeout !== void 0 || stored.requestTimeout !== void 0 || stored.taskTtl !== void 0 || stored.autoRefreshOnListChanged !== void 0 || stored.paginatedLists !== void 0 || stored.advertisedExtensions !== void 0 || stored.maxFetchRequests !== void 0 || stored.oauth !== void 0 || stored.roots !== void 0 || stored.protocolEra !== void 0 || stored.modernLogLevel !== void 0 || stored.env !== void 0 || stored.cwd !== void 0;
|
|
185
294
|
if (!hasAny) return void 0;
|
|
@@ -187,7 +296,7 @@ function storedFieldsToInspectorSettings(stored) {
|
|
|
187
296
|
const settings = {
|
|
188
297
|
headers: headersPairs,
|
|
189
298
|
env: envRecordToPairs(stored.env),
|
|
190
|
-
metadata: stored.metadata
|
|
299
|
+
metadata: normalizeStoredMetadata(stored.metadata),
|
|
191
300
|
connectionTimeout: stored.connectionTimeout ?? 0,
|
|
192
301
|
requestTimeout: stored.requestTimeout ?? 0,
|
|
193
302
|
// Unlike the timeouts (0 = "SDK default"), task TTL has a concrete product
|
|
@@ -217,12 +326,28 @@ function storedFieldsToInspectorSettings(stored) {
|
|
|
217
326
|
if (stored.oauth?.clientSecret)
|
|
218
327
|
settings.oauthClientSecret = stored.oauth.clientSecret;
|
|
219
328
|
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
|
|
329
|
+
const authorizationParams = cleanAuthorizationParams(
|
|
330
|
+
stored.oauth?.authorizationParams
|
|
331
|
+
);
|
|
332
|
+
if (authorizationParams) {
|
|
333
|
+
settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams);
|
|
334
|
+
}
|
|
335
|
+
const authorizationUrl = cleanEndpointOverride(
|
|
336
|
+
stored.oauth?.authorizationUrl,
|
|
337
|
+
"authorizationUrl"
|
|
338
|
+
);
|
|
339
|
+
if (authorizationUrl) settings.oauthAuthorizationUrl = authorizationUrl;
|
|
340
|
+
const tokenUrl = cleanEndpointOverride(stored.oauth?.tokenUrl, "tokenUrl");
|
|
341
|
+
if (tokenUrl) settings.oauthTokenUrl = tokenUrl;
|
|
220
342
|
if (stored.oauth?.onInsufficientScope) {
|
|
221
343
|
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
|
|
222
344
|
}
|
|
223
345
|
if (stored.oauth?.enterpriseManaged === true) {
|
|
224
346
|
settings.enterpriseManaged = true;
|
|
225
347
|
}
|
|
348
|
+
if (stored.oauth?.requestRefreshToken === false) {
|
|
349
|
+
settings.oauthRequestRefreshToken = false;
|
|
350
|
+
}
|
|
226
351
|
if (stored.cwd) settings.cwd = stored.cwd;
|
|
227
352
|
return settings;
|
|
228
353
|
}
|
|
@@ -236,9 +361,8 @@ function inspectorSettingsToStoredFields(settings) {
|
|
|
236
361
|
if (Object.keys(headersRecord).length > 0) {
|
|
237
362
|
out.headers = headersRecord;
|
|
238
363
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
out.metadata = metadataFiltered;
|
|
364
|
+
if (Object.keys(settings.metadata).length > 0) {
|
|
365
|
+
out.metadata = settings.metadata;
|
|
242
366
|
}
|
|
243
367
|
if (settings.connectionTimeout > 0) {
|
|
244
368
|
out.connectionTimeout = settings.connectionTimeout;
|
|
@@ -272,12 +396,27 @@ function inspectorSettingsToStoredFields(settings) {
|
|
|
272
396
|
if (settings.oauthClientSecret)
|
|
273
397
|
oauthFields.clientSecret = settings.oauthClientSecret;
|
|
274
398
|
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
|
|
399
|
+
if (settings.oauthAuthorizationParams) {
|
|
400
|
+
const authParams = envPairsToRecord(settings.oauthAuthorizationParams);
|
|
401
|
+
if (Object.keys(authParams).length > 0) {
|
|
402
|
+
oauthFields.authorizationParams = authParams;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (settings.oauthAuthorizationUrl?.trim()) {
|
|
406
|
+
oauthFields.authorizationUrl = settings.oauthAuthorizationUrl.trim();
|
|
407
|
+
}
|
|
408
|
+
if (settings.oauthTokenUrl?.trim()) {
|
|
409
|
+
oauthFields.tokenUrl = settings.oauthTokenUrl.trim();
|
|
410
|
+
}
|
|
275
411
|
if (settings.oauthOnInsufficientScope) {
|
|
276
412
|
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
|
|
277
413
|
}
|
|
278
414
|
if (settings.enterpriseManaged === true) {
|
|
279
415
|
oauthFields.enterpriseManaged = true;
|
|
280
416
|
}
|
|
417
|
+
if (settings.oauthRequestRefreshToken === false) {
|
|
418
|
+
oauthFields.requestRefreshToken = false;
|
|
419
|
+
}
|
|
281
420
|
if (Object.keys(oauthFields).length > 0) {
|
|
282
421
|
out.oauth = oauthFields;
|
|
283
422
|
}
|
|
@@ -318,14 +457,7 @@ function extractSecretsFromStored(stored) {
|
|
|
318
457
|
const stripped = { ...stored };
|
|
319
458
|
if (stored.oauth?.clientSecret) {
|
|
320
459
|
secrets[SECRET_FIELD_OAUTH_CLIENT_SECRET] = stored.oauth.clientSecret;
|
|
321
|
-
const restOauth =
|
|
322
|
-
if (stored.oauth.clientId !== void 0)
|
|
323
|
-
restOauth.clientId = stored.oauth.clientId;
|
|
324
|
-
if (stored.oauth.scopes !== void 0)
|
|
325
|
-
restOauth.scopes = stored.oauth.scopes;
|
|
326
|
-
if (stored.oauth.enterpriseManaged === true) {
|
|
327
|
-
restOauth.enterpriseManaged = true;
|
|
328
|
-
}
|
|
460
|
+
const { clientSecret: _clientSecret, ...restOauth } = stored.oauth;
|
|
329
461
|
if (Object.keys(restOauth).length > 0) {
|
|
330
462
|
stripped.oauth = restOauth;
|
|
331
463
|
} else {
|
|
@@ -608,6 +740,7 @@ function resolveServerConfigs(options, mode) {
|
|
|
608
740
|
}
|
|
609
741
|
|
|
610
742
|
// server/web-server-config.ts
|
|
743
|
+
import open from "open";
|
|
611
744
|
import pino from "pino";
|
|
612
745
|
|
|
613
746
|
// ../../core/mcp/remote/constants.ts
|
|
@@ -621,6 +754,49 @@ var API_SERVER_ENV_VARS = {
|
|
|
621
754
|
};
|
|
622
755
|
var INSPECTOR_API_TOKEN_GLOBAL = "__INSPECTOR_API_TOKEN__";
|
|
623
756
|
|
|
757
|
+
// ../../core/auth/secret-storage-info.ts
|
|
758
|
+
function secretStorageLabel(info) {
|
|
759
|
+
switch (info.kind) {
|
|
760
|
+
case "keyring":
|
|
761
|
+
return "OS keychain";
|
|
762
|
+
case "file":
|
|
763
|
+
if (info.encryptionUnknown !== void 0) return "File (unreadable)";
|
|
764
|
+
return info.plaintext ? "File (unencrypted)" : "File (encrypted)";
|
|
765
|
+
case "memory":
|
|
766
|
+
return "Memory (this session only)";
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
function secretStorageCaveat(info) {
|
|
770
|
+
if (info.kind === "memory") {
|
|
771
|
+
return "Secrets are not written anywhere and are lost on exit.";
|
|
772
|
+
}
|
|
773
|
+
if (info.looseMode !== void 0) {
|
|
774
|
+
const mode = info.looseMode.toString(8).padStart(4, "0");
|
|
775
|
+
if (info.encryptionUnknown !== void 0) {
|
|
776
|
+
return `The secrets file is mode ${mode}, not 0600, and could not be tightened \u2014 others can copy it, and whether that exposes the secrets depends on encryption this build could not determine (${info.encryptionUnknown}).`;
|
|
777
|
+
}
|
|
778
|
+
return info.plaintext === false ? `The secrets file is mode ${mode}, not 0600, and could not be tightened \u2014 others can copy it, and the passphrase is then the only thing protecting its contents.` : `The secrets file is mode ${mode}, not 0600, and could not be tightened \u2014 anyone who can read it can read the secrets in it.`;
|
|
779
|
+
}
|
|
780
|
+
if (info.permissionsUnknown !== void 0) {
|
|
781
|
+
return `The secrets file's permissions could not be checked (${info.permissionsUnknown}), so it is not known whether others can read it.`;
|
|
782
|
+
}
|
|
783
|
+
if (info.encryptionUnknown !== void 0) {
|
|
784
|
+
return `The secrets file could not be read (${info.encryptionUnknown}). Saving a secret will fail rather than overwrite it.`;
|
|
785
|
+
}
|
|
786
|
+
if (info.plaintext) {
|
|
787
|
+
return info.pendingEncryption ? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved." : "Secrets are stored unencrypted (file mode 0600). Set MCP_INSPECTOR_SECRET_KEY to encrypt them.";
|
|
788
|
+
}
|
|
789
|
+
return void 0;
|
|
790
|
+
}
|
|
791
|
+
function secretStorageSummary(info) {
|
|
792
|
+
const parts = [secretStorageLabel(info)];
|
|
793
|
+
if (info.path) parts.push(`at ${info.path}`);
|
|
794
|
+
if (info.reason === "fallback") {
|
|
795
|
+
parts.push("\u2014 fell back from the OS keychain");
|
|
796
|
+
}
|
|
797
|
+
return parts.join(" ");
|
|
798
|
+
}
|
|
799
|
+
|
|
624
800
|
// ../../core/node/version.ts
|
|
625
801
|
import { readFileSync as readFileSync2 } from "fs";
|
|
626
802
|
import { dirname as dirname3, join as join2, parse } from "path";
|
|
@@ -710,12 +886,34 @@ function isAllInterfacesHost(host) {
|
|
|
710
886
|
return ALL_INTERFACES_LITERALS.has(normalized) || isAllZeroIpv4(normalized);
|
|
711
887
|
}
|
|
712
888
|
|
|
889
|
+
// server/resolve-bind-host.ts
|
|
890
|
+
var BIND_ALL_INTERFACES_ENV = "DANGEROUSLY_BIND_ALL_INTERFACES";
|
|
891
|
+
function isEnabled(value) {
|
|
892
|
+
const v = value?.trim().toLowerCase();
|
|
893
|
+
return v === "true" || v === "1";
|
|
894
|
+
}
|
|
895
|
+
var DEFAULT_BIND_HOST = "127.0.0.1";
|
|
896
|
+
function resolveBindHostname(env = process.env) {
|
|
897
|
+
const host = (env.HOST ?? DEFAULT_BIND_HOST).trim();
|
|
898
|
+
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
|
|
899
|
+
const resolved2 = canonicalUrlHost(host);
|
|
900
|
+
const shown = stripBrackets(resolved2) === stripBrackets(host.toLowerCase()) ? `HOST="${host}"` : `HOST="${host}" (resolves to ${resolved2})`;
|
|
901
|
+
throw new Error(
|
|
902
|
+
`Refusing to bind ${shown}: this exposes the MCP Inspector to your entire network, and its backend can spawn local processes and connect to MCP servers on your behalf \u2014 the exposure DNS-rebinding attacks target. Bind a loopback host (localhost / 127.0.0.1) instead. To override \u2014 only inside an isolated container or trusted network \u2014 set ${BIND_ALL_INTERFACES_ENV}=true.`
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
return stripBrackets(host);
|
|
906
|
+
}
|
|
907
|
+
|
|
713
908
|
// server/sandbox-controller.ts
|
|
714
909
|
var __dirname = dirname4(fileURLToPath2(import.meta.url));
|
|
715
910
|
var LOOPBACK_FRAME_ANCESTORS = ["http://127.0.0.1:*", "http://localhost:*"];
|
|
716
911
|
var CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]*]+$/i;
|
|
717
912
|
function sandboxFrameAncestors(allowedOrigins) {
|
|
718
|
-
|
|
913
|
+
return frameAncestorsDirective(allowedOrigins);
|
|
914
|
+
}
|
|
915
|
+
function frameAncestorsDirective(origins) {
|
|
916
|
+
const valid = (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
|
719
917
|
const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS;
|
|
720
918
|
return `frame-ancestors ${sources.join(" ")}`;
|
|
721
919
|
}
|
|
@@ -725,6 +923,7 @@ function parseListenPort(raw) {
|
|
|
725
923
|
const n = parseInt(v, 10);
|
|
726
924
|
return n <= 65535 ? n : void 0;
|
|
727
925
|
}
|
|
926
|
+
var DEFAULT_SANDBOX_PORT = 6275;
|
|
728
927
|
function resolveSandboxPort() {
|
|
729
928
|
const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT);
|
|
730
929
|
if (fromSandbox === void 0 && process.env.MCP_SANDBOX_PORT?.trim()) {
|
|
@@ -732,10 +931,10 @@ function resolveSandboxPort() {
|
|
|
732
931
|
`Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0\u201365535); falling back.`
|
|
733
932
|
);
|
|
734
933
|
}
|
|
735
|
-
return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ??
|
|
934
|
+
return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? DEFAULT_SANDBOX_PORT;
|
|
736
935
|
}
|
|
737
936
|
function createSandboxController(options) {
|
|
738
|
-
const { port, host =
|
|
937
|
+
const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options;
|
|
739
938
|
let server = null;
|
|
740
939
|
let sandboxUrl = null;
|
|
741
940
|
const SANDBOX_PROXY_CSP = sandboxFrameAncestors(allowedOrigins);
|
|
@@ -752,12 +951,12 @@ function createSandboxController(options) {
|
|
|
752
951
|
const p = parseInt(new URL(sandboxUrl).port, 10);
|
|
753
952
|
return { port: p, url: sandboxUrl };
|
|
754
953
|
}
|
|
755
|
-
return new Promise((
|
|
954
|
+
return new Promise((resolve9) => {
|
|
756
955
|
let settled = false;
|
|
757
956
|
const settle = (value) => {
|
|
758
957
|
if (settled) return;
|
|
759
958
|
settled = true;
|
|
760
|
-
|
|
959
|
+
resolve9(value);
|
|
761
960
|
};
|
|
762
961
|
server = createServer((req, res) => {
|
|
763
962
|
if (req.method !== "GET" || req.url !== "/sandbox" && req.url !== "/sandbox/") {
|
|
@@ -773,8 +972,17 @@ function createSandboxController(options) {
|
|
|
773
972
|
});
|
|
774
973
|
res.end(sandboxHtml);
|
|
775
974
|
});
|
|
975
|
+
let retriedDynamic = false;
|
|
776
976
|
server.on("error", (err) => {
|
|
777
977
|
if (err.code === "EADDRINUSE") {
|
|
978
|
+
if (!retriedDynamic && port !== 0) {
|
|
979
|
+
retriedDynamic = true;
|
|
980
|
+
console.warn(
|
|
981
|
+
`Sandbox: port ${port} in use; falling back to an OS-assigned port. The MCP Apps tab will work locally, but the sandbox is no longer on a predictable port \u2014 set MCP_SANDBOX_PORT to a free one if you need to forward it (container / SSH tunnel / reverse proxy).`
|
|
982
|
+
);
|
|
983
|
+
server.listen(0, host);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
778
986
|
console.error(
|
|
779
987
|
`Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`
|
|
780
988
|
);
|
|
@@ -802,11 +1010,11 @@ function createSandboxController(options) {
|
|
|
802
1010
|
},
|
|
803
1011
|
async close() {
|
|
804
1012
|
if (!server) return;
|
|
805
|
-
return new Promise((
|
|
1013
|
+
return new Promise((resolve9) => {
|
|
806
1014
|
server.close(() => {
|
|
807
1015
|
server = null;
|
|
808
1016
|
sandboxUrl = null;
|
|
809
|
-
|
|
1017
|
+
resolve9();
|
|
810
1018
|
});
|
|
811
1019
|
});
|
|
812
1020
|
},
|
|
@@ -816,22 +1024,167 @@ function createSandboxController(options) {
|
|
|
816
1024
|
};
|
|
817
1025
|
}
|
|
818
1026
|
|
|
819
|
-
// server/
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
1027
|
+
// server/app-origin-controller.ts
|
|
1028
|
+
import { createServer as createServer2 } from "http";
|
|
1029
|
+
import { randomBytes } from "crypto";
|
|
1030
|
+
var DEFAULT_APP_ORIGIN_PORT = 6278;
|
|
1031
|
+
var MAX_DOCUMENTS = 32;
|
|
1032
|
+
var MAX_RETAINED_CHARS = 64 * 1024 * 1024;
|
|
1033
|
+
var DOCUMENT_TTL_MS = 60 * 60 * 1e3;
|
|
1034
|
+
function appDocumentEmbedders(sandboxUrl, allowedOrigins) {
|
|
1035
|
+
const origins = [];
|
|
1036
|
+
if (sandboxUrl) {
|
|
1037
|
+
try {
|
|
1038
|
+
origins.push(new URL(sandboxUrl).origin);
|
|
1039
|
+
} catch {
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
origins.push(...allowedOrigins ?? []);
|
|
1043
|
+
return origins.length > 0 ? origins : void 0;
|
|
824
1044
|
}
|
|
825
|
-
function
|
|
826
|
-
const
|
|
827
|
-
if (
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1045
|
+
function parseListenPort2(raw) {
|
|
1046
|
+
const v = raw?.trim();
|
|
1047
|
+
if (!v || !/^\d+$/.test(v)) return void 0;
|
|
1048
|
+
const n = parseInt(v, 10);
|
|
1049
|
+
return n <= 65535 ? n : void 0;
|
|
1050
|
+
}
|
|
1051
|
+
function resolveAppOriginPort() {
|
|
1052
|
+
const parsed = parseListenPort2(process.env.MCP_APP_ORIGIN_PORT);
|
|
1053
|
+
if (parsed === void 0 && process.env.MCP_APP_ORIGIN_PORT?.trim()) {
|
|
1054
|
+
console.warn(
|
|
1055
|
+
`Ignoring invalid MCP_APP_ORIGIN_PORT="${process.env.MCP_APP_ORIGIN_PORT}" (need an integer 0\u201365535); falling back.`
|
|
832
1056
|
);
|
|
833
1057
|
}
|
|
834
|
-
return
|
|
1058
|
+
return parsed ?? DEFAULT_APP_ORIGIN_PORT;
|
|
1059
|
+
}
|
|
1060
|
+
var DOC_PATH_PREFIX = "/app-document/";
|
|
1061
|
+
function parseDocumentId(url) {
|
|
1062
|
+
if (!url || !url.startsWith(DOC_PATH_PREFIX)) return null;
|
|
1063
|
+
const id = url.slice(DOC_PATH_PREFIX.length).split(/[?#]/)[0];
|
|
1064
|
+
return /^[0-9a-f]{32}$/.test(id ?? "") ? id ?? null : null;
|
|
1065
|
+
}
|
|
1066
|
+
function createAppOriginController(options) {
|
|
1067
|
+
const { port, host = DEFAULT_BIND_HOST, embedderOrigins } = options;
|
|
1068
|
+
let server = null;
|
|
1069
|
+
let origin = null;
|
|
1070
|
+
const documents = /* @__PURE__ */ new Map();
|
|
1071
|
+
let retainedChars = 0;
|
|
1072
|
+
function drop(id) {
|
|
1073
|
+
const doc = documents.get(id);
|
|
1074
|
+
if (!doc) return;
|
|
1075
|
+
retainedChars -= doc.size;
|
|
1076
|
+
documents.delete(id);
|
|
1077
|
+
}
|
|
1078
|
+
const FRAME_ANCESTORS = frameAncestorsDirective(embedderOrigins);
|
|
1079
|
+
function evictExpired(now) {
|
|
1080
|
+
for (const [id, doc] of documents) {
|
|
1081
|
+
if (doc.expiresAt <= now) drop(id);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
async start() {
|
|
1086
|
+
if (server && origin) {
|
|
1087
|
+
return { port: parseInt(new URL(origin).port, 10), url: origin };
|
|
1088
|
+
}
|
|
1089
|
+
return new Promise((resolve9) => {
|
|
1090
|
+
let settled = false;
|
|
1091
|
+
const settle = (value) => {
|
|
1092
|
+
if (settled) return;
|
|
1093
|
+
settled = true;
|
|
1094
|
+
resolve9(value);
|
|
1095
|
+
};
|
|
1096
|
+
server = createServer2((req, res) => {
|
|
1097
|
+
const id = req.method === "GET" ? parseDocumentId(req.url) : null;
|
|
1098
|
+
const doc = id ? documents.get(id) : void 0;
|
|
1099
|
+
if (!doc || doc.expiresAt <= Date.now()) {
|
|
1100
|
+
if (id) drop(id);
|
|
1101
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1102
|
+
res.end("Not Found");
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
res.writeHead(200, {
|
|
1106
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1107
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
1108
|
+
Pragma: "no-cache",
|
|
1109
|
+
// `nosniff` matters more here than on a page we authored: these
|
|
1110
|
+
// bytes are server-supplied and untrusted.
|
|
1111
|
+
"X-Content-Type-Options": "nosniff",
|
|
1112
|
+
// The per-app policy as a real header. It is the same policy baked
|
|
1113
|
+
// into the document's <meta>; delivering both is deliberate —
|
|
1114
|
+
// multiple policies intersect, and two copies of one policy
|
|
1115
|
+
// intersect to itself, so this can only ever be at least as strict.
|
|
1116
|
+
"Content-Security-Policy": doc.csp ? `${doc.csp}; ${FRAME_ANCESTORS}` : FRAME_ANCESTORS
|
|
1117
|
+
});
|
|
1118
|
+
res.end(doc.html);
|
|
1119
|
+
});
|
|
1120
|
+
let retriedDynamic = false;
|
|
1121
|
+
server.on("error", (err) => {
|
|
1122
|
+
if (err.code === "EADDRINUSE") {
|
|
1123
|
+
if (!retriedDynamic && port !== 0) {
|
|
1124
|
+
retriedDynamic = true;
|
|
1125
|
+
console.warn(
|
|
1126
|
+
`App origin: port ${port} in use; falling back to an OS-assigned port. Apps declaring _meta.ui.domain will still render, but the origin they are served from is no longer predictable \u2014 set MCP_APP_ORIGIN_PORT to a free one if your app's backend allowlists it.`
|
|
1127
|
+
);
|
|
1128
|
+
server.listen(0, host);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
console.error(
|
|
1132
|
+
`App origin: port ${port || "dynamic"} in use. Apps declaring _meta.ui.domain will fall back to an opaque origin.`
|
|
1133
|
+
);
|
|
1134
|
+
} else {
|
|
1135
|
+
console.error("App origin server error:", err);
|
|
1136
|
+
}
|
|
1137
|
+
server = null;
|
|
1138
|
+
origin = null;
|
|
1139
|
+
documents.clear();
|
|
1140
|
+
retainedChars = 0;
|
|
1141
|
+
settle({ port: 0, url: "" });
|
|
1142
|
+
});
|
|
1143
|
+
server.listen(port, host, () => {
|
|
1144
|
+
const addr = server.address();
|
|
1145
|
+
if (typeof addr !== "object" || addr === null) {
|
|
1146
|
+
settle({ port: 0, url: "" });
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
const actualPort = addr.port;
|
|
1150
|
+
const canonicalHost = canonicalUrlHost(host);
|
|
1151
|
+
const urlHost = isAllInterfacesHost(canonicalHost) ? "localhost" : canonicalHost;
|
|
1152
|
+
origin = `http://${urlHost}:${actualPort}`;
|
|
1153
|
+
settle({ port: actualPort, url: origin });
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
},
|
|
1157
|
+
async close() {
|
|
1158
|
+
documents.clear();
|
|
1159
|
+
retainedChars = 0;
|
|
1160
|
+
if (!server) return;
|
|
1161
|
+
return new Promise((resolve9) => {
|
|
1162
|
+
server.close(() => {
|
|
1163
|
+
server = null;
|
|
1164
|
+
origin = null;
|
|
1165
|
+
resolve9();
|
|
1166
|
+
});
|
|
1167
|
+
});
|
|
1168
|
+
},
|
|
1169
|
+
getOrigin() {
|
|
1170
|
+
return origin;
|
|
1171
|
+
},
|
|
1172
|
+
publish(doc) {
|
|
1173
|
+
if (!origin) return null;
|
|
1174
|
+
const now = Date.now();
|
|
1175
|
+
evictExpired(now);
|
|
1176
|
+
const size = doc.html.length + (doc.csp?.length ?? 0);
|
|
1177
|
+
while (documents.size >= MAX_DOCUMENTS || documents.size > 0 && retainedChars + size > MAX_RETAINED_CHARS) {
|
|
1178
|
+
const oldest = documents.keys().next();
|
|
1179
|
+
if (oldest.done) break;
|
|
1180
|
+
drop(oldest.value);
|
|
1181
|
+
}
|
|
1182
|
+
const id = randomBytes(16).toString("hex");
|
|
1183
|
+
documents.set(id, { ...doc, expiresAt: now + DOCUMENT_TTL_MS, size });
|
|
1184
|
+
retainedChars += size;
|
|
1185
|
+
return { url: `${origin}${DOC_PATH_PREFIX}${id}` };
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
835
1188
|
}
|
|
836
1189
|
|
|
837
1190
|
// server/web-server-config.ts
|
|
@@ -863,8 +1216,16 @@ function defaultEnvironmentFromProcess(extra) {
|
|
|
863
1216
|
}
|
|
864
1217
|
return out;
|
|
865
1218
|
}
|
|
866
|
-
function webServerConfigToInitialPayload(config) {
|
|
867
|
-
return {
|
|
1219
|
+
function webServerConfigToInitialPayload(config, secretStorage) {
|
|
1220
|
+
return {
|
|
1221
|
+
...transportDefaults(config),
|
|
1222
|
+
version: inspectorVersion,
|
|
1223
|
+
// Omitted rather than defaulted when the caller didn't resolve a store:
|
|
1224
|
+
// the UI treats an absent descriptor as "unknown" and shows nothing,
|
|
1225
|
+
// which is the only honest rendering. A default of "keychain" would put a
|
|
1226
|
+
// confident wrong answer under the field where a secret is typed.
|
|
1227
|
+
...secretStorage ? { secretStorage } : {}
|
|
1228
|
+
};
|
|
868
1229
|
}
|
|
869
1230
|
function transportDefaults(config) {
|
|
870
1231
|
const mc = config.initialMcpConfig;
|
|
@@ -904,7 +1265,7 @@ function transportDefaults(config) {
|
|
|
904
1265
|
defaultEnvironment
|
|
905
1266
|
};
|
|
906
1267
|
}
|
|
907
|
-
function printServerBanner(config, actualPort, resolvedToken, sandboxUrl) {
|
|
1268
|
+
function printServerBanner(config, actualPort, resolvedToken, sandboxUrl, secretStorage) {
|
|
908
1269
|
const h = canonicalUrlHost(config.hostname);
|
|
909
1270
|
const bannerHost = isAllInterfacesHost(h) ? "localhost" : h;
|
|
910
1271
|
const baseUrl = httpOrigin(bannerHost, actualPort);
|
|
@@ -921,6 +1282,10 @@ MCP Inspector Web is up and running at:
|
|
|
921
1282
|
console.log(" Auth: disabled (DANGEROUSLY_OMIT_AUTH)\n");
|
|
922
1283
|
} else {
|
|
923
1284
|
console.log(` Auth token: ${resolvedToken}
|
|
1285
|
+
`);
|
|
1286
|
+
}
|
|
1287
|
+
if (secretStorage) {
|
|
1288
|
+
console.log(` Secrets: ${secretStorageSummary(secretStorage)}
|
|
924
1289
|
`);
|
|
925
1290
|
}
|
|
926
1291
|
if (config.autoOpen) {
|
|
@@ -928,6 +1293,11 @@ MCP Inspector Web is up and running at:
|
|
|
928
1293
|
}
|
|
929
1294
|
return url;
|
|
930
1295
|
}
|
|
1296
|
+
function openBrowser(url) {
|
|
1297
|
+
open(url).catch((err) => {
|
|
1298
|
+
console.warn("Could not open a browser automatically:", err);
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
931
1301
|
var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
932
1302
|
function httpOrigin(host, port) {
|
|
933
1303
|
return port === 80 ? `http://${host}` : `http://${host}:${port}`;
|
|
@@ -971,6 +1341,14 @@ function buildWebServerConfig(options = {}) {
|
|
|
971
1341
|
const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH;
|
|
972
1342
|
const authToken = dangerouslyOmitAuth ? "" : process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] ?? process.env[LEGACY_AUTH_TOKEN_ENV] ?? "";
|
|
973
1343
|
const sandboxPort = resolveSandboxPort();
|
|
1344
|
+
const requestedAppOriginPort = resolveAppOriginPort();
|
|
1345
|
+
const appOriginCollidesWith = requestedAppOriginPort !== 0 && (requestedAppOriginPort === port ? "the web server (CLIENT_PORT)" : requestedAppOriginPort === sandboxPort ? "the MCP Apps sandbox (MCP_SANDBOX_PORT)" : null);
|
|
1346
|
+
if (appOriginCollidesWith) {
|
|
1347
|
+
console.warn(
|
|
1348
|
+
`App origin: port ${requestedAppOriginPort} is already ${appOriginCollidesWith}; using an OS-assigned port instead. Apps declaring _meta.ui.domain will still render, but the origin they are served from is no longer predictable \u2014 set MCP_APP_ORIGIN_PORT to a free one if your app's backend allowlists it.`
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
const appOriginPort = appOriginCollidesWith ? 0 : requestedAppOriginPort;
|
|
974
1352
|
let logger;
|
|
975
1353
|
if (process.env.MCP_LOG_FILE) {
|
|
976
1354
|
logger = pino(
|
|
@@ -1006,6 +1384,7 @@ function buildWebServerConfig(options = {}) {
|
|
|
1006
1384
|
allowedOrigins: configuredOrigins?.length ? configuredOrigins : defaultAllowedOrigins(hostname, port),
|
|
1007
1385
|
sandboxPort,
|
|
1008
1386
|
sandboxHost: hostname,
|
|
1387
|
+
appOriginPort,
|
|
1009
1388
|
logger,
|
|
1010
1389
|
autoOpen: resolveAutoOpen()
|
|
1011
1390
|
};
|
|
@@ -1018,21 +1397,19 @@ function resolveAutoOpen() {
|
|
|
1018
1397
|
}
|
|
1019
1398
|
|
|
1020
1399
|
// server/start-vite-dev-server.ts
|
|
1021
|
-
import { join as
|
|
1400
|
+
import { join as join7, dirname as dirname7, resolve as resolve6 } from "path";
|
|
1022
1401
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
1023
|
-
import { createServer as
|
|
1402
|
+
import { createServer as createServer3 } from "vite";
|
|
1024
1403
|
import react from "@vitejs/plugin-react";
|
|
1025
1404
|
|
|
1026
|
-
// server/vite-hono-plugin.ts
|
|
1027
|
-
import open from "open";
|
|
1028
|
-
|
|
1029
1405
|
// ../../core/mcp/remote/node/server.ts
|
|
1030
|
-
import { randomBytes, timingSafeEqual } from "crypto";
|
|
1031
|
-
import { existsSync as
|
|
1406
|
+
import { randomBytes as randomBytes3, timingSafeEqual } from "crypto";
|
|
1407
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
|
|
1032
1408
|
import { stat as fsStat } from "fs/promises";
|
|
1033
1409
|
import { homedir } from "os";
|
|
1034
1410
|
import { Hono } from "hono";
|
|
1035
1411
|
import { streamSSE } from "hono/streaming";
|
|
1412
|
+
import { bodyLimit } from "hono/body-limit";
|
|
1036
1413
|
import { watch as chokidarWatch } from "chokidar";
|
|
1037
1414
|
|
|
1038
1415
|
// ../../core/mcp/config.ts
|
|
@@ -1347,6 +1724,7 @@ function parseAuthChallengeFromResponse(response, context) {
|
|
|
1347
1724
|
return {
|
|
1348
1725
|
reason: reasonFromHttpResponse(status, bearer),
|
|
1349
1726
|
...requiredScopes.length > 0 ? { requiredScopes } : {},
|
|
1727
|
+
...bearer.resourceMetadata ? { resourceMetadataUrl: bearer.resourceMetadata } : {},
|
|
1350
1728
|
...bearer.errorDescription ? { message: bearer.errorDescription } : {},
|
|
1351
1729
|
...context ? { context } : {},
|
|
1352
1730
|
raw: {
|
|
@@ -1376,8 +1754,26 @@ function createAuthChallengeInterceptFetch(baseFetch) {
|
|
|
1376
1754
|
);
|
|
1377
1755
|
};
|
|
1378
1756
|
}
|
|
1757
|
+
function createAuthChallengeObserverFetch(baseFetch, onChallenge) {
|
|
1758
|
+
return async (input, init) => {
|
|
1759
|
+
const response = await baseFetch(input, init);
|
|
1760
|
+
if (response.status === 401 || response.status === 403) {
|
|
1761
|
+
const challenge = parseAuthChallengeFromResponse(response);
|
|
1762
|
+
if (challenge) {
|
|
1763
|
+
onChallenge(challenge);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
return response;
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1379
1769
|
|
|
1380
|
-
// ../../core/mcp/node/
|
|
1770
|
+
// ../../core/mcp/node/proxyFetch.ts
|
|
1771
|
+
function asUndiciInit(init) {
|
|
1772
|
+
return init;
|
|
1773
|
+
}
|
|
1774
|
+
function asBodyInit(body) {
|
|
1775
|
+
return body;
|
|
1776
|
+
}
|
|
1381
1777
|
var PROXY_ENV_VARS = [
|
|
1382
1778
|
"HTTPS_PROXY",
|
|
1383
1779
|
"https_proxy",
|
|
@@ -1391,27 +1787,95 @@ function readProxyEnv() {
|
|
|
1391
1787
|
}
|
|
1392
1788
|
return void 0;
|
|
1393
1789
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1790
|
+
var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
|
|
1791
|
+
101,
|
|
1792
|
+
103,
|
|
1793
|
+
204,
|
|
1794
|
+
205,
|
|
1795
|
+
304
|
|
1796
|
+
]);
|
|
1797
|
+
function toGlobalResponse(res) {
|
|
1798
|
+
const headers = new Headers();
|
|
1799
|
+
for (const [key, value] of res.headers) headers.append(key, value);
|
|
1800
|
+
const out = new Response(
|
|
1801
|
+
NULL_BODY_STATUSES.has(res.status) ? null : asBodyInit(res.body),
|
|
1802
|
+
{
|
|
1803
|
+
status: res.status,
|
|
1804
|
+
statusText: res.statusText,
|
|
1805
|
+
headers
|
|
1806
|
+
}
|
|
1807
|
+
);
|
|
1808
|
+
for (const [key, value] of [
|
|
1809
|
+
["url", res.url],
|
|
1810
|
+
["redirected", res.redirected],
|
|
1811
|
+
["type", res.type]
|
|
1812
|
+
]) {
|
|
1813
|
+
Object.defineProperty(out, key, {
|
|
1814
|
+
value,
|
|
1815
|
+
configurable: true,
|
|
1816
|
+
enumerable: false
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
return out;
|
|
1820
|
+
}
|
|
1821
|
+
var loadedProxiedFetch;
|
|
1822
|
+
async function loadProxiedFetch() {
|
|
1823
|
+
let undici;
|
|
1824
|
+
try {
|
|
1825
|
+
undici = await import("undici");
|
|
1826
|
+
} catch (cause) {
|
|
1827
|
+
throw new Error(
|
|
1828
|
+
"HTTPS_PROXY / HTTP_PROXY is set but the `undici` package could not be loaded, so requests cannot be routed through the proxy. This is a packaging fault rather than something to fix by installing undici; please report it, or unset the proxy env var to fall back to a direct connection.",
|
|
1829
|
+
{ cause }
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
const dispatcher = new undici.EnvHttpProxyAgent();
|
|
1833
|
+
return async (input, init) => {
|
|
1834
|
+
const [target, requestInit] = toUndiciRequest(input, init);
|
|
1835
|
+
return toGlobalResponse(
|
|
1836
|
+
await undici.fetch(target, { ...asUndiciInit(requestInit), dispatcher })
|
|
1406
1837
|
);
|
|
1407
|
-
return dispatcherPromise;
|
|
1408
1838
|
};
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1839
|
+
}
|
|
1840
|
+
function toUndiciRequest(input, init) {
|
|
1841
|
+
if (input instanceof Request) {
|
|
1842
|
+
return [
|
|
1843
|
+
input.url,
|
|
1844
|
+
{
|
|
1845
|
+
method: input.method,
|
|
1846
|
+
headers: input.headers,
|
|
1847
|
+
// A stream body needs `duplex: "half"`; the field is undici's, not in
|
|
1848
|
+
// the DOM `RequestInit`, so it rides along as an extra property.
|
|
1849
|
+
...input.body ? { body: input.body, duplex: "half" } : {},
|
|
1850
|
+
signal: input.signal,
|
|
1851
|
+
// Everything else the Request carries. Dropping these would silently
|
|
1852
|
+
// change fetch semantics under a proxy — most visibly `redirect`, where
|
|
1853
|
+
// a `new Request(url, { redirect: "manual" })` would start following
|
|
1854
|
+
// redirects instead of surfacing them.
|
|
1855
|
+
redirect: input.redirect,
|
|
1856
|
+
cache: input.cache,
|
|
1857
|
+
credentials: input.credentials,
|
|
1858
|
+
integrity: input.integrity,
|
|
1859
|
+
keepalive: input.keepalive,
|
|
1860
|
+
mode: input.mode,
|
|
1861
|
+
referrer: input.referrer,
|
|
1862
|
+
referrerPolicy: input.referrerPolicy,
|
|
1863
|
+
// An explicit `init` still wins, per the fetch contract.
|
|
1864
|
+
...init
|
|
1865
|
+
}
|
|
1866
|
+
];
|
|
1867
|
+
}
|
|
1868
|
+
return [input instanceof URL ? input.href : input, init ?? {}];
|
|
1869
|
+
}
|
|
1870
|
+
function createProxyFetch() {
|
|
1871
|
+
if (readProxyEnv() === void 0) return void 0;
|
|
1872
|
+
return async (input, init) => {
|
|
1873
|
+
loadedProxiedFetch ??= loadProxiedFetch();
|
|
1874
|
+
return (await loadedProxiedFetch)(input, init);
|
|
1412
1875
|
};
|
|
1413
|
-
return proxiedFetch;
|
|
1414
1876
|
}
|
|
1877
|
+
|
|
1878
|
+
// ../../core/mcp/node/transport.ts
|
|
1415
1879
|
function headersFromSettings(settings) {
|
|
1416
1880
|
if (!settings || settings.headers.length === 0) return void 0;
|
|
1417
1881
|
const out = {};
|
|
@@ -1431,10 +1895,12 @@ function createTransportNode(config, options = {}) {
|
|
|
1431
1895
|
onFetchResponseBody,
|
|
1432
1896
|
authProvider,
|
|
1433
1897
|
settings,
|
|
1434
|
-
interceptAuthChallenges = false
|
|
1898
|
+
interceptAuthChallenges = false,
|
|
1899
|
+
onAuthChallengeObserved
|
|
1435
1900
|
} = options;
|
|
1436
|
-
const baseFetch =
|
|
1437
|
-
const
|
|
1901
|
+
const baseFetch = optionsFetchFn ?? createProxyFetch() ?? globalThis.fetch;
|
|
1902
|
+
const withChallengeObserver = (inner) => onAuthChallengeObserved ? createAuthChallengeObserverFetch(inner, onAuthChallengeObserved) : inner;
|
|
1903
|
+
const fetchWithOptionalAuthIntercept = interceptAuthChallenges ? createAuthChallengeInterceptFetch(withChallengeObserver(baseFetch)) : withChallengeObserver(baseFetch);
|
|
1438
1904
|
if (serverType === "stdio") {
|
|
1439
1905
|
const stdioConfig = config;
|
|
1440
1906
|
const transport = new StdioClientTransport({
|
|
@@ -1459,7 +1925,8 @@ function createTransportNode(config, options = {}) {
|
|
|
1459
1925
|
} else if (serverType === "sse") {
|
|
1460
1926
|
const sseConfig = config;
|
|
1461
1927
|
const url = new URL(sseConfig.url);
|
|
1462
|
-
const
|
|
1928
|
+
const configuredSseFetch = sseConfig.eventSourceInit?.fetch;
|
|
1929
|
+
const sseFetch = configuredSseFetch ? withChallengeObserver(configuredSseFetch) : fetchWithOptionalAuthIntercept;
|
|
1463
1930
|
const trackedFetch = onFetchRequest ? createFetchTracker(sseFetch, {
|
|
1464
1931
|
trackRequest: onFetchRequest,
|
|
1465
1932
|
updateResponseBody: onFetchResponseBody
|
|
@@ -1637,27 +2104,27 @@ function resolveImportSource(type, platform, homeDir, readFile2) {
|
|
|
1637
2104
|
const strategy = getImportStrategy(type);
|
|
1638
2105
|
if (!strategy) return null;
|
|
1639
2106
|
const searched = strategy.defaultPaths(platform, homeDir);
|
|
1640
|
-
for (const
|
|
2107
|
+
for (const path6 of searched) {
|
|
1641
2108
|
let raw;
|
|
1642
2109
|
try {
|
|
1643
|
-
raw = readFile2(
|
|
2110
|
+
raw = readFile2(path6);
|
|
1644
2111
|
} catch (err) {
|
|
1645
2112
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1646
2113
|
return {
|
|
1647
2114
|
type,
|
|
1648
2115
|
found: true,
|
|
1649
|
-
path:
|
|
1650
|
-
error: `Failed to read ${
|
|
2116
|
+
path: path6,
|
|
2117
|
+
error: `Failed to read ${path6}: ${msg}`,
|
|
1651
2118
|
searched
|
|
1652
2119
|
};
|
|
1653
2120
|
}
|
|
1654
2121
|
if (raw === null) continue;
|
|
1655
2122
|
try {
|
|
1656
2123
|
const config = strategy.parse(raw);
|
|
1657
|
-
return { type, found: true, path:
|
|
2124
|
+
return { type, found: true, path: path6, config, searched };
|
|
1658
2125
|
} catch (err) {
|
|
1659
2126
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1660
|
-
return { type, found: true, path:
|
|
2127
|
+
return { type, found: true, path: path6, error: msg, searched };
|
|
1661
2128
|
}
|
|
1662
2129
|
}
|
|
1663
2130
|
return { type, found: false, searched };
|
|
@@ -1781,7 +2248,7 @@ var RemoteSession = class _RemoteSession {
|
|
|
1781
2248
|
* `auth_challenge` or `ok: true` only after the MCP round-trip (or auth error).
|
|
1782
2249
|
*/
|
|
1783
2250
|
waitForRequestResponse(requestId, timeoutMs = 6e4) {
|
|
1784
|
-
return new Promise((
|
|
2251
|
+
return new Promise((resolve9, reject) => {
|
|
1785
2252
|
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
1786
2253
|
this.requestWaits.delete(requestId);
|
|
1787
2254
|
reject(
|
|
@@ -1795,7 +2262,7 @@ var RemoteSession = class _RemoteSession {
|
|
|
1795
2262
|
if (timer !== void 0) {
|
|
1796
2263
|
clearTimeout(timer);
|
|
1797
2264
|
}
|
|
1798
|
-
|
|
2265
|
+
resolve9();
|
|
1799
2266
|
},
|
|
1800
2267
|
reject: (error) => {
|
|
1801
2268
|
if (timer !== void 0) {
|
|
@@ -1975,130 +2442,1372 @@ function createRemoteAuthProvider(initialAuthState) {
|
|
|
1975
2442
|
}
|
|
1976
2443
|
};
|
|
1977
2444
|
}
|
|
1978
|
-
|
|
1979
|
-
// ../../core/auth/node/secret-store.ts
|
|
1980
|
-
var SERVICE_NAME = "mcp-inspector";
|
|
1981
|
-
var keyringLoad;
|
|
1982
|
-
var KeyringModuleShapeError = class extends Error {
|
|
1983
|
-
constructor(detail, options) {
|
|
1984
|
-
super(`@napi-rs/keyring loaded but ${detail}`, options);
|
|
1985
|
-
this.name = "KeyringModuleShapeError";
|
|
2445
|
+
|
|
2446
|
+
// ../../core/auth/node/secret-store.ts
|
|
2447
|
+
var SERVICE_NAME = "mcp-inspector";
|
|
2448
|
+
var keyringLoad;
|
|
2449
|
+
var KeyringModuleShapeError = class extends Error {
|
|
2450
|
+
constructor(detail, options) {
|
|
2451
|
+
super(`@napi-rs/keyring loaded but ${detail}`, options);
|
|
2452
|
+
this.name = "KeyringModuleShapeError";
|
|
2453
|
+
}
|
|
2454
|
+
};
|
|
2455
|
+
var checkKeyringShape = (mod) => {
|
|
2456
|
+
try {
|
|
2457
|
+
if (typeof mod.AsyncEntry === "function" && typeof mod.findCredentialsAsync === "function") {
|
|
2458
|
+
return { ok: true, mod };
|
|
2459
|
+
}
|
|
2460
|
+
return {
|
|
2461
|
+
ok: false,
|
|
2462
|
+
err: new KeyringModuleShapeError(
|
|
2463
|
+
"did not expose AsyncEntry / findCredentialsAsync"
|
|
2464
|
+
)
|
|
2465
|
+
};
|
|
2466
|
+
} catch (err) {
|
|
2467
|
+
return {
|
|
2468
|
+
ok: false,
|
|
2469
|
+
err: new KeyringModuleShapeError(
|
|
2470
|
+
`its exports could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
|
2471
|
+
{ cause: err }
|
|
2472
|
+
)
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
var loadKeyring = () => {
|
|
2477
|
+
keyringLoad ??= import("@napi-rs/keyring").then(
|
|
2478
|
+
checkKeyringShape,
|
|
2479
|
+
(err) => ({ ok: false, err })
|
|
2480
|
+
);
|
|
2481
|
+
return keyringLoad;
|
|
2482
|
+
};
|
|
2483
|
+
function parseAccount(account) {
|
|
2484
|
+
const idx = account.indexOf(":");
|
|
2485
|
+
if (idx <= 0 || idx === account.length - 1) return null;
|
|
2486
|
+
return {
|
|
2487
|
+
serverId: account.slice(0, idx),
|
|
2488
|
+
field: account.slice(idx + 1)
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
var buildAccount = (serverId, field) => `${serverId}:${field}`;
|
|
2492
|
+
var SecretStoreUnavailableError = class extends Error {
|
|
2493
|
+
constructor(message, options) {
|
|
2494
|
+
super(message, options);
|
|
2495
|
+
this.name = "SecretStoreUnavailableError";
|
|
2496
|
+
}
|
|
2497
|
+
};
|
|
2498
|
+
var KeychainUnavailableError = class extends SecretStoreUnavailableError {
|
|
2499
|
+
constructor(cause) {
|
|
2500
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
2501
|
+
super(
|
|
2502
|
+
`OS keychain is not available. ${hintFor(cause, message)} Underlying error: ${message}`
|
|
2503
|
+
);
|
|
2504
|
+
this.name = "KeychainUnavailableError";
|
|
2505
|
+
}
|
|
2506
|
+
};
|
|
2507
|
+
var hintFor = (cause, message) => {
|
|
2508
|
+
if (cause instanceof KeyringModuleShapeError) {
|
|
2509
|
+
return `The @napi-rs/keyring package loaded but does not expose the API this build expects \u2014 most likely a version or packaging mismatch; reinstall the Inspector, and report this if it persists.`;
|
|
2510
|
+
}
|
|
2511
|
+
if (message.includes("Cannot find native binding")) {
|
|
2512
|
+
return `The @napi-rs/keyring platform package for this OS is missing or unavailable \u2014 reinstall the Inspector (for npx, clear the npx cache under your npm cache directory first).`;
|
|
2513
|
+
}
|
|
2514
|
+
return `On Linux, install libsecret / gnome-keyring.`;
|
|
2515
|
+
};
|
|
2516
|
+
async function probeKeyringAvailable() {
|
|
2517
|
+
const keyring = await loadKeyring();
|
|
2518
|
+
if (!keyring.ok) {
|
|
2519
|
+
const err = keyring.err;
|
|
2520
|
+
return {
|
|
2521
|
+
available: false,
|
|
2522
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
try {
|
|
2526
|
+
const entry = new keyring.mod.AsyncEntry(SERVICE_NAME, PROBE_ACCOUNT);
|
|
2527
|
+
await entry.getPassword();
|
|
2528
|
+
return { available: true };
|
|
2529
|
+
} catch (err) {
|
|
2530
|
+
return {
|
|
2531
|
+
available: false,
|
|
2532
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
2533
|
+
};
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
var PROBE_ACCOUNT = "__inspector:probe";
|
|
2537
|
+
var KeyringSecretStore = class {
|
|
2538
|
+
/**
|
|
2539
|
+
* The intolerant read. Same lookup as {@link get}, minus the catch — a
|
|
2540
|
+
* keychain that cannot answer must not be reported as an empty one to a
|
|
2541
|
+
* caller that is about to write based on the answer.
|
|
2542
|
+
*/
|
|
2543
|
+
async getStrict(serverId, field) {
|
|
2544
|
+
try {
|
|
2545
|
+
const keyring = await loadKeyring();
|
|
2546
|
+
if (!keyring.ok) throw new KeychainUnavailableError(keyring.err);
|
|
2547
|
+
const entry = new keyring.mod.AsyncEntry(
|
|
2548
|
+
SERVICE_NAME,
|
|
2549
|
+
buildAccount(serverId, field)
|
|
2550
|
+
);
|
|
2551
|
+
return await entry.getPassword() ?? null;
|
|
2552
|
+
} catch (err) {
|
|
2553
|
+
if (err instanceof KeychainUnavailableError) throw err;
|
|
2554
|
+
throw new KeychainUnavailableError(err);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
async get(serverId, field) {
|
|
2558
|
+
try {
|
|
2559
|
+
const keyring = await loadKeyring();
|
|
2560
|
+
if (!keyring.ok) return null;
|
|
2561
|
+
const entry = new keyring.mod.AsyncEntry(
|
|
2562
|
+
SERVICE_NAME,
|
|
2563
|
+
buildAccount(serverId, field)
|
|
2564
|
+
);
|
|
2565
|
+
const v = await entry.getPassword();
|
|
2566
|
+
return v ?? null;
|
|
2567
|
+
} catch {
|
|
2568
|
+
return null;
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
async set(serverId, field, value) {
|
|
2572
|
+
try {
|
|
2573
|
+
const keyring = await loadKeyring();
|
|
2574
|
+
if (!keyring.ok) throw new KeychainUnavailableError(keyring.err);
|
|
2575
|
+
const entry = new keyring.mod.AsyncEntry(
|
|
2576
|
+
SERVICE_NAME,
|
|
2577
|
+
buildAccount(serverId, field)
|
|
2578
|
+
);
|
|
2579
|
+
await entry.setPassword(value);
|
|
2580
|
+
} catch (err) {
|
|
2581
|
+
if (err instanceof KeychainUnavailableError) throw err;
|
|
2582
|
+
throw new KeychainUnavailableError(err);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
async delete(serverId, field) {
|
|
2586
|
+
try {
|
|
2587
|
+
const keyring = await loadKeyring();
|
|
2588
|
+
if (!keyring.ok) return;
|
|
2589
|
+
const entry = new keyring.mod.AsyncEntry(
|
|
2590
|
+
SERVICE_NAME,
|
|
2591
|
+
buildAccount(serverId, field)
|
|
2592
|
+
);
|
|
2593
|
+
await entry.deleteCredential();
|
|
2594
|
+
} catch {
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
async deleteAllForServer(serverId) {
|
|
2598
|
+
let creds;
|
|
2599
|
+
try {
|
|
2600
|
+
const keyring = await loadKeyring();
|
|
2601
|
+
if (!keyring.ok) return;
|
|
2602
|
+
creds = await keyring.mod.findCredentialsAsync(SERVICE_NAME);
|
|
2603
|
+
} catch {
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
const prefix = `${serverId}:`;
|
|
2607
|
+
for (const c of creds) {
|
|
2608
|
+
if (!c.account.startsWith(prefix)) continue;
|
|
2609
|
+
const parsed = parseAccount(c.account);
|
|
2610
|
+
if (!parsed || parsed.serverId !== serverId) continue;
|
|
2611
|
+
await this.delete(serverId, parsed.field);
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
};
|
|
2615
|
+
async function secretStoreGetStrict(store, serverId, field) {
|
|
2616
|
+
return store.getStrict ? store.getStrict(serverId, field) : store.get(serverId, field);
|
|
2617
|
+
}
|
|
2618
|
+
async function secretStoreGetMany(store, requests) {
|
|
2619
|
+
if (store.getMany) return store.getMany(requests);
|
|
2620
|
+
const out = {};
|
|
2621
|
+
await Promise.all(
|
|
2622
|
+
requests.map(async ({ serverId, fields }) => {
|
|
2623
|
+
const entries = await Promise.all(
|
|
2624
|
+
fields.map(
|
|
2625
|
+
async (field) => [field, await store.get(serverId, field)]
|
|
2626
|
+
)
|
|
2627
|
+
);
|
|
2628
|
+
const found = {};
|
|
2629
|
+
for (const [field, value] of entries) {
|
|
2630
|
+
if (value !== null) found[field] = value;
|
|
2631
|
+
}
|
|
2632
|
+
out[serverId] = found;
|
|
2633
|
+
})
|
|
2634
|
+
);
|
|
2635
|
+
return out;
|
|
2636
|
+
}
|
|
2637
|
+
async function secretStoreSetMany(store, serverId, values) {
|
|
2638
|
+
if (store.setMany) return store.setMany(serverId, values);
|
|
2639
|
+
await Promise.all(
|
|
2640
|
+
Object.entries(values).map(
|
|
2641
|
+
([field, value]) => store.set(serverId, field, value)
|
|
2642
|
+
)
|
|
2643
|
+
);
|
|
2644
|
+
}
|
|
2645
|
+
async function secretStoreIsDurable(store) {
|
|
2646
|
+
return store.isDurable ? store.isDurable() : true;
|
|
2647
|
+
}
|
|
2648
|
+
var InMemorySecretStore = class {
|
|
2649
|
+
map = /* @__PURE__ */ new Map();
|
|
2650
|
+
async get(serverId, field) {
|
|
2651
|
+
return this.map.get(buildAccount(serverId, field)) ?? null;
|
|
2652
|
+
}
|
|
2653
|
+
async set(serverId, field, value) {
|
|
2654
|
+
this.map.set(buildAccount(serverId, field), value);
|
|
2655
|
+
}
|
|
2656
|
+
async delete(serverId, field) {
|
|
2657
|
+
this.map.delete(buildAccount(serverId, field));
|
|
2658
|
+
}
|
|
2659
|
+
async deleteAllForServer(serverId) {
|
|
2660
|
+
const prefix = `${serverId}:`;
|
|
2661
|
+
for (const key of [...this.map.keys()]) {
|
|
2662
|
+
if (key.startsWith(prefix)) this.map.delete(key);
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2666
|
+
var SessionSecretStore = class extends InMemorySecretStore {
|
|
2667
|
+
async isDurable() {
|
|
2668
|
+
return false;
|
|
2669
|
+
}
|
|
2670
|
+
};
|
|
2671
|
+
|
|
2672
|
+
// ../../core/auth/node/secret-store-selection.ts
|
|
2673
|
+
import { randomUUID } from "crypto";
|
|
2674
|
+
import * as fsSync from "fs";
|
|
2675
|
+
import * as fs4 from "fs/promises";
|
|
2676
|
+
import * as path4 from "path";
|
|
2677
|
+
|
|
2678
|
+
// ../../core/auth/node/file-secret-store.ts
|
|
2679
|
+
import * as crypto2 from "crypto";
|
|
2680
|
+
import * as fs3 from "fs/promises";
|
|
2681
|
+
import * as path3 from "path";
|
|
2682
|
+
|
|
2683
|
+
// ../../core/auth/node/file-lock.ts
|
|
2684
|
+
import nodeFs, { statSync, rmdirSync } from "fs";
|
|
2685
|
+
import * as fs2 from "fs/promises";
|
|
2686
|
+
import * as path2 from "path";
|
|
2687
|
+
import properLockfile from "proper-lockfile";
|
|
2688
|
+
var STALE_MS = 1e4;
|
|
2689
|
+
var RETRY = {
|
|
2690
|
+
retries: 20,
|
|
2691
|
+
factor: 2,
|
|
2692
|
+
minTimeout: 20,
|
|
2693
|
+
maxTimeout: 1e3
|
|
2694
|
+
};
|
|
2695
|
+
var RETRY_BUDGET_MS = (() => {
|
|
2696
|
+
let total = 0;
|
|
2697
|
+
let delay = RETRY.minTimeout;
|
|
2698
|
+
for (let i = 0; i < RETRY.retries; i++) {
|
|
2699
|
+
total += Math.min(delay, RETRY.maxTimeout);
|
|
2700
|
+
delay *= RETRY.factor;
|
|
2701
|
+
}
|
|
2702
|
+
return total;
|
|
2703
|
+
})();
|
|
2704
|
+
var warned = /* @__PURE__ */ new Set();
|
|
2705
|
+
function warnOnce(message) {
|
|
2706
|
+
if (warned.has(message)) return;
|
|
2707
|
+
warned.add(message);
|
|
2708
|
+
console.warn(`[mcp-inspector] ${message}`);
|
|
2709
|
+
}
|
|
2710
|
+
function isStuckOrHeld(err, lockPath) {
|
|
2711
|
+
return isHeldElsewhere(err) || identifySync(lockPath) !== null;
|
|
2712
|
+
}
|
|
2713
|
+
var isHeldElsewhere = (err) => err.code === "ELOCKED";
|
|
2714
|
+
var describeError = (err) => err instanceof Error ? err.message : String(err);
|
|
2715
|
+
var lockPathOf = (target) => `${target}.lock`;
|
|
2716
|
+
function identifySync(lockPath) {
|
|
2717
|
+
try {
|
|
2718
|
+
const stat2 = statSync(lockPath);
|
|
2719
|
+
return { ino: stat2.ino, birthtimeMs: stat2.birthtimeMs };
|
|
2720
|
+
} catch {
|
|
2721
|
+
return null;
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
function guardedFs(lockPath, owned, onRefused) {
|
|
2725
|
+
const mine = () => {
|
|
2726
|
+
if (owned.id === null) return true;
|
|
2727
|
+
const now = identifySync(lockPath);
|
|
2728
|
+
if (now === null) return false;
|
|
2729
|
+
return now.ino === owned.id.ino && now.birthtimeMs === owned.id.birthtimeMs;
|
|
2730
|
+
};
|
|
2731
|
+
const removeIfMine = () => {
|
|
2732
|
+
if (!mine()) {
|
|
2733
|
+
onRefused();
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
rmdirSync(lockPath);
|
|
2737
|
+
};
|
|
2738
|
+
return {
|
|
2739
|
+
...nodeFs,
|
|
2740
|
+
// **Identity is captured here, not after `lock()` resolves.** `mkdir` is
|
|
2741
|
+
// the moment the directory becomes ours, and it is synchronous with
|
|
2742
|
+
// respect to this process: recording it in the callback, before yielding,
|
|
2743
|
+
// leaves no window. Capturing after the acquire promise settled — as this
|
|
2744
|
+
// did originally — spans the library's own `utimes`/`stat` probe, and a
|
|
2745
|
+
// waiter replacing our directory inside that span would have us record
|
|
2746
|
+
// *the winner's* identity as our own, after which the guard below would
|
|
2747
|
+
// cheerfully delete their lock.
|
|
2748
|
+
mkdir: (p, cb) => {
|
|
2749
|
+
nodeFs.mkdir(p, (err) => {
|
|
2750
|
+
if (!err) owned.id = identifySync(lockPath);
|
|
2751
|
+
cb(err);
|
|
2752
|
+
});
|
|
2753
|
+
},
|
|
2754
|
+
// Reported as success when refused: the library's bookkeeping should
|
|
2755
|
+
// forget this lock either way. Leaving it registered would hand the
|
|
2756
|
+
// `signal-exit` handler a record pointing at the winner's directory.
|
|
2757
|
+
rmdir: (_p, cb) => {
|
|
2758
|
+
try {
|
|
2759
|
+
removeIfMine();
|
|
2760
|
+
cb(null);
|
|
2761
|
+
} catch (err) {
|
|
2762
|
+
cb(err);
|
|
2763
|
+
}
|
|
2764
|
+
},
|
|
2765
|
+
/* v8 ignore next 3 -- @preserve: only reachable from proper-lockfile's
|
|
2766
|
+
signal-exit handler, i.e. at real process exit, which no in-process
|
|
2767
|
+
test can drive. Its logic is `removeIfMine`, covered via `rmdir`. */
|
|
2768
|
+
rmdirSync: () => {
|
|
2769
|
+
removeIfMine();
|
|
2770
|
+
}
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
function acquire(target, retries, fsShim) {
|
|
2774
|
+
return properLockfile.lock(target, {
|
|
2775
|
+
realpath: false,
|
|
2776
|
+
stale: STALE_MS,
|
|
2777
|
+
retries,
|
|
2778
|
+
// Every directory removal the library performs — on release and from its
|
|
2779
|
+
// exit handler — routes through here. See `guardedFs`.
|
|
2780
|
+
fs: fsShim,
|
|
2781
|
+
// The library's default `onCompromised` *throws* — from a timer, with no
|
|
2782
|
+
// caller on the stack, so it lands as an uncaught exception and takes the
|
|
2783
|
+
// process down. This is also the library's *only* signal for the
|
|
2784
|
+
// stale-takeover race described at the top of this file — someone
|
|
2785
|
+
// declared our lock stale and replaced it — so it is the one place a user
|
|
2786
|
+
// learns the guarantee was lost. Worth saying; not worth killing an
|
|
2787
|
+
// Inspector session over.
|
|
2788
|
+
onCompromised: (err) => warnOnce(
|
|
2789
|
+
`The lock on the secrets file at ${target} was taken over by another process while a write was in progress (${err.message}). If a secret you just saved is missing, save it again.`
|
|
2790
|
+
)
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
async function isFileLockHeld(filePath) {
|
|
2794
|
+
try {
|
|
2795
|
+
return await properLockfile.check(path2.resolve(filePath), {
|
|
2796
|
+
realpath: false,
|
|
2797
|
+
stale: STALE_MS
|
|
2798
|
+
});
|
|
2799
|
+
} catch {
|
|
2800
|
+
return false;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
async function openSecretFileLock(filePath) {
|
|
2804
|
+
const target = path2.resolve(filePath);
|
|
2805
|
+
await fs2.mkdir(path2.dirname(target), { recursive: true }).catch(() => {
|
|
2806
|
+
});
|
|
2807
|
+
const owned = { id: null };
|
|
2808
|
+
const fsShim = guardedFs(
|
|
2809
|
+
lockPathOf(target),
|
|
2810
|
+
owned,
|
|
2811
|
+
() => warnOnce(
|
|
2812
|
+
`The lock on the secrets file at ${target} was taken over by another process while a write was in progress, so it was left alone rather than removed. If a secret you just saved is missing, save it again.`
|
|
2813
|
+
)
|
|
2814
|
+
);
|
|
2815
|
+
let release;
|
|
2816
|
+
try {
|
|
2817
|
+
release = await acquire(target, 0, fsShim).catch((err) => {
|
|
2818
|
+
if (!isStuckOrHeld(err, lockPathOf(target))) throw err;
|
|
2819
|
+
return acquire(target, RETRY, fsShim);
|
|
2820
|
+
});
|
|
2821
|
+
} catch (err) {
|
|
2822
|
+
if (isStuckOrHeld(err, lockPathOf(target))) {
|
|
2823
|
+
throw new SecretStoreUnavailableError(
|
|
2824
|
+
`Could not save to the secrets file at ${target}: its lock (${lockPathOf(target)}) was still held after the ${Math.round(RETRY_BUDGET_MS / 1e3)} seconds this save waited. Its secrets are intact; the value you just entered was not saved. If no other Inspector is running, remove that lock and try again.`
|
|
2825
|
+
);
|
|
2826
|
+
}
|
|
2827
|
+
warnOnce(
|
|
2828
|
+
`Could not take a lock on the secrets file at ${target} (${describeError(err)}), so writes to it are not protected against another process writing at the same moment.`
|
|
2829
|
+
);
|
|
2830
|
+
return null;
|
|
2831
|
+
}
|
|
2832
|
+
return async () => {
|
|
2833
|
+
try {
|
|
2834
|
+
await release();
|
|
2835
|
+
} catch (err) {
|
|
2836
|
+
if (err.code === "ERELEASED") return;
|
|
2837
|
+
warnOnce(
|
|
2838
|
+
// **Conditional, not an instruction.** Two blanket versions of this
|
|
2839
|
+
// have now been wrong in opposite directions: "it expires on its own"
|
|
2840
|
+
// (false whenever the same `rmdir` that blocked us also blocks stale
|
|
2841
|
+
// takeover — `ENOTEMPTY`, `EACCES`, `EPERM`, `EROFS`), and "remove it
|
|
2842
|
+
// by hand" (false whenever the failure was transient, since the path
|
|
2843
|
+
// may by then hold a *different, live* Inspector's lock, and deleting
|
|
2844
|
+
// that destroys the exclusion of a process that did nothing wrong).
|
|
2845
|
+
//
|
|
2846
|
+
// `proper-lockfile` forwards whatever the filesystem returned, and
|
|
2847
|
+
// nothing here can tell a permanent refusal from a passing one. So
|
|
2848
|
+
// this reports what happened and states the two conditions the
|
|
2849
|
+
// operator can check for themselves, rather than asserting an outcome
|
|
2850
|
+
// this code does not know.
|
|
2851
|
+
`Could not release the lock on the secrets file at ${target} (${describeError(err)}). It may clear on its own \u2014 stale takeover reclaims a lock through the same directory removal, so it will not if whatever blocked this persists. If saves keep failing against this file and no other Inspector is running, remove ${lockPathOf(target)} by hand.`
|
|
2852
|
+
);
|
|
2853
|
+
}
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
async function withSecretFileLock(filePath, fn) {
|
|
2857
|
+
const release = await openSecretFileLock(filePath);
|
|
2858
|
+
if (release === null) return fn();
|
|
2859
|
+
try {
|
|
2860
|
+
return await fn();
|
|
2861
|
+
} finally {
|
|
2862
|
+
await release();
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// ../../core/auth/node/file-secret-store.ts
|
|
2867
|
+
var scrypt2 = (passphrase, salt, keylen, options) => new Promise((resolve9, reject) => {
|
|
2868
|
+
crypto2.scrypt(passphrase, salt, keylen, options, (err, key) => {
|
|
2869
|
+
if (err) reject(err);
|
|
2870
|
+
else resolve9(key);
|
|
2871
|
+
});
|
|
2872
|
+
});
|
|
2873
|
+
var FORMAT_VERSION = 1;
|
|
2874
|
+
var CIPHER = "aes-256-gcm";
|
|
2875
|
+
var KEY_BYTES = 32;
|
|
2876
|
+
var IV_BYTES = 12;
|
|
2877
|
+
var GCM_TAG_BYTES = 16;
|
|
2878
|
+
var SCRYPT_N = 16384;
|
|
2879
|
+
var SCRYPT_R = 8;
|
|
2880
|
+
var SCRYPT_P = 1;
|
|
2881
|
+
var SECRET_KEY_ENV = "MCP_INSPECTOR_SECRET_KEY";
|
|
2882
|
+
var buildAccount2 = (serverId, field) => `${serverId}:${field}`;
|
|
2883
|
+
function asSecretMap(value, filePath) {
|
|
2884
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2885
|
+
throw new SecretStoreUnavailableError(
|
|
2886
|
+
`The secrets file at ${filePath} does not hold a secret map (found ${Array.isArray(value) ? "an array" : typeof value}). Refusing to write, which would replace it. Move the file aside to start over.`
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
2890
|
+
if (typeof entry !== "string") {
|
|
2891
|
+
throw new SecretStoreUnavailableError(
|
|
2892
|
+
`The secrets file at ${filePath} holds a non-string value for "${key}". Refusing to write, which would replace it. Move the file aside to start over.`
|
|
2893
|
+
);
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
return { ...value };
|
|
2897
|
+
}
|
|
2898
|
+
var SecretFileKeyMismatchError = class extends SecretStoreUnavailableError {
|
|
2899
|
+
constructor(filePath, hasKey) {
|
|
2900
|
+
super(
|
|
2901
|
+
hasKey ? `The secrets file at ${filePath} could not be decrypted with the current ${SECRET_KEY_ENV}. Refusing to write, which would overwrite the existing secrets. Restore the original passphrase, or delete the file to start over.` : `The secrets file at ${filePath} is encrypted but ${SECRET_KEY_ENV} is not set. Refusing to write, which would overwrite the existing secrets. Set the passphrase this file was written with, or delete the file to start over.`
|
|
2902
|
+
);
|
|
2903
|
+
this.name = "SecretFileKeyMismatchError";
|
|
2904
|
+
}
|
|
2905
|
+
};
|
|
2906
|
+
var encodeParts = (iv, tag, body) => `${iv.toString("base64")}.${tag.toString("base64")}.${body.toString("base64")}`;
|
|
2907
|
+
function decodeParts(payload) {
|
|
2908
|
+
const parts = payload.split(".");
|
|
2909
|
+
if (parts.length !== 3) return null;
|
|
2910
|
+
const [iv, tag, body] = parts;
|
|
2911
|
+
return {
|
|
2912
|
+
iv: Buffer.from(iv, "base64"),
|
|
2913
|
+
tag: Buffer.from(tag, "base64"),
|
|
2914
|
+
body: Buffer.from(body, "base64")
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
var MAX_WRITE_ATTEMPTS = 5;
|
|
2918
|
+
var fileQueues = /* @__PURE__ */ new Map();
|
|
2919
|
+
function sameMap(a, b) {
|
|
2920
|
+
const ak = Object.keys(a);
|
|
2921
|
+
if (ak.length !== Object.keys(b).length) return false;
|
|
2922
|
+
return ak.every(
|
|
2923
|
+
(k) => Object.prototype.hasOwnProperty.call(b, k) && a[k] === b[k]
|
|
2924
|
+
);
|
|
2925
|
+
}
|
|
2926
|
+
var isPositiveInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
|
|
2927
|
+
function encryptedEnvelopeProblem(parsed) {
|
|
2928
|
+
if (!parsed.kdf || typeof parsed.data !== "string") {
|
|
2929
|
+
return "encrypted envelope is missing its kdf or data";
|
|
2930
|
+
}
|
|
2931
|
+
const parts = decodeParts(parsed.data);
|
|
2932
|
+
if (!parts) return "encrypted payload is not iv.tag.ciphertext";
|
|
2933
|
+
if (parts.iv.length !== IV_BYTES) {
|
|
2934
|
+
return `initialization vector is ${parts.iv.length} bytes, expected ${IV_BYTES}`;
|
|
2935
|
+
}
|
|
2936
|
+
if (parts.tag.length !== GCM_TAG_BYTES) {
|
|
2937
|
+
return `authentication tag is ${parts.tag.length} bytes, expected ${GCM_TAG_BYTES}`;
|
|
2938
|
+
}
|
|
2939
|
+
if (parts.body.length === 0) return "ciphertext is empty";
|
|
2940
|
+
const { algorithm, salt, N, r, p } = parsed.kdf;
|
|
2941
|
+
if (algorithm !== "scrypt") return `unsupported kdf "${String(algorithm)}"`;
|
|
2942
|
+
if (typeof salt !== "string" || Buffer.from(salt, "base64").length === 0) {
|
|
2943
|
+
return "kdf salt is missing or not base64";
|
|
2944
|
+
}
|
|
2945
|
+
if (!isPositiveInt(N) || N < 2 || (N & N - 1) !== 0) {
|
|
2946
|
+
return `kdf N is ${String(N)}, which is not a power of two above 1`;
|
|
2947
|
+
}
|
|
2948
|
+
if (!isPositiveInt(r) || !isPositiveInt(p)) {
|
|
2949
|
+
return `kdf r/p are ${String(r)}/${String(p)}, which are not positive integers`;
|
|
2950
|
+
}
|
|
2951
|
+
if (N > SCRYPT_N || r > SCRYPT_R || p > SCRYPT_P) {
|
|
2952
|
+
return `kdf cost parameters (N=${N}, r=${r}, p=${p}) exceed the supported maximum (N=${SCRYPT_N}, r=${SCRYPT_R}, p=${SCRYPT_P})`;
|
|
2953
|
+
}
|
|
2954
|
+
return null;
|
|
2955
|
+
}
|
|
2956
|
+
var FileSecretStore = class {
|
|
2957
|
+
filePath;
|
|
2958
|
+
passphrase;
|
|
2959
|
+
constructor(options) {
|
|
2960
|
+
this.filePath = options.filePath;
|
|
2961
|
+
const raw = options.passphrase ?? process.env[SECRET_KEY_ENV];
|
|
2962
|
+
this.passphrase = raw && raw.trim() ? raw : void 0;
|
|
2963
|
+
}
|
|
2964
|
+
/**
|
|
2965
|
+
* True when the *next write* will encrypt. This is the configured policy,
|
|
2966
|
+
* not the state of the file — see {@link readOnDiskEncryption}, which is
|
|
2967
|
+
* what the user-facing descriptor is built from.
|
|
2968
|
+
*/
|
|
2969
|
+
get encrypted() {
|
|
2970
|
+
return this.passphrase !== void 0;
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* What the file on disk is *currently* written as — `null` when there is
|
|
2974
|
+
* no file yet, or when it cannot be read or parsed.
|
|
2975
|
+
*
|
|
2976
|
+
* Separate from {@link encrypted} because the two genuinely disagree for a
|
|
2977
|
+
* whole session: adding `MCP_INSPECTOR_SECRET_KEY` to an install that
|
|
2978
|
+
* already has a plaintext file flips `encrypted` to true immediately,
|
|
2979
|
+
* while the bytes stay readable until the next `set`. A descriptor built
|
|
2980
|
+
* from the policy would tell that user "File (encrypted)" while their
|
|
2981
|
+
* existing secrets sat in the clear — the precise reassurance this whole
|
|
2982
|
+
* subsystem exists not to give.
|
|
2983
|
+
*
|
|
2984
|
+
* Reads the envelope, and for an encrypted one **attempts to open it**.
|
|
2985
|
+
* Structure alone is not access: a well-formed file this passphrase cannot
|
|
2986
|
+
* decrypt answers `null` from every `get` and refuses every `set`, so
|
|
2987
|
+
* reporting it as a healthy "encrypted" store hands the quiet footer to
|
|
2988
|
+
* exactly the state the unreadable one exists to warn about. The
|
|
2989
|
+
* authentication costs one scrypt derivation per call, which is once per
|
|
2990
|
+
* `/api/config`, and the decrypted bytes are discarded.
|
|
2991
|
+
*
|
|
2992
|
+
* `absent` and `unreadable` are kept apart deliberately. Collapsing them
|
|
2993
|
+
* (as a bare `null` did) means an existing file that is corrupt, or
|
|
2994
|
+
* written in an envelope this build does not know, is reported as "no
|
|
2995
|
+
* file yet" — and the descriptor then falls back to the configured write
|
|
2996
|
+
* policy, so a passphrase-configured install claims "Encrypted file"
|
|
2997
|
+
* about a file whose encryption state was never established and which
|
|
2998
|
+
* `set` is in fact about to refuse.
|
|
2999
|
+
*/
|
|
3000
|
+
async readOnDiskEncryption() {
|
|
3001
|
+
let raw;
|
|
3002
|
+
try {
|
|
3003
|
+
raw = await readStoreFile(this.filePath);
|
|
3004
|
+
} catch (err) {
|
|
3005
|
+
return {
|
|
3006
|
+
state: "unreadable",
|
|
3007
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
if (raw === null) return { state: "absent" };
|
|
3011
|
+
try {
|
|
3012
|
+
const parsed = JSON.parse(raw);
|
|
3013
|
+
if (parsed.version !== FORMAT_VERSION) {
|
|
3014
|
+
return {
|
|
3015
|
+
state: "unreadable",
|
|
3016
|
+
detail: `format version ${String(parsed.version)}, expected ${FORMAT_VERSION}`
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
3019
|
+
if (parsed.encryption === "none") {
|
|
3020
|
+
try {
|
|
3021
|
+
asSecretMap(parsed.secrets ?? {}, this.filePath);
|
|
3022
|
+
} catch (err) {
|
|
3023
|
+
return {
|
|
3024
|
+
state: "unreadable",
|
|
3025
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
return { state: "plaintext" };
|
|
3029
|
+
}
|
|
3030
|
+
if (parsed.encryption === CIPHER) {
|
|
3031
|
+
const problem = encryptedEnvelopeProblem(parsed);
|
|
3032
|
+
if (problem) return { state: "unreadable", detail: problem };
|
|
3033
|
+
try {
|
|
3034
|
+
await this.readMap();
|
|
3035
|
+
} catch (err) {
|
|
3036
|
+
return {
|
|
3037
|
+
state: "unreadable",
|
|
3038
|
+
detail: err instanceof SecretFileKeyMismatchError ? `it cannot be decrypted with the current ${SECRET_KEY_ENV}` : err instanceof Error ? err.message : String(err)
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
return { state: "encrypted" };
|
|
3042
|
+
}
|
|
3043
|
+
return {
|
|
3044
|
+
state: "unreadable",
|
|
3045
|
+
detail: `unsupported envelope (encryption="${String(parsed.encryption)}", version=${String(parsed.version)})`
|
|
3046
|
+
};
|
|
3047
|
+
} catch {
|
|
3048
|
+
return { state: "unreadable", detail: "not valid JSON" };
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
async deriveKey(kdf) {
|
|
3052
|
+
return scrypt2(
|
|
3053
|
+
// Only reached when the passphrase is set — every caller checks first,
|
|
3054
|
+
// which is what makes the non-null assertion safe here rather than a
|
|
3055
|
+
// widened `string | undefined` threaded through the crypto helpers.
|
|
3056
|
+
this.passphrase,
|
|
3057
|
+
Buffer.from(kdf.salt, "base64"),
|
|
3058
|
+
KEY_BYTES,
|
|
3059
|
+
{ N: kdf.N, r: kdf.r, p: kdf.p }
|
|
3060
|
+
);
|
|
3061
|
+
}
|
|
3062
|
+
/**
|
|
3063
|
+
* Read and decode the file into a plain map.
|
|
3064
|
+
*
|
|
3065
|
+
* Distinguishes three outcomes that callers treat differently: `null` for
|
|
3066
|
+
* "no file yet" (a first write should create one), a map for success, and a
|
|
3067
|
+
* throw for "there is a file and we cannot read it" — which must never be
|
|
3068
|
+
* flattened into an empty map, or `set` would cheerfully overwrite it.
|
|
3069
|
+
*/
|
|
3070
|
+
/**
|
|
3071
|
+
* Every secret in the file, keyed by `${serverId}:${field}` — `null` when
|
|
3072
|
+
* there is no file.
|
|
3073
|
+
*
|
|
3074
|
+
* Exists for one caller: the hand-off performed when a keychain becomes
|
|
3075
|
+
* available on a box that had been falling back to a file (see
|
|
3076
|
+
* `absorbFileSecretsIntoKeyring`). Nothing in the normal request path
|
|
3077
|
+
* enumerates secrets, and deliberately so — this is the widest possible
|
|
3078
|
+
* read of the store and it should stay a one-purpose seam rather than a
|
|
3079
|
+
* general convenience.
|
|
3080
|
+
*
|
|
3081
|
+
* Throws for the same reasons `get` swallows: an unreadable or
|
|
3082
|
+
* undecryptable file must not present as "no secrets", because the caller
|
|
3083
|
+
* would conclude there was nothing to migrate and move on.
|
|
3084
|
+
*/
|
|
3085
|
+
async readAll() {
|
|
3086
|
+
return this.serialize(() => this.readMap());
|
|
3087
|
+
}
|
|
3088
|
+
async readMap() {
|
|
3089
|
+
const raw = await readStoreFile(this.filePath);
|
|
3090
|
+
if (raw === null) return null;
|
|
3091
|
+
let parsed;
|
|
3092
|
+
try {
|
|
3093
|
+
parsed = JSON.parse(raw);
|
|
3094
|
+
} catch (err) {
|
|
3095
|
+
throw new SecretStoreUnavailableError(
|
|
3096
|
+
`The secrets file at ${this.filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
3097
|
+
);
|
|
3098
|
+
}
|
|
3099
|
+
if (parsed.version !== FORMAT_VERSION) {
|
|
3100
|
+
throw new SecretStoreUnavailableError(
|
|
3101
|
+
`The secrets file at ${this.filePath} declares format version ${String(parsed.version)}, but this Inspector only understands version ${FORMAT_VERSION}. It was probably written by a newer Inspector; upgrade, or move the file aside to start over.`
|
|
3102
|
+
);
|
|
3103
|
+
}
|
|
3104
|
+
if (parsed.encryption === "none") {
|
|
3105
|
+
return asSecretMap(parsed.secrets ?? {}, this.filePath);
|
|
3106
|
+
}
|
|
3107
|
+
if (parsed.encryption !== CIPHER || !parsed.kdf || !parsed.data) {
|
|
3108
|
+
throw new SecretStoreUnavailableError(
|
|
3109
|
+
`The secrets file at ${this.filePath} declares an unsupported format (encryption="${String(parsed.encryption)}", version=${String(parsed.version)}). It may have been written by a newer Inspector.`
|
|
3110
|
+
);
|
|
3111
|
+
}
|
|
3112
|
+
if (!this.passphrase)
|
|
3113
|
+
throw new SecretFileKeyMismatchError(this.filePath, false);
|
|
3114
|
+
const structural = encryptedEnvelopeProblem(parsed);
|
|
3115
|
+
if (structural) {
|
|
3116
|
+
throw new SecretStoreUnavailableError(
|
|
3117
|
+
`The secrets file at ${this.filePath} is not readable: ${structural}. Restoring a passphrase will not repair it; the file itself is malformed.`
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
const parts = decodeParts(parsed.data);
|
|
3121
|
+
if (!parts) throw new SecretFileKeyMismatchError(this.filePath, true);
|
|
3122
|
+
let plain;
|
|
3123
|
+
try {
|
|
3124
|
+
const key = await this.deriveKey(parsed.kdf);
|
|
3125
|
+
const decipher = crypto2.createDecipheriv(CIPHER, key, parts.iv);
|
|
3126
|
+
decipher.setAuthTag(parts.tag);
|
|
3127
|
+
plain = Buffer.concat([
|
|
3128
|
+
decipher.update(parts.body),
|
|
3129
|
+
decipher.final()
|
|
3130
|
+
]).toString("utf-8");
|
|
3131
|
+
} catch {
|
|
3132
|
+
throw new SecretFileKeyMismatchError(this.filePath, true);
|
|
3133
|
+
}
|
|
3134
|
+
let payload;
|
|
3135
|
+
try {
|
|
3136
|
+
payload = JSON.parse(plain);
|
|
3137
|
+
} catch (err) {
|
|
3138
|
+
throw new SecretStoreUnavailableError(
|
|
3139
|
+
`The secrets file at ${this.filePath} decrypted successfully but its contents are not valid JSON: ${err instanceof Error ? err.message : String(err)}. The passphrase is correct; the file itself is corrupt.`
|
|
3140
|
+
);
|
|
3141
|
+
}
|
|
3142
|
+
return asSecretMap(payload, this.filePath);
|
|
3143
|
+
}
|
|
3144
|
+
async writeMap(map) {
|
|
3145
|
+
let file;
|
|
3146
|
+
if (this.passphrase) {
|
|
3147
|
+
const kdf = {
|
|
3148
|
+
algorithm: "scrypt",
|
|
3149
|
+
// A fresh salt per write, not per file: rewriting with the same salt
|
|
3150
|
+
// and a fresh IV would still be safe for GCM, but re-salting costs one
|
|
3151
|
+
// scrypt derivation we were doing anyway and removes the question.
|
|
3152
|
+
salt: crypto2.randomBytes(16).toString("base64"),
|
|
3153
|
+
N: SCRYPT_N,
|
|
3154
|
+
r: SCRYPT_R,
|
|
3155
|
+
p: SCRYPT_P
|
|
3156
|
+
};
|
|
3157
|
+
const key = await this.deriveKey(kdf);
|
|
3158
|
+
const iv = crypto2.randomBytes(IV_BYTES);
|
|
3159
|
+
const cipher = crypto2.createCipheriv(CIPHER, key, iv);
|
|
3160
|
+
const body = Buffer.concat([
|
|
3161
|
+
cipher.update(JSON.stringify(map), "utf-8"),
|
|
3162
|
+
cipher.final()
|
|
3163
|
+
]);
|
|
3164
|
+
file = {
|
|
3165
|
+
version: FORMAT_VERSION,
|
|
3166
|
+
encryption: CIPHER,
|
|
3167
|
+
kdf,
|
|
3168
|
+
data: encodeParts(iv, cipher.getAuthTag(), body)
|
|
3169
|
+
};
|
|
3170
|
+
} else {
|
|
3171
|
+
file = { version: FORMAT_VERSION, encryption: "none", secrets: map };
|
|
3172
|
+
}
|
|
3173
|
+
await writeStoreFile(this.filePath, `${JSON.stringify(file, null, 2)}
|
|
3174
|
+
`);
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* Apply a mutation to the file, under a cross-process lock, and confirm it
|
|
3178
|
+
* survived.
|
|
3179
|
+
*
|
|
3180
|
+
* **Two mechanisms, and they answer different questions** — this is not
|
|
3181
|
+
* belt-and-braces.
|
|
3182
|
+
*
|
|
3183
|
+
* The lock ({@link withSecretFileLock}, #2082) is the one that provides
|
|
3184
|
+
* mutual exclusion: a durable secrets file is shared state, and a second
|
|
3185
|
+
* Inspector on the same box — a CLI run beside a web session is the
|
|
3186
|
+
* ordinary case, not a contrived one — otherwise reads the same "before"
|
|
3187
|
+
* map and then atomically replaces the file, dropping whatever the first
|
|
3188
|
+
* process had just added. Both writes report success; one secret is simply
|
|
3189
|
+
* gone. Held across the read *and* the write, so no other holder can
|
|
3190
|
+
* observe or replace the map in between.
|
|
3191
|
+
*
|
|
3192
|
+
* The verify covers what a lock structurally cannot. A lock is an advisory
|
|
3193
|
+
* convention between the processes that take it, so it says nothing about
|
|
3194
|
+
* a writer that does not: an editor, a restored backup, a `jq` one-liner,
|
|
3195
|
+
* or an Inspector predating #2082. It is also the fallback when the lock
|
|
3196
|
+
* is unavailable at all — `withSecretFileLock` runs the body anyway on a
|
|
3197
|
+
* read-only or lock-hostile directory rather than failing a `set` on
|
|
3198
|
+
* exactly the deployments this store exists for (#1848, #1905).
|
|
3199
|
+
*
|
|
3200
|
+
* So: read `M0`, apply the mutation to get `M1`, write it, then read back
|
|
3201
|
+
* `M2`. If `M2` equals `M1` nothing interleaved. If it does not, someone
|
|
3202
|
+
* wrote between our write and our read — so re-apply onto what they left
|
|
3203
|
+
* and try again.
|
|
3204
|
+
*
|
|
3205
|
+
* The comparison is over the **whole map**, not just the entry we touched.
|
|
3206
|
+
* Checking only our own key would pass in precisely the case that loses
|
|
3207
|
+
* data: our entry is present, and it is the *other* writer's that is gone.
|
|
3208
|
+
* When the verify does fire, the writer that was clobbered is the one that
|
|
3209
|
+
* repairs it — A writes `MA`, B writes `MB` over it, B verifies `MB`
|
|
3210
|
+
* correctly, A verifies, sees `M2 !== MA`, re-applies onto `MB`.
|
|
3211
|
+
*
|
|
3212
|
+
* **What is left, stated without overselling.** Against an unlocked writer
|
|
3213
|
+
* the verify is not mutual exclusion: it only catches a clobber that has
|
|
3214
|
+
* already landed, so ordering the two as write-A, verify-A, write-B,
|
|
3215
|
+
* verify-B loses A's entry with both reporting success. That residual is
|
|
3216
|
+
* now confined to a writer outside this codebase, or to a directory where
|
|
3217
|
+
* no lock could be taken — and the latter announces itself, once, on the
|
|
3218
|
+
* console.
|
|
3219
|
+
*
|
|
3220
|
+
* Reads deliberately do not participate: `writeStoreFile` is atomic
|
|
3221
|
+
* (write-temp-then-rename), so a reader sees either the old file or the
|
|
3222
|
+
* new one, never a torn one. Taking the lock for them would serialize
|
|
3223
|
+
* every `GET /api/servers` behind whatever else holds it, to prevent
|
|
3224
|
+
* nothing.
|
|
3225
|
+
*/
|
|
3226
|
+
async mutate(apply) {
|
|
3227
|
+
await withSecretFileLock(this.filePath, () => this.mutateLocked(apply));
|
|
3228
|
+
}
|
|
3229
|
+
/** {@link mutate}'s body, with the lock already held. */
|
|
3230
|
+
async mutateLocked(apply) {
|
|
3231
|
+
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
|
|
3232
|
+
let current;
|
|
3233
|
+
try {
|
|
3234
|
+
current = await this.readMap() ?? {};
|
|
3235
|
+
} catch (err) {
|
|
3236
|
+
if (err instanceof SecretStoreUnavailableError) throw err;
|
|
3237
|
+
throw new SecretStoreUnavailableError(
|
|
3238
|
+
`Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
3239
|
+
);
|
|
3240
|
+
}
|
|
3241
|
+
const next = apply(current);
|
|
3242
|
+
if (next === null) return;
|
|
3243
|
+
try {
|
|
3244
|
+
await this.writeMap(next);
|
|
3245
|
+
} catch (err) {
|
|
3246
|
+
throw new SecretStoreUnavailableError(
|
|
3247
|
+
`Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
3248
|
+
);
|
|
3249
|
+
}
|
|
3250
|
+
let observed;
|
|
3251
|
+
try {
|
|
3252
|
+
observed = await this.readMap() ?? {};
|
|
3253
|
+
} catch {
|
|
3254
|
+
continue;
|
|
3255
|
+
}
|
|
3256
|
+
if (sameMap(next, observed)) return;
|
|
3257
|
+
}
|
|
3258
|
+
throw new SecretStoreUnavailableError(
|
|
3259
|
+
`Could not write the secrets file at ${this.filePath}: another process kept overwriting it (gave up after ${MAX_WRITE_ATTEMPTS} attempts). Its secrets are intact; the value you just entered was not saved.`
|
|
3260
|
+
);
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Run `fn` with the file to itself, so concurrent callers can't lose
|
|
3264
|
+
* writes.
|
|
3265
|
+
*
|
|
3266
|
+
* Keyed on the **resolved path, process-wide** rather than on this
|
|
3267
|
+
* instance. Two `FileSecretStore`s pointing at one file are ordinary —
|
|
3268
|
+
* the resolved store holds one and `absorbFileSecretsIntoKeyring`
|
|
3269
|
+
* constructs another — and a per-instance queue does not order them
|
|
3270
|
+
* against each other at all. They then race in-process, which the
|
|
3271
|
+
* optimistic verify is only *supposed* to catch for writers in different
|
|
3272
|
+
* processes: both read the same map, both write, and whichever verifies
|
|
3273
|
+
* before the other's write lands sees its own value and returns happy.
|
|
3274
|
+
* Measured, not theorised — two instances racing `set` lost an entry on
|
|
3275
|
+
* the first run of the convergence test below.
|
|
3276
|
+
*
|
|
3277
|
+
* So the in-process case is made correct by construction here, and
|
|
3278
|
+
* {@link mutate}'s cross-process lock covers what this cannot see: a
|
|
3279
|
+
* second Inspector process.
|
|
3280
|
+
*
|
|
3281
|
+
* The queue is *also* what keeps that lock usable. `proper-lockfile` is
|
|
3282
|
+
* not reentrant — a second `lock()` on a path this process already holds
|
|
3283
|
+
* fails `ELOCKED`, which is indistinguishable from a genuine remote
|
|
3284
|
+
* holder. Serializing here means the lock is only ever contended between
|
|
3285
|
+
* processes, which is the only contention it is asked to arbitrate.
|
|
3286
|
+
*/
|
|
3287
|
+
serialize(fn) {
|
|
3288
|
+
const key = path3.resolve(this.filePath);
|
|
3289
|
+
const run = (fileQueues.get(key) ?? Promise.resolve()).then(fn);
|
|
3290
|
+
const settled = run.then(
|
|
3291
|
+
() => {
|
|
3292
|
+
},
|
|
3293
|
+
() => {
|
|
3294
|
+
}
|
|
3295
|
+
);
|
|
3296
|
+
fileQueues.set(key, settled);
|
|
3297
|
+
void settled.then(() => {
|
|
3298
|
+
if (fileQueues.get(key) === settled) fileQueues.delete(key);
|
|
3299
|
+
});
|
|
3300
|
+
return run;
|
|
3301
|
+
}
|
|
3302
|
+
/**
|
|
3303
|
+
* One read and one derivation for an entire rehydration.
|
|
3304
|
+
*
|
|
3305
|
+
* `get` reads and decrypts the whole file, so the cost is per *call*, not
|
|
3306
|
+
* per field — which is why the batch has to span servers. A per-server
|
|
3307
|
+
* version of this shipped first and left a 20-server catalog paying 20
|
|
3308
|
+
* serialized derivations, the same stall it was written to remove.
|
|
3309
|
+
* Tolerant like `get`: an unreadable store yields no fields for anyone
|
|
3310
|
+
* rather than throwing.
|
|
3311
|
+
*/
|
|
3312
|
+
async getMany(requests) {
|
|
3313
|
+
let map;
|
|
3314
|
+
try {
|
|
3315
|
+
map = await this.serialize(() => this.readMap());
|
|
3316
|
+
} catch {
|
|
3317
|
+
map = null;
|
|
3318
|
+
}
|
|
3319
|
+
const out = {};
|
|
3320
|
+
for (const { serverId, fields } of requests) {
|
|
3321
|
+
const found = {};
|
|
3322
|
+
for (const field of fields) {
|
|
3323
|
+
const value = map?.[buildAccount2(serverId, field)];
|
|
3324
|
+
if (value !== void 0) found[field] = value;
|
|
3325
|
+
}
|
|
3326
|
+
out[serverId] = found;
|
|
3327
|
+
}
|
|
3328
|
+
return out;
|
|
3329
|
+
}
|
|
3330
|
+
/**
|
|
3331
|
+
* The intolerant read — same lookup as {@link get}, minus the catch.
|
|
3332
|
+
*
|
|
3333
|
+
* Without this, `secretStoreGetStrict` fell back to `get` for the file
|
|
3334
|
+
* store and turned every read failure into `null`. Both plaintext
|
|
3335
|
+
* migrations *write* on `null` and then strip the disk copy, so a
|
|
3336
|
+
* transient read error that cleared before the write let an older
|
|
3337
|
+
* on-disk value replace a newer stored one — the same inversion the
|
|
3338
|
+
* strict seam was introduced to prevent, still open on the store the
|
|
3339
|
+
* seam was introduced for.
|
|
3340
|
+
*/
|
|
3341
|
+
async getStrict(serverId, field) {
|
|
3342
|
+
try {
|
|
3343
|
+
const map = await this.serialize(() => this.readMap());
|
|
3344
|
+
return map?.[buildAccount2(serverId, field)] ?? null;
|
|
3345
|
+
} catch (err) {
|
|
3346
|
+
if (err instanceof SecretStoreUnavailableError) throw err;
|
|
3347
|
+
throw new SecretStoreUnavailableError(
|
|
3348
|
+
`Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
3349
|
+
);
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
async get(serverId, field) {
|
|
3353
|
+
try {
|
|
3354
|
+
const map = await this.serialize(() => this.readMap());
|
|
3355
|
+
return map?.[buildAccount2(serverId, field)] ?? null;
|
|
3356
|
+
} catch {
|
|
3357
|
+
return null;
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
/**
|
|
3361
|
+
* One read-modify-write-verify cycle for a whole server's fields.
|
|
3362
|
+
*
|
|
3363
|
+
* `set` pays that cycle — and, encrypted, three scrypt derivations — per
|
|
3364
|
+
* call, serialized behind this store's queue. The settings form resends a
|
|
3365
|
+
* server's full `env` map on any edit, so a 10-variable stdio server spent
|
|
3366
|
+
* ~30 derivations saving an unrelated timeout change.
|
|
3367
|
+
*/
|
|
3368
|
+
async setMany(serverId, values) {
|
|
3369
|
+
if (Object.keys(values).length === 0) return;
|
|
3370
|
+
await this.serialize(
|
|
3371
|
+
() => this.mutate((map) => {
|
|
3372
|
+
const next = { ...map };
|
|
3373
|
+
for (const [field, value] of Object.entries(values)) {
|
|
3374
|
+
next[buildAccount2(serverId, field)] = value;
|
|
3375
|
+
}
|
|
3376
|
+
return next;
|
|
3377
|
+
})
|
|
3378
|
+
);
|
|
3379
|
+
}
|
|
3380
|
+
async set(serverId, field, value) {
|
|
3381
|
+
await this.serialize(
|
|
3382
|
+
() => this.mutate((map) => ({
|
|
3383
|
+
...map,
|
|
3384
|
+
[buildAccount2(serverId, field)]: value
|
|
3385
|
+
}))
|
|
3386
|
+
);
|
|
3387
|
+
}
|
|
3388
|
+
async delete(serverId, field) {
|
|
3389
|
+
await this.deleteWhere((key) => key === buildAccount2(serverId, field));
|
|
3390
|
+
}
|
|
3391
|
+
async deleteAllForServer(serverId) {
|
|
3392
|
+
const prefix = `${serverId}:`;
|
|
3393
|
+
await this.deleteWhere((key) => key.startsWith(prefix));
|
|
3394
|
+
}
|
|
3395
|
+
/**
|
|
3396
|
+
* Shared delete body. Silent on every failure, matching the keyring store:
|
|
3397
|
+
* every reason a delete can fail collapses to "the entry isn't there
|
|
3398
|
+
* anymore", and `set` is the operation that reports a broken store.
|
|
3399
|
+
*/
|
|
3400
|
+
async deleteWhere(match) {
|
|
3401
|
+
try {
|
|
3402
|
+
await this.serialize(
|
|
3403
|
+
() => this.mutate((map) => {
|
|
3404
|
+
const keys = Object.keys(map).filter(match);
|
|
3405
|
+
if (keys.length === 0) return null;
|
|
3406
|
+
const next = { ...map };
|
|
3407
|
+
for (const key of keys) delete next[key];
|
|
3408
|
+
return next;
|
|
3409
|
+
})
|
|
3410
|
+
);
|
|
3411
|
+
} catch {
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
};
|
|
3415
|
+
async function tightenSecretFilePermissions(filePath) {
|
|
3416
|
+
try {
|
|
3417
|
+
await fs3.chmod(filePath, 384);
|
|
3418
|
+
} catch {
|
|
3419
|
+
}
|
|
3420
|
+
return readSecretFilePermissions(filePath);
|
|
3421
|
+
}
|
|
3422
|
+
async function readSecretFilePermissions(filePath) {
|
|
3423
|
+
try {
|
|
3424
|
+
if (process.platform === "win32") {
|
|
3425
|
+
await fs3.stat(filePath);
|
|
3426
|
+
return { state: "unknown", detail: "not checked on Windows" };
|
|
3427
|
+
}
|
|
3428
|
+
const mode = (await fs3.stat(filePath)).mode & 511;
|
|
3429
|
+
if (mode === 384) return { state: "ok" };
|
|
3430
|
+
return { state: "loose", mode };
|
|
3431
|
+
} catch (err) {
|
|
3432
|
+
const code = err.code;
|
|
3433
|
+
if (code === "ENOENT") {
|
|
3434
|
+
return { state: "absent" };
|
|
3435
|
+
}
|
|
3436
|
+
return { state: "unknown", detail: code ?? "unknown error" };
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
// ../../core/auth/node/secret-store-selection.ts
|
|
3441
|
+
var SECRET_STORE_ENV = "MCP_INSPECTOR_SECRET_STORE";
|
|
3442
|
+
var SECRET_FILE_ENV = "MCP_INSPECTOR_SECRET_FILE";
|
|
3443
|
+
var STORAGE_DIR_ENV = "MCP_STORAGE_DIR";
|
|
3444
|
+
var KINDS = ["keyring", "file", "memory"];
|
|
3445
|
+
function parseSecretStoreEnv(raw) {
|
|
3446
|
+
const value = raw?.trim().toLowerCase();
|
|
3447
|
+
if (!value) return void 0;
|
|
3448
|
+
if (KINDS.includes(value)) return value;
|
|
3449
|
+
console.warn(
|
|
3450
|
+
`[mcp-inspector] Ignoring ${SECRET_STORE_ENV}="${raw}": expected one of ${KINDS.join(", ")}.`
|
|
3451
|
+
);
|
|
3452
|
+
return void 0;
|
|
3453
|
+
}
|
|
3454
|
+
function defaultSecretFilePath() {
|
|
3455
|
+
const override = process.env[SECRET_FILE_ENV]?.trim();
|
|
3456
|
+
if (override) return path4.resolve(override);
|
|
3457
|
+
const storageDir = process.env[STORAGE_DIR_ENV]?.trim();
|
|
3458
|
+
if (storageDir) return path4.resolve(storageDir, "secrets.json");
|
|
3459
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
|
|
3460
|
+
return path4.resolve(homeDir, ".mcp-inspector", "secrets.json");
|
|
3461
|
+
}
|
|
3462
|
+
function isContainer() {
|
|
3463
|
+
if (process.env.KUBERNETES_SERVICE_HOST) return true;
|
|
3464
|
+
try {
|
|
3465
|
+
if (fsSync.existsSync("/.dockerenv") || fsSync.existsSync("/run/.containerenv")) {
|
|
3466
|
+
return true;
|
|
3467
|
+
}
|
|
3468
|
+
} catch {
|
|
3469
|
+
}
|
|
3470
|
+
try {
|
|
3471
|
+
const cgroup = fsSync.readFileSync("/proc/1/cgroup", "utf-8");
|
|
3472
|
+
return /\b(docker|containerd|kubepods|podman|libpod|lxc)\b/.test(cgroup);
|
|
3473
|
+
} catch {
|
|
3474
|
+
return false;
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
function nearestExisting(dir) {
|
|
3478
|
+
let current = path4.resolve(dir);
|
|
3479
|
+
while (!fsSync.existsSync(current)) {
|
|
3480
|
+
const parent = path4.dirname(current);
|
|
3481
|
+
if (parent === current) return null;
|
|
3482
|
+
current = parent;
|
|
3483
|
+
}
|
|
3484
|
+
return current;
|
|
3485
|
+
}
|
|
3486
|
+
function unescapeMountPoint(point) {
|
|
3487
|
+
return point.replace(
|
|
3488
|
+
/\\(\d{3})/g,
|
|
3489
|
+
(_match, octal) => String.fromCharCode(parseInt(octal, 8))
|
|
3490
|
+
);
|
|
3491
|
+
}
|
|
3492
|
+
var EPHEMERAL_FSTYPES = /* @__PURE__ */ new Set(["tmpfs", "ramfs"]);
|
|
3493
|
+
function readMountPoints() {
|
|
3494
|
+
try {
|
|
3495
|
+
return fsSync.readFileSync("/proc/self/mountinfo", "utf-8").split("\n").flatMap((line) => {
|
|
3496
|
+
const point = line.split(" ")[4];
|
|
3497
|
+
if (!point) return [];
|
|
3498
|
+
const after = line.split(" - ")[1];
|
|
3499
|
+
const fstype = after?.trim().split(/\s+/)[0];
|
|
3500
|
+
if (fstype && EPHEMERAL_FSTYPES.has(fstype)) return [];
|
|
3501
|
+
return [unescapeMountPoint(point)];
|
|
3502
|
+
}).filter((point) => point !== "/");
|
|
3503
|
+
} catch {
|
|
3504
|
+
return null;
|
|
1986
3505
|
}
|
|
1987
|
-
}
|
|
1988
|
-
|
|
3506
|
+
}
|
|
3507
|
+
function isOnMountPoint(dir) {
|
|
1989
3508
|
try {
|
|
1990
|
-
|
|
1991
|
-
|
|
3509
|
+
const nearest = nearestExisting(dir);
|
|
3510
|
+
if (nearest === null) return false;
|
|
3511
|
+
let current = nearest;
|
|
3512
|
+
try {
|
|
3513
|
+
current = fsSync.realpathSync(nearest);
|
|
3514
|
+
} catch {
|
|
3515
|
+
}
|
|
3516
|
+
const mounts = readMountPoints();
|
|
3517
|
+
if (mounts !== null) {
|
|
3518
|
+
return mounts.some(
|
|
3519
|
+
(point) => current === point || current.startsWith(`${point}/`)
|
|
3520
|
+
);
|
|
1992
3521
|
}
|
|
3522
|
+
const parent = path4.dirname(current);
|
|
3523
|
+
if (parent === current) return false;
|
|
3524
|
+
return fsSync.statSync(current).dev !== fsSync.statSync("/").dev;
|
|
3525
|
+
} catch {
|
|
3526
|
+
return false;
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
async function buildStore(kind, reason, detail) {
|
|
3530
|
+
if (kind === "keyring") {
|
|
1993
3531
|
return {
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
"did not expose AsyncEntry / findCredentialsAsync"
|
|
1997
|
-
)
|
|
3532
|
+
store: new KeyringSecretStore(),
|
|
3533
|
+
info: { kind, reason, durable: true }
|
|
1998
3534
|
};
|
|
1999
|
-
}
|
|
3535
|
+
}
|
|
3536
|
+
if (kind === "memory") {
|
|
2000
3537
|
return {
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
`its exports could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
|
2004
|
-
{ cause: err }
|
|
2005
|
-
)
|
|
3538
|
+
store: new SessionSecretStore(),
|
|
3539
|
+
info: { kind, reason, durable: false, detail }
|
|
2006
3540
|
};
|
|
2007
3541
|
}
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
checkKeyringShape,
|
|
2012
|
-
(err) => ({ ok: false, err })
|
|
2013
|
-
);
|
|
2014
|
-
return keyringLoad;
|
|
2015
|
-
};
|
|
2016
|
-
function parseAccount(account) {
|
|
2017
|
-
const idx = account.indexOf(":");
|
|
2018
|
-
if (idx <= 0 || idx === account.length - 1) return null;
|
|
3542
|
+
const filePath = defaultSecretFilePath();
|
|
3543
|
+
const store = new FileSecretStore({ filePath });
|
|
3544
|
+
await tightenSecretFilePermissions(filePath);
|
|
2019
3545
|
return {
|
|
2020
|
-
|
|
2021
|
-
|
|
3546
|
+
store,
|
|
3547
|
+
info: await describeFileStore(store, filePath, kind, reason, detail)
|
|
2022
3548
|
};
|
|
2023
3549
|
}
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
3550
|
+
async function describeFileStore(store, filePath, kind, reason, detail) {
|
|
3551
|
+
const onDisk = await store.readOnDiskEncryption();
|
|
3552
|
+
const unreadable = onDisk.state === "unreadable" ? onDisk.detail : void 0;
|
|
3553
|
+
const plaintext = onDisk.state === "absent" ? !store.encrypted : onDisk.state === "plaintext";
|
|
3554
|
+
const perms = await readSecretFilePermissions(filePath);
|
|
3555
|
+
return {
|
|
3556
|
+
kind,
|
|
3557
|
+
reason,
|
|
3558
|
+
path: filePath,
|
|
3559
|
+
// Omitted entirely when the envelope could not be read, so no consumer
|
|
3560
|
+
// can read a default out of it.
|
|
3561
|
+
...unreadable === void 0 ? { plaintext } : {},
|
|
3562
|
+
...unreadable !== void 0 ? { encryptionUnknown: unreadable } : {},
|
|
3563
|
+
...perms.state === "loose" ? { looseMode: perms.mode } : {},
|
|
3564
|
+
...perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {},
|
|
3565
|
+
// Only meaningful while the two disagree; omitted otherwise so the
|
|
3566
|
+
// payload does not carry a flag that says nothing.
|
|
3567
|
+
...unreadable === void 0 && plaintext && store.encrypted ? { pendingEncryption: true } : {},
|
|
3568
|
+
durable: true,
|
|
3569
|
+
detail
|
|
3570
|
+
};
|
|
3571
|
+
}
|
|
3572
|
+
function chooseFallbackKind(opts) {
|
|
3573
|
+
return opts.container && !opts.mounted ? "memory" : "file";
|
|
3574
|
+
}
|
|
3575
|
+
function warnAboutSecretStorage(info) {
|
|
3576
|
+
if (info.reason === "fallback") {
|
|
3577
|
+
console.warn(
|
|
3578
|
+
`
|
|
3579
|
+
[mcp-inspector] The OS keychain is not available, so secrets will be kept in: ${secretStorageSummary(info)}.` + (info.detail ? `
|
|
3580
|
+
[mcp-inspector] Keychain error: ${info.detail}` : "") + `
|
|
3581
|
+
[mcp-inspector] Set ${SECRET_STORE_ENV}=keyring|file|memory to choose explicitly.`
|
|
2030
3582
|
);
|
|
2031
|
-
this.name = "KeychainUnavailableError";
|
|
2032
|
-
}
|
|
2033
|
-
};
|
|
2034
|
-
var hintFor = (cause, message) => {
|
|
2035
|
-
if (cause instanceof KeyringModuleShapeError) {
|
|
2036
|
-
return `The @napi-rs/keyring package loaded but does not expose the API this build expects \u2014 most likely a version or packaging mismatch; reinstall the Inspector, and report this if it persists.`;
|
|
2037
3583
|
}
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
3584
|
+
const caveat = secretStorageCaveat(info);
|
|
3585
|
+
if (caveat) console.warn(`[mcp-inspector] ${caveat}`);
|
|
3586
|
+
}
|
|
3587
|
+
var resolved;
|
|
3588
|
+
function resolveSecretStore() {
|
|
3589
|
+
resolved ??= (async () => {
|
|
3590
|
+
const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]);
|
|
3591
|
+
if (configured) {
|
|
3592
|
+
const result2 = await buildStore(configured, "configured");
|
|
3593
|
+
if (configured === "keyring") {
|
|
3594
|
+
await absorbFileSecretsIntoKeyring(result2.store);
|
|
3595
|
+
}
|
|
3596
|
+
warnAboutSecretStorage(result2.info);
|
|
3597
|
+
return result2;
|
|
3598
|
+
}
|
|
3599
|
+
const probe = await probeKeyringAvailable();
|
|
3600
|
+
if (probe.available) {
|
|
3601
|
+
const result2 = await buildStore("keyring", "default");
|
|
3602
|
+
await absorbFileSecretsIntoKeyring(result2.store);
|
|
3603
|
+
return result2;
|
|
3604
|
+
}
|
|
3605
|
+
const kind = chooseFallbackKind({
|
|
3606
|
+
container: isContainer(),
|
|
3607
|
+
mounted: isOnMountPoint(path4.dirname(defaultSecretFilePath()))
|
|
3608
|
+
});
|
|
3609
|
+
const result = await buildStore(kind, "fallback", probe.detail);
|
|
3610
|
+
warnAboutSecretStorage(result.info);
|
|
3611
|
+
return result;
|
|
3612
|
+
})();
|
|
3613
|
+
return resolved;
|
|
3614
|
+
}
|
|
3615
|
+
async function absorbFileSecretsIntoKeyring(keyring) {
|
|
3616
|
+
const filePath = defaultSecretFilePath();
|
|
3617
|
+
if (!await anythingToMigrate(filePath)) return;
|
|
3618
|
+
const claimed = await withSecretFileLock(filePath, async () => {
|
|
3619
|
+
await recoverOrphanedSnapshots(filePath);
|
|
3620
|
+
if (!fsSync.existsSync(filePath)) return null;
|
|
3621
|
+
const staged = `${filePath}.migrating-${process.pid}-${randomUUID()}`;
|
|
2045
3622
|
try {
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
const
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
3623
|
+
await fs4.rename(filePath, staged);
|
|
3624
|
+
} catch (err) {
|
|
3625
|
+
const code = err.code;
|
|
3626
|
+
if (code !== "ENOENT") {
|
|
3627
|
+
console.warn(
|
|
3628
|
+
`
|
|
3629
|
+
[mcp-inspector] Could not claim the secrets file at ${filePath} for migration into the OS keychain (${code ?? "unknown error"}), so it has been left in place. Its secrets are not visible to this session.`
|
|
3630
|
+
);
|
|
3631
|
+
}
|
|
2055
3632
|
return null;
|
|
2056
3633
|
}
|
|
3634
|
+
const releaseSnapshot = await openSecretFileLock(staged).catch(() => null);
|
|
3635
|
+
return { staged, releaseSnapshot };
|
|
3636
|
+
}).catch((err) => {
|
|
3637
|
+
console.warn(
|
|
3638
|
+
`
|
|
3639
|
+
[mcp-inspector] Could not lock the secrets file at ${filePath} to migrate it into the OS keychain (${err.message}), so it has been left in place. Its secrets are not visible to this session; the next run will try again.`
|
|
3640
|
+
);
|
|
3641
|
+
return null;
|
|
3642
|
+
});
|
|
3643
|
+
if (claimed === null) return;
|
|
3644
|
+
try {
|
|
3645
|
+
await handOffStagedSecrets(claimed.staged, filePath, keyring);
|
|
3646
|
+
} finally {
|
|
3647
|
+
await claimed.releaseSnapshot?.();
|
|
2057
3648
|
}
|
|
2058
|
-
|
|
3649
|
+
}
|
|
3650
|
+
async function handOffStagedSecrets(staged, filePath, keyring) {
|
|
3651
|
+
const file = new FileSecretStore({ filePath: staged });
|
|
3652
|
+
const absorbed = await handOff(file, staged, keyring);
|
|
3653
|
+
if (absorbed) {
|
|
2059
3654
|
try {
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
buildAccount(serverId, field)
|
|
3655
|
+
await fs4.rm(staged, { force: true });
|
|
3656
|
+
console.warn(
|
|
3657
|
+
`
|
|
3658
|
+
[mcp-inspector] The OS keychain is available again. Secrets kept in ${filePath} while it was not have been moved into the keychain, and the file has been removed.`
|
|
2065
3659
|
);
|
|
2066
|
-
await entry.setPassword(value);
|
|
2067
3660
|
} catch (err) {
|
|
2068
|
-
|
|
2069
|
-
|
|
3661
|
+
console.warn(
|
|
3662
|
+
`
|
|
3663
|
+
[mcp-inspector] Secrets were moved into the OS keychain, but the temporary copy at ${staged} could not be removed (${err instanceof Error ? err.message : String(err)}). Delete it once you have confirmed the keychain has your secrets.`
|
|
3664
|
+
);
|
|
2070
3665
|
}
|
|
3666
|
+
return;
|
|
2071
3667
|
}
|
|
2072
|
-
|
|
3668
|
+
try {
|
|
3669
|
+
await fs4.link(staged, filePath);
|
|
3670
|
+
await fs4.rm(staged, { force: true });
|
|
3671
|
+
} catch {
|
|
3672
|
+
console.warn(
|
|
3673
|
+
`
|
|
3674
|
+
[mcp-inspector] Could not restore ${staged} to ${filePath}; both files exist and the snapshot holds the secrets that were not migrated. Merge it by hand, or delete it once you have confirmed nothing is missing.`
|
|
3675
|
+
);
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
var isSnapshotName = (name, base) => name.startsWith(`${base}.migrating-`) && !name.endsWith(".lock");
|
|
3679
|
+
async function anythingToMigrate(filePath) {
|
|
3680
|
+
const base = path4.basename(filePath);
|
|
3681
|
+
try {
|
|
3682
|
+
return (await fs4.readdir(path4.dirname(filePath))).some(
|
|
3683
|
+
(name) => name === base || isSnapshotName(name, base)
|
|
3684
|
+
);
|
|
3685
|
+
} catch (err) {
|
|
3686
|
+
const code = err.code;
|
|
3687
|
+
return code !== "ENOENT" && code !== "ENOTDIR";
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
async function recoverOrphanedSnapshots(filePath) {
|
|
3691
|
+
const dir = path4.dirname(filePath);
|
|
3692
|
+
const base = path4.basename(filePath);
|
|
3693
|
+
let names;
|
|
3694
|
+
try {
|
|
3695
|
+
names = (await fs4.readdir(dir)).filter((n) => isSnapshotName(n, base));
|
|
3696
|
+
} catch {
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3699
|
+
for (const name of names) {
|
|
3700
|
+
const orphan = path4.join(dir, name);
|
|
3701
|
+
if (await isFileLockHeld(orphan)) continue;
|
|
2073
3702
|
try {
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
3703
|
+
await fs4.link(orphan, filePath);
|
|
3704
|
+
await fs4.rm(orphan, { force: true });
|
|
3705
|
+
console.warn(
|
|
3706
|
+
`
|
|
3707
|
+
[mcp-inspector] Recovered secrets left behind by an interrupted migration (${orphan} \u2192 ${filePath}).`
|
|
2079
3708
|
);
|
|
2080
|
-
await entry.deleteCredential();
|
|
2081
3709
|
} catch {
|
|
3710
|
+
console.warn(
|
|
3711
|
+
`
|
|
3712
|
+
[mcp-inspector] Found secrets from an interrupted migration at ${orphan}, and a current ${filePath} already exists, so it was left alone. It may hold values the current file does not.`
|
|
3713
|
+
);
|
|
2082
3714
|
}
|
|
2083
3715
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
3716
|
+
}
|
|
3717
|
+
async function handOff(file, filePath, keyring) {
|
|
3718
|
+
let entries;
|
|
3719
|
+
try {
|
|
3720
|
+
entries = await file.readAll();
|
|
3721
|
+
} catch (err) {
|
|
3722
|
+
console.warn(
|
|
3723
|
+
`
|
|
3724
|
+
[mcp-inspector] The OS keychain is available again, but the secrets file at ${filePath} could not be read, so its contents were left there: ${err instanceof Error ? err.message : String(err)}`
|
|
3725
|
+
);
|
|
3726
|
+
return false;
|
|
3727
|
+
}
|
|
3728
|
+
if (!entries || Object.keys(entries).length === 0) return false;
|
|
3729
|
+
let complete = true;
|
|
3730
|
+
try {
|
|
3731
|
+
for (const [account, value] of Object.entries(entries)) {
|
|
3732
|
+
const parsed = parseAccount(account);
|
|
3733
|
+
if (!parsed) {
|
|
3734
|
+
complete = false;
|
|
3735
|
+
continue;
|
|
3736
|
+
}
|
|
3737
|
+
const existing = await secretStoreGetStrict(
|
|
3738
|
+
keyring,
|
|
3739
|
+
parsed.serverId,
|
|
3740
|
+
parsed.field
|
|
3741
|
+
);
|
|
3742
|
+
if (existing === null) {
|
|
3743
|
+
await keyring.set(parsed.serverId, parsed.field, value);
|
|
3744
|
+
}
|
|
2099
3745
|
}
|
|
3746
|
+
} catch (err) {
|
|
3747
|
+
console.warn(
|
|
3748
|
+
`
|
|
3749
|
+
[mcp-inspector] Could not move the secrets file at ${filePath} into the OS keychain, so it has been left in place: ${err instanceof Error ? err.message : String(err)}`
|
|
3750
|
+
);
|
|
3751
|
+
return false;
|
|
3752
|
+
}
|
|
3753
|
+
if (!complete) {
|
|
3754
|
+
console.warn(
|
|
3755
|
+
`
|
|
3756
|
+
[mcp-inspector] Secrets from ${filePath} have been copied into the OS keychain, but at least one entry could not be read (its key is not in \`serverId:field\` form). The file has been left in place so nothing is lost.`
|
|
3757
|
+
);
|
|
3758
|
+
return false;
|
|
3759
|
+
}
|
|
3760
|
+
return true;
|
|
3761
|
+
}
|
|
3762
|
+
async function getSecretStorageInfo() {
|
|
3763
|
+
const { store, info } = await resolveSecretStore();
|
|
3764
|
+
if (info.kind !== "file" || !(store instanceof FileSecretStore)) return info;
|
|
3765
|
+
return describeFileStore(
|
|
3766
|
+
store,
|
|
3767
|
+
store.filePath,
|
|
3768
|
+
"file",
|
|
3769
|
+
info.reason,
|
|
3770
|
+
info.detail
|
|
3771
|
+
);
|
|
3772
|
+
}
|
|
3773
|
+
var DeferredSecretStore = class {
|
|
3774
|
+
async target() {
|
|
3775
|
+
return (await resolveSecretStore()).store;
|
|
3776
|
+
}
|
|
3777
|
+
async isDurable() {
|
|
3778
|
+
return secretStoreIsDurable(await this.target());
|
|
3779
|
+
}
|
|
3780
|
+
/**
|
|
3781
|
+
* Forwarded, and its absence was a real defect rather than an omission:
|
|
3782
|
+
* this is the store production actually uses, so a `secretStoreGetStrict`
|
|
3783
|
+
* call against it fell through to the tolerant `get` and the strictness
|
|
3784
|
+
* existed only in tests that injected a concrete store.
|
|
3785
|
+
*/
|
|
3786
|
+
async getStrict(serverId, field) {
|
|
3787
|
+
return secretStoreGetStrict(await this.target(), serverId, field);
|
|
3788
|
+
}
|
|
3789
|
+
async getMany(requests) {
|
|
3790
|
+
return secretStoreGetMany(await this.target(), requests);
|
|
3791
|
+
}
|
|
3792
|
+
async get(serverId, field) {
|
|
3793
|
+
return (await this.target()).get(serverId, field);
|
|
3794
|
+
}
|
|
3795
|
+
async set(serverId, field, value) {
|
|
3796
|
+
await (await this.target()).set(serverId, field, value);
|
|
3797
|
+
}
|
|
3798
|
+
async setMany(serverId, values) {
|
|
3799
|
+
await secretStoreSetMany(await this.target(), serverId, values);
|
|
3800
|
+
}
|
|
3801
|
+
async delete(serverId, field) {
|
|
3802
|
+
await (await this.target()).delete(serverId, field);
|
|
3803
|
+
}
|
|
3804
|
+
async deleteAllForServer(serverId) {
|
|
3805
|
+
await (await this.target()).deleteAllForServer(serverId);
|
|
2100
3806
|
}
|
|
2101
3807
|
};
|
|
3808
|
+
function defaultSecretStore() {
|
|
3809
|
+
return new DeferredSecretStore();
|
|
3810
|
+
}
|
|
2102
3811
|
|
|
2103
3812
|
// ../../core/client/config-parse.ts
|
|
2104
3813
|
import { z } from "zod";
|
|
@@ -2261,7 +3970,8 @@ async function migrateClientPlaintextSecret(filePath, config, secretStore) {
|
|
|
2261
3970
|
const value = secrets[SECRET_FIELD_IDP_CLIENT_SECRET];
|
|
2262
3971
|
if (!value) return config;
|
|
2263
3972
|
try {
|
|
2264
|
-
const existing = await
|
|
3973
|
+
const existing = await secretStoreGetStrict(
|
|
3974
|
+
secretStore,
|
|
2265
3975
|
CLIENT_KEYCHAIN_ID,
|
|
2266
3976
|
SECRET_FIELD_IDP_CLIENT_SECRET
|
|
2267
3977
|
);
|
|
@@ -2272,10 +3982,11 @@ async function migrateClientPlaintextSecret(filePath, config, secretStore) {
|
|
|
2272
3982
|
value
|
|
2273
3983
|
);
|
|
2274
3984
|
}
|
|
3985
|
+
if (!await secretStoreIsDurable(secretStore)) return config;
|
|
2275
3986
|
await writeStoreFile(filePath, serializeStore(stripped));
|
|
2276
3987
|
return stripped;
|
|
2277
3988
|
} catch (err) {
|
|
2278
|
-
if (err instanceof
|
|
3989
|
+
if (err instanceof SecretStoreUnavailableError) {
|
|
2279
3990
|
return config;
|
|
2280
3991
|
}
|
|
2281
3992
|
throw err;
|
|
@@ -2309,7 +4020,30 @@ async function writeClientConfigStore(filePath, body, secretStore) {
|
|
|
2309
4020
|
SECRET_FIELD_IDP_CLIENT_SECRET
|
|
2310
4021
|
);
|
|
2311
4022
|
}
|
|
2312
|
-
await
|
|
4023
|
+
const durable = await secretStoreIsDurable(secretStore);
|
|
4024
|
+
await writeStoreFile(
|
|
4025
|
+
filePath,
|
|
4026
|
+
serializeStore(
|
|
4027
|
+
durable ? stripped : await preserveLegacyPlaintext(
|
|
4028
|
+
filePath,
|
|
4029
|
+
validated,
|
|
4030
|
+
stripped,
|
|
4031
|
+
idpSecret
|
|
4032
|
+
)
|
|
4033
|
+
)
|
|
4034
|
+
);
|
|
4035
|
+
}
|
|
4036
|
+
async function preserveLegacyPlaintext(filePath, validated, stripped, idpSecret) {
|
|
4037
|
+
if (!idpSecret) return stripped;
|
|
4038
|
+
try {
|
|
4039
|
+
const raw = await readStoreFile(filePath);
|
|
4040
|
+
if (raw === null) return stripped;
|
|
4041
|
+
const prior = parseClientConfig(parseStore(raw));
|
|
4042
|
+
const priorSecret = extractSecretsFromClientConfig(prior).secrets[SECRET_FIELD_IDP_CLIENT_SECRET];
|
|
4043
|
+
return priorSecret === idpSecret ? validated : stripped;
|
|
4044
|
+
} catch {
|
|
4045
|
+
return stripped;
|
|
4046
|
+
}
|
|
2313
4047
|
}
|
|
2314
4048
|
async function deleteClientConfigStore(filePath, secretStore) {
|
|
2315
4049
|
await deleteStoreFile(filePath);
|
|
@@ -2319,6 +4053,30 @@ async function deleteClientConfigStore(filePath, secretStore) {
|
|
|
2319
4053
|
// ../../core/mcp/remote/node/server.ts
|
|
2320
4054
|
import { ZodError as ZodError2 } from "zod";
|
|
2321
4055
|
var SSE_PRIMING_COMMENT = ":\n\n";
|
|
4056
|
+
function closeAbortedStream(stream) {
|
|
4057
|
+
stream.close().catch(() => {
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
4060
|
+
async function primeAndHoldSseStream(stream, cleanup) {
|
|
4061
|
+
let done = false;
|
|
4062
|
+
const aborted = new Promise((resolve9) => {
|
|
4063
|
+
stream.onAbort(() => {
|
|
4064
|
+
if (done) return;
|
|
4065
|
+
done = true;
|
|
4066
|
+
cleanup();
|
|
4067
|
+
closeAbortedStream(stream);
|
|
4068
|
+
resolve9();
|
|
4069
|
+
});
|
|
4070
|
+
});
|
|
4071
|
+
await stream.write(SSE_PRIMING_COMMENT);
|
|
4072
|
+
await aborted;
|
|
4073
|
+
}
|
|
4074
|
+
var MAX_APP_DOCUMENT_CHARS = 8 * 1024 * 1024;
|
|
4075
|
+
var MAX_APP_DOCUMENT_BODY_BYTES = 24 * 1024 * 1024;
|
|
4076
|
+
var MAX_APP_CSP_CHARS = 8 * 1024;
|
|
4077
|
+
function isHeaderSafeValue(value) {
|
|
4078
|
+
return !/[^\t\x20-\x7e]/.test(value);
|
|
4079
|
+
}
|
|
2322
4080
|
function createOriginMiddleware(allowedOrigins) {
|
|
2323
4081
|
return async (c, next) => {
|
|
2324
4082
|
if (!allowedOrigins || allowedOrigins.length === 0) {
|
|
@@ -2453,13 +4211,13 @@ function mcpParamHeadersOnly(headers) {
|
|
|
2453
4211
|
}
|
|
2454
4212
|
function createRemoteApp(options) {
|
|
2455
4213
|
const dangerouslyOmitAuth = !!options.dangerouslyOmitAuth;
|
|
2456
|
-
const authToken = dangerouslyOmitAuth ? "" : options.authToken || process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] ||
|
|
4214
|
+
const authToken = dangerouslyOmitAuth ? "" : options.authToken || process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] || randomBytes3(32).toString("hex");
|
|
2457
4215
|
const app = new Hono();
|
|
2458
4216
|
const sessions = /* @__PURE__ */ new Map();
|
|
2459
4217
|
const { logger: fileLogger, allowedOrigins } = options;
|
|
2460
4218
|
const storageDir = options.storageDir ?? getDefaultStorageDir();
|
|
2461
4219
|
const mcpConfigPath = options.mcpConfigPath ?? getDefaultMcpConfigPath();
|
|
2462
|
-
const secretStore = options.secretStore ??
|
|
4220
|
+
const secretStore = options.secretStore ?? defaultSecretStore();
|
|
2463
4221
|
const writable = options.writable ?? true;
|
|
2464
4222
|
const inMemoryServers = options.initialServers;
|
|
2465
4223
|
const watchable = !inMemoryServers;
|
|
@@ -2543,14 +4301,66 @@ function createRemoteApp(options) {
|
|
|
2543
4301
|
if (!dangerouslyOmitAuth) {
|
|
2544
4302
|
app.use("*", createAuthMiddleware(authToken));
|
|
2545
4303
|
}
|
|
2546
|
-
app.get("/api/config", (c) => {
|
|
4304
|
+
app.get("/api/config", async (c) => {
|
|
4305
|
+
const secretStorage = options.secretStorageResolver ? await options.secretStorageResolver() : options.initialConfig?.secretStorage;
|
|
2547
4306
|
const payload = {
|
|
2548
4307
|
...options.initialConfig,
|
|
2549
4308
|
writable,
|
|
4309
|
+
// Assigned unconditionally, not spread-when-truthy. The resolver's
|
|
4310
|
+
// contract is that `undefined` means "not known right now", and a
|
|
4311
|
+
// conditional spread leaves the *startup* descriptor from
|
|
4312
|
+
// `initialConfig` standing in its place — so a store that became
|
|
4313
|
+
// undescribable would keep serving a stale, confident answer. Setting
|
|
4314
|
+
// it to `undefined` is what clears it; `c.json` omits the key.
|
|
4315
|
+
secretStorage,
|
|
2550
4316
|
...options.sandboxUrl ? { sandboxUrl: options.sandboxUrl } : {}
|
|
2551
4317
|
};
|
|
2552
4318
|
return c.json(payload);
|
|
2553
4319
|
});
|
|
4320
|
+
app.post(
|
|
4321
|
+
"/api/app-document",
|
|
4322
|
+
// Before the parse, not after: see MAX_APP_DOCUMENT_BODY_BYTES.
|
|
4323
|
+
bodyLimit({
|
|
4324
|
+
maxSize: MAX_APP_DOCUMENT_BODY_BYTES,
|
|
4325
|
+
onError: (c) => c.json({ error: "Document too large" }, 413)
|
|
4326
|
+
}),
|
|
4327
|
+
async (c) => {
|
|
4328
|
+
const publish = options.publishAppDocument;
|
|
4329
|
+
if (!publish) {
|
|
4330
|
+
return c.json({ error: "App-origin hosting is not available" }, 503);
|
|
4331
|
+
}
|
|
4332
|
+
let parsed;
|
|
4333
|
+
try {
|
|
4334
|
+
parsed = await c.req.json();
|
|
4335
|
+
} catch {
|
|
4336
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
4337
|
+
}
|
|
4338
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4339
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
4340
|
+
}
|
|
4341
|
+
const { html, csp } = parsed;
|
|
4342
|
+
if (typeof html !== "string" || html === "") {
|
|
4343
|
+
return c.json({ error: "Missing html" }, 400);
|
|
4344
|
+
}
|
|
4345
|
+
if (csp !== void 0 && typeof csp !== "string") {
|
|
4346
|
+
return c.json({ error: "csp must be a string" }, 400);
|
|
4347
|
+
}
|
|
4348
|
+
if (csp !== void 0 && !isHeaderSafeValue(csp)) {
|
|
4349
|
+
return c.json({ error: "csp is not a valid header value" }, 400);
|
|
4350
|
+
}
|
|
4351
|
+
if (html.length > MAX_APP_DOCUMENT_CHARS) {
|
|
4352
|
+
return c.json({ error: "Document too large" }, 413);
|
|
4353
|
+
}
|
|
4354
|
+
if (csp !== void 0 && csp.length > MAX_APP_CSP_CHARS) {
|
|
4355
|
+
return c.json({ error: "csp too large" }, 413);
|
|
4356
|
+
}
|
|
4357
|
+
const published = publish({ html, csp });
|
|
4358
|
+
if (!published) {
|
|
4359
|
+
return c.json({ error: "App-origin hosting is not available" }, 503);
|
|
4360
|
+
}
|
|
4361
|
+
return c.json(published);
|
|
4362
|
+
}
|
|
4363
|
+
);
|
|
2554
4364
|
app.post("/api/mcp/connect", async (c) => {
|
|
2555
4365
|
let body;
|
|
2556
4366
|
try {
|
|
@@ -2725,24 +4535,17 @@ function createRemoteApp(options) {
|
|
|
2725
4535
|
});
|
|
2726
4536
|
});
|
|
2727
4537
|
if (session.isTransportDead()) {
|
|
2728
|
-
await new Promise((
|
|
4538
|
+
await new Promise((resolve9) => setTimeout(resolve9, 0));
|
|
2729
4539
|
session.clearEventConsumer();
|
|
2730
4540
|
sessions.delete(sessionId);
|
|
2731
4541
|
return;
|
|
2732
4542
|
}
|
|
2733
|
-
await stream
|
|
2734
|
-
stream.onAbort(() => {
|
|
4543
|
+
await primeAndHoldSseStream(stream, () => {
|
|
2735
4544
|
const shouldCleanup = session.clearEventConsumer();
|
|
2736
|
-
stream.close();
|
|
2737
4545
|
if (shouldCleanup || session.isTransportDead()) {
|
|
2738
4546
|
sessions.delete(sessionId);
|
|
2739
4547
|
}
|
|
2740
4548
|
});
|
|
2741
|
-
await new Promise((resolve6) => {
|
|
2742
|
-
stream.onAbort(() => {
|
|
2743
|
-
resolve6();
|
|
2744
|
-
});
|
|
2745
|
-
});
|
|
2746
4549
|
});
|
|
2747
4550
|
});
|
|
2748
4551
|
app.post("/api/mcp/disconnect", async (c) => {
|
|
@@ -2803,7 +4606,7 @@ function createRemoteApp(options) {
|
|
|
2803
4606
|
return c.json({ error: "Missing url" }, 400);
|
|
2804
4607
|
}
|
|
2805
4608
|
try {
|
|
2806
|
-
const res = await fetch(url, {
|
|
4609
|
+
const res = await (createProxyFetch() ?? fetch)(url, {
|
|
2807
4610
|
method,
|
|
2808
4611
|
headers: new Headers(headers),
|
|
2809
4612
|
body: reqBody
|
|
@@ -2914,6 +4717,7 @@ function createRemoteApp(options) {
|
|
|
2914
4717
|
console.warn("[mcp.json]", msg, bindings);
|
|
2915
4718
|
}
|
|
2916
4719
|
};
|
|
4720
|
+
const isJsonObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2917
4721
|
const isStringRecord = (v) => {
|
|
2918
4722
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
2919
4723
|
for (const val of Object.values(v)) {
|
|
@@ -2930,12 +4734,27 @@ function createRemoteApp(options) {
|
|
|
2930
4734
|
const isOauthObject = (v) => {
|
|
2931
4735
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
2932
4736
|
const o = v;
|
|
2933
|
-
for (const k of [
|
|
4737
|
+
for (const k of [
|
|
4738
|
+
"clientId",
|
|
4739
|
+
"clientSecret",
|
|
4740
|
+
"scopes",
|
|
4741
|
+
// #1906 — shape only; the values are validated as absolute http(s) URLs
|
|
4742
|
+
// where they're applied (core/auth/endpointOverrides.ts), so a typo drops
|
|
4743
|
+
// one field with a warning instead of dropping the whole `oauth` block.
|
|
4744
|
+
"authorizationUrl",
|
|
4745
|
+
"tokenUrl"
|
|
4746
|
+
]) {
|
|
2934
4747
|
if (o[k] !== void 0 && typeof o[k] !== "string") return false;
|
|
2935
4748
|
}
|
|
4749
|
+
if (o.authorizationParams !== void 0 && !isStringRecord(o.authorizationParams)) {
|
|
4750
|
+
return false;
|
|
4751
|
+
}
|
|
2936
4752
|
if (o.enterpriseManaged !== void 0 && typeof o.enterpriseManaged !== "boolean") {
|
|
2937
4753
|
return false;
|
|
2938
4754
|
}
|
|
4755
|
+
if (o.requestRefreshToken !== void 0 && typeof o.requestRefreshToken !== "boolean") {
|
|
4756
|
+
return false;
|
|
4757
|
+
}
|
|
2939
4758
|
return true;
|
|
2940
4759
|
};
|
|
2941
4760
|
const isRootArray = (v) => {
|
|
@@ -2969,10 +4788,10 @@ function createRemoteApp(options) {
|
|
|
2969
4788
|
);
|
|
2970
4789
|
delete valObj.headers;
|
|
2971
4790
|
}
|
|
2972
|
-
if ("metadata" in valObj && !isKvArray(valObj.metadata)) {
|
|
4791
|
+
if ("metadata" in valObj && !isJsonObject(valObj.metadata) && !isKvArray(valObj.metadata)) {
|
|
2973
4792
|
logWarn(
|
|
2974
4793
|
{ route: "/api/servers", id, droppedKey: "metadata" },
|
|
2975
|
-
"Dropping malformed `metadata` field \u2014 expected
|
|
4794
|
+
"Dropping malformed `metadata` field \u2014 expected a JSON object."
|
|
2976
4795
|
);
|
|
2977
4796
|
delete valObj.metadata;
|
|
2978
4797
|
}
|
|
@@ -3007,7 +4826,7 @@ function createRemoteApp(options) {
|
|
|
3007
4826
|
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
|
3008
4827
|
logWarn(
|
|
3009
4828
|
{ route: "/api/servers", id, droppedKey: "oauth" },
|
|
3010
|
-
"Dropping malformed `oauth` field \u2014 expected `{ clientId?, clientSecret?, scopes?, enterpriseManaged? }`."
|
|
4829
|
+
"Dropping malformed `oauth` field \u2014 expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken? }`."
|
|
3011
4830
|
);
|
|
3012
4831
|
delete valObj.oauth;
|
|
3013
4832
|
}
|
|
@@ -3070,16 +4889,15 @@ function createRemoteApp(options) {
|
|
|
3070
4889
|
const applyStdioSettingsToConfig = (entry, settings, provided) => {
|
|
3071
4890
|
if (!(entry.type === "stdio" || entry.type === void 0)) return;
|
|
3072
4891
|
const stdio = entry;
|
|
4892
|
+
const { env, cwd } = stdioConfigFieldsFromSettings(settings);
|
|
3073
4893
|
if (provided.env) {
|
|
3074
|
-
|
|
3075
|
-
if (Object.keys(env).length > 0) {
|
|
4894
|
+
if (env) {
|
|
3076
4895
|
stdio.env = env;
|
|
3077
4896
|
} else {
|
|
3078
4897
|
delete stdio.env;
|
|
3079
4898
|
}
|
|
3080
4899
|
}
|
|
3081
4900
|
if (provided.cwd) {
|
|
3082
|
-
const cwd = settings.cwd?.trim();
|
|
3083
4901
|
if (cwd) {
|
|
3084
4902
|
stdio.cwd = cwd;
|
|
3085
4903
|
} else {
|
|
@@ -3110,10 +4928,10 @@ function createRemoteApp(options) {
|
|
|
3110
4928
|
error: "settings.headers must be an array of { key, value }"
|
|
3111
4929
|
};
|
|
3112
4930
|
}
|
|
3113
|
-
if (!
|
|
4931
|
+
if (!isJsonObject(obj.metadata)) {
|
|
3114
4932
|
return {
|
|
3115
4933
|
ok: false,
|
|
3116
|
-
error: "settings.metadata must be
|
|
4934
|
+
error: "settings.metadata must be a JSON object"
|
|
3117
4935
|
};
|
|
3118
4936
|
}
|
|
3119
4937
|
if (obj.env !== void 0 && !isKvArray2(obj.env)) {
|
|
@@ -3164,18 +4982,34 @@ function createRemoteApp(options) {
|
|
|
3164
4982
|
for (const optional of [
|
|
3165
4983
|
"oauthClientId",
|
|
3166
4984
|
"oauthClientSecret",
|
|
3167
|
-
"oauthScopes"
|
|
4985
|
+
"oauthScopes",
|
|
4986
|
+
// #1906 — string-shaped on the wire; URL validity is checked where the
|
|
4987
|
+
// override is applied, not here.
|
|
4988
|
+
"oauthAuthorizationUrl",
|
|
4989
|
+
"oauthTokenUrl"
|
|
3168
4990
|
]) {
|
|
3169
4991
|
if (obj[optional] !== void 0 && typeof obj[optional] !== "string") {
|
|
3170
4992
|
return { ok: false, error: `settings.${optional} must be a string` };
|
|
3171
4993
|
}
|
|
3172
4994
|
}
|
|
4995
|
+
if (obj.oauthAuthorizationParams !== void 0 && !isKvArray2(obj.oauthAuthorizationParams)) {
|
|
4996
|
+
return {
|
|
4997
|
+
ok: false,
|
|
4998
|
+
error: "settings.oauthAuthorizationParams must be an array of { key, value }"
|
|
4999
|
+
};
|
|
5000
|
+
}
|
|
3173
5001
|
if (obj.enterpriseManaged !== void 0 && typeof obj.enterpriseManaged !== "boolean") {
|
|
3174
5002
|
return {
|
|
3175
5003
|
ok: false,
|
|
3176
5004
|
error: "settings.enterpriseManaged must be a boolean"
|
|
3177
5005
|
};
|
|
3178
5006
|
}
|
|
5007
|
+
if (obj.oauthRequestRefreshToken !== void 0 && typeof obj.oauthRequestRefreshToken !== "boolean") {
|
|
5008
|
+
return {
|
|
5009
|
+
ok: false,
|
|
5010
|
+
error: "settings.oauthRequestRefreshToken must be a boolean"
|
|
5011
|
+
};
|
|
5012
|
+
}
|
|
3179
5013
|
if (obj.oauthOnInsufficientScope !== void 0 && obj.oauthOnInsufficientScope !== "reauthorize" && obj.oauthOnInsufficientScope !== "throw") {
|
|
3180
5014
|
return {
|
|
3181
5015
|
ok: false,
|
|
@@ -3243,9 +5077,21 @@ function createRemoteApp(options) {
|
|
|
3243
5077
|
if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") {
|
|
3244
5078
|
value.oauthScopes = obj.oauthScopes;
|
|
3245
5079
|
}
|
|
5080
|
+
if (isKvArray2(obj.oauthAuthorizationParams)) {
|
|
5081
|
+
value.oauthAuthorizationParams = obj.oauthAuthorizationParams;
|
|
5082
|
+
}
|
|
5083
|
+
if (typeof obj.oauthAuthorizationUrl === "string" && obj.oauthAuthorizationUrl !== "") {
|
|
5084
|
+
value.oauthAuthorizationUrl = obj.oauthAuthorizationUrl;
|
|
5085
|
+
}
|
|
5086
|
+
if (typeof obj.oauthTokenUrl === "string" && obj.oauthTokenUrl !== "") {
|
|
5087
|
+
value.oauthTokenUrl = obj.oauthTokenUrl;
|
|
5088
|
+
}
|
|
3246
5089
|
if (obj.enterpriseManaged === true) {
|
|
3247
5090
|
value.enterpriseManaged = true;
|
|
3248
5091
|
}
|
|
5092
|
+
if (obj.oauthRequestRefreshToken === false) {
|
|
5093
|
+
value.oauthRequestRefreshToken = false;
|
|
5094
|
+
}
|
|
3249
5095
|
if (obj.oauthOnInsufficientScope === "reauthorize" || obj.oauthOnInsufficientScope === "throw") {
|
|
3250
5096
|
value.oauthOnInsufficientScope = obj.oauthOnInsufficientScope;
|
|
3251
5097
|
}
|
|
@@ -3290,23 +5136,13 @@ function createRemoteApp(options) {
|
|
|
3290
5136
|
return { mcpServers: normalizeMcpServers(parsed?.mcpServers) };
|
|
3291
5137
|
};
|
|
3292
5138
|
const writeKeychainEntriesFor = async (id, secrets) => {
|
|
3293
|
-
await
|
|
3294
|
-
Object.entries(secrets).map(
|
|
3295
|
-
([field, value]) => secretStore.set(id, field, value)
|
|
3296
|
-
)
|
|
3297
|
-
);
|
|
5139
|
+
await secretStoreSetMany(secretStore, id, secrets);
|
|
3298
5140
|
};
|
|
3299
5141
|
const readKeychainEntriesFor = async (id, fields) => {
|
|
3300
|
-
const
|
|
3301
|
-
fields
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
);
|
|
3305
|
-
const out = {};
|
|
3306
|
-
for (const [field, v] of values) {
|
|
3307
|
-
if (v !== null) out[field] = v;
|
|
3308
|
-
}
|
|
3309
|
-
return out;
|
|
5142
|
+
const many = await secretStoreGetMany(secretStore, [
|
|
5143
|
+
{ serverId: id, fields }
|
|
5144
|
+
]);
|
|
5145
|
+
return many[id] ?? {};
|
|
3310
5146
|
};
|
|
3311
5147
|
const hasPlaintextSecrets = (config) => {
|
|
3312
5148
|
for (const stored of Object.values(config.mcpServers)) {
|
|
@@ -3315,9 +5151,18 @@ function createRemoteApp(options) {
|
|
|
3315
5151
|
}
|
|
3316
5152
|
return false;
|
|
3317
5153
|
};
|
|
5154
|
+
let warnedSessionPreserved = false;
|
|
3318
5155
|
const migratePlaintextSecrets = async (config) => {
|
|
3319
5156
|
let changed = false;
|
|
3320
5157
|
const next = { mcpServers: {} };
|
|
5158
|
+
const durable = await secretStoreIsDurable(secretStore);
|
|
5159
|
+
const warnSessionPreserved = () => {
|
|
5160
|
+
if (warnedSessionPreserved || durable || !fileLogger) return;
|
|
5161
|
+
warnedSessionPreserved = true;
|
|
5162
|
+
fileLogger.warn(
|
|
5163
|
+
"Secrets are kept in memory for this session, so plaintext values in mcp.json are left on disk rather than migrated away. They would otherwise be lost when the Inspector exits."
|
|
5164
|
+
);
|
|
5165
|
+
};
|
|
3321
5166
|
try {
|
|
3322
5167
|
for (const [id, stored] of Object.entries(config.mcpServers)) {
|
|
3323
5168
|
const { stripped, secrets } = extractSecretsFromStored(stored);
|
|
@@ -3326,20 +5171,25 @@ function createRemoteApp(options) {
|
|
|
3326
5171
|
continue;
|
|
3327
5172
|
}
|
|
3328
5173
|
for (const [field, value] of Object.entries(secrets)) {
|
|
3329
|
-
const existing = await secretStore
|
|
5174
|
+
const existing = await secretStoreGetStrict(secretStore, id, field);
|
|
3330
5175
|
if (existing === null) {
|
|
3331
5176
|
await secretStore.set(id, field, value);
|
|
3332
5177
|
}
|
|
3333
5178
|
}
|
|
5179
|
+
if (!durable) {
|
|
5180
|
+
warnSessionPreserved();
|
|
5181
|
+
next.mcpServers[id] = stored;
|
|
5182
|
+
continue;
|
|
5183
|
+
}
|
|
3334
5184
|
next.mcpServers[id] = stripped;
|
|
3335
5185
|
changed = true;
|
|
3336
5186
|
}
|
|
3337
5187
|
} catch (err) {
|
|
3338
|
-
if (err instanceof
|
|
5188
|
+
if (err instanceof SecretStoreUnavailableError) {
|
|
3339
5189
|
if (fileLogger) {
|
|
3340
5190
|
fileLogger.warn(
|
|
3341
5191
|
{ err: err.message },
|
|
3342
|
-
"
|
|
5192
|
+
"Secret store unavailable; skipping plaintext-secret migration on this read. Existing mcp.json plaintext values are preserved."
|
|
3343
5193
|
);
|
|
3344
5194
|
}
|
|
3345
5195
|
return { migrated: config, changed: false };
|
|
@@ -3349,22 +5199,20 @@ function createRemoteApp(options) {
|
|
|
3349
5199
|
return { migrated: next, changed };
|
|
3350
5200
|
};
|
|
3351
5201
|
const rehydrateConfig = async (config) => {
|
|
5202
|
+
const entries = Object.entries(config.mcpServers);
|
|
5203
|
+
const secrets = await secretStoreGetMany(
|
|
5204
|
+
secretStore,
|
|
5205
|
+
entries.map(([serverId, stored]) => ({
|
|
5206
|
+
serverId,
|
|
5207
|
+
fields: expectedSecretFields(stored)
|
|
5208
|
+
}))
|
|
5209
|
+
);
|
|
3352
5210
|
const out = { mcpServers: {} };
|
|
3353
|
-
for (const [id, stored] of
|
|
3354
|
-
|
|
3355
|
-
const secrets = await readKeychainEntriesFor(id, fields);
|
|
3356
|
-
out.mcpServers[id] = mergeSecretsIntoStored(stored, secrets);
|
|
5211
|
+
for (const [id, stored] of entries) {
|
|
5212
|
+
out.mcpServers[id] = mergeSecretsIntoStored(stored, secrets[id] ?? {});
|
|
3357
5213
|
}
|
|
3358
5214
|
return out;
|
|
3359
5215
|
};
|
|
3360
|
-
const computeObsoleteFields = (previousFields, nextSecrets) => {
|
|
3361
|
-
const nextFieldSet = new Set(Object.keys(nextSecrets));
|
|
3362
|
-
const obsolete = [];
|
|
3363
|
-
for (const field of previousFields) {
|
|
3364
|
-
if (!nextFieldSet.has(field)) obsolete.push(field);
|
|
3365
|
-
}
|
|
3366
|
-
return obsolete;
|
|
3367
|
-
};
|
|
3368
5216
|
const mergeRenameKeychainSecrets = (stripped, keychainSecrets, nextSecrets) => {
|
|
3369
5217
|
const allowedFields = new Set(expectedSecretFields(stripped));
|
|
3370
5218
|
const merged = { ...keychainSecrets, ...nextSecrets };
|
|
@@ -3377,8 +5225,18 @@ function createRemoteApp(options) {
|
|
|
3377
5225
|
const deleteKeychainFields = async (id, fields) => {
|
|
3378
5226
|
await Promise.all(fields.map((field) => secretStore.delete(id, field)));
|
|
3379
5227
|
};
|
|
5228
|
+
const entryForDisk = async (built, stripped, previous) => {
|
|
5229
|
+
if (await secretStoreIsDurable(secretStore)) return stripped;
|
|
5230
|
+
const prior = previous ? extractSecretsFromStored(previous).secrets : {};
|
|
5231
|
+
const submitted = extractSecretsFromStored(built).secrets;
|
|
5232
|
+
const carry = {};
|
|
5233
|
+
for (const [field, value] of Object.entries(submitted)) {
|
|
5234
|
+
if (prior[field] === value) carry[field] = value;
|
|
5235
|
+
}
|
|
5236
|
+
return Object.keys(carry).length > 0 ? mergeSecretsIntoStored(stripped, carry) : stripped;
|
|
5237
|
+
};
|
|
3380
5238
|
const keychainErrorResponse = (c, err) => {
|
|
3381
|
-
if (err instanceof
|
|
5239
|
+
if (err instanceof SecretStoreUnavailableError) {
|
|
3382
5240
|
return c.json({ error: err.message }, 503);
|
|
3383
5241
|
}
|
|
3384
5242
|
return void 0;
|
|
@@ -3441,7 +5299,7 @@ function createRemoteApp(options) {
|
|
|
3441
5299
|
type,
|
|
3442
5300
|
process.platform,
|
|
3443
5301
|
homedir(),
|
|
3444
|
-
(
|
|
5302
|
+
(path6) => existsSync3(path6) ? readFileSync5(path6, "utf-8") : null
|
|
3445
5303
|
);
|
|
3446
5304
|
if (!result) {
|
|
3447
5305
|
return c.json(
|
|
@@ -3488,7 +5346,7 @@ function createRemoteApp(options) {
|
|
|
3488
5346
|
const { stripped, secrets } = extractSecretsFromStored(built);
|
|
3489
5347
|
await secretStore.deleteAllForServer(id);
|
|
3490
5348
|
await writeKeychainEntriesFor(id, secrets);
|
|
3491
|
-
current.mcpServers[id] = stripped;
|
|
5349
|
+
current.mcpServers[id] = await entryForDisk(built, stripped);
|
|
3492
5350
|
await writeMcpAndTrackMtime(serializeStore(current));
|
|
3493
5351
|
return c.json({ ok: true });
|
|
3494
5352
|
});
|
|
@@ -3628,10 +5486,11 @@ function createRemoteApp(options) {
|
|
|
3628
5486
|
}
|
|
3629
5487
|
}
|
|
3630
5488
|
const { stripped, secrets } = extractSecretsFromStored(built);
|
|
5489
|
+
const onDisk = await entryForDisk(built, stripped, existing);
|
|
3631
5490
|
const next = { mcpServers: {} };
|
|
3632
5491
|
for (const [key, val] of Object.entries(current.mcpServers)) {
|
|
3633
5492
|
if (key === originalId) {
|
|
3634
|
-
next.mcpServers[newId] =
|
|
5493
|
+
next.mcpServers[newId] = onDisk;
|
|
3635
5494
|
} else {
|
|
3636
5495
|
next.mcpServers[key] = val;
|
|
3637
5496
|
}
|
|
@@ -3652,7 +5511,10 @@ function createRemoteApp(options) {
|
|
|
3652
5511
|
await secretStore.deleteAllForServer(originalId);
|
|
3653
5512
|
} else {
|
|
3654
5513
|
const previousFields = new Set(expectedSecretFields(existing));
|
|
3655
|
-
const
|
|
5514
|
+
const stillExpected = new Set(expectedSecretFields(built));
|
|
5515
|
+
const obsolete = [...previousFields].filter(
|
|
5516
|
+
(field) => field === SECRET_FIELD_OAUTH_CLIENT_SECRET ? settingsIntent.kind !== "preserve" && !(field in secrets) : !stillExpected.has(field)
|
|
5517
|
+
);
|
|
3656
5518
|
await writeKeychainEntriesFor(newId, secrets);
|
|
3657
5519
|
await writeMcpAndTrackMtime(serializeStore(next));
|
|
3658
5520
|
await deleteKeychainFields(newId, obsolete);
|
|
@@ -3701,14 +5563,9 @@ function createRemoteApp(options) {
|
|
|
3701
5563
|
serverEventSubscribers.add(send);
|
|
3702
5564
|
ensureWatcher();
|
|
3703
5565
|
}
|
|
3704
|
-
await stream
|
|
3705
|
-
stream.onAbort(() => {
|
|
5566
|
+
await primeAndHoldSseStream(stream, () => {
|
|
3706
5567
|
serverEventSubscribers.delete(send);
|
|
3707
5568
|
void maybeStopWatcher();
|
|
3708
|
-
stream.close();
|
|
3709
|
-
});
|
|
3710
|
-
await new Promise((resolve6) => {
|
|
3711
|
-
stream.onAbort(() => resolve6());
|
|
3712
5569
|
});
|
|
3713
5570
|
});
|
|
3714
5571
|
});
|
|
@@ -3773,6 +5630,16 @@ function honoMiddlewarePlugin(config) {
|
|
|
3773
5630
|
allowedOrigins: config.allowedOrigins
|
|
3774
5631
|
});
|
|
3775
5632
|
await sandboxController.start();
|
|
5633
|
+
const appOriginController = createAppOriginController({
|
|
5634
|
+
port: config.appOriginPort,
|
|
5635
|
+
host: config.sandboxHost,
|
|
5636
|
+
embedderOrigins: appDocumentEmbedders(
|
|
5637
|
+
sandboxController.getUrl(),
|
|
5638
|
+
config.allowedOrigins
|
|
5639
|
+
)
|
|
5640
|
+
});
|
|
5641
|
+
await appOriginController.start();
|
|
5642
|
+
const secretStorage = await getSecretStorageInfo();
|
|
3776
5643
|
const {
|
|
3777
5644
|
app: honoApp,
|
|
3778
5645
|
authToken: resolvedToken,
|
|
@@ -3786,14 +5653,21 @@ function honoMiddlewarePlugin(config) {
|
|
|
3786
5653
|
initialServers: config.initialServers ?? void 0,
|
|
3787
5654
|
allowedOrigins: config.allowedOrigins,
|
|
3788
5655
|
sandboxUrl: sandboxController.getUrl() ?? void 0,
|
|
5656
|
+
publishAppDocument: (doc) => appOriginController.publish(doc),
|
|
3789
5657
|
logger: config.logger,
|
|
3790
|
-
initialConfig: webServerConfigToInitialPayload(config)
|
|
5658
|
+
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
|
5659
|
+
// The startup value above is what the banner printed; the route
|
|
5660
|
+
// re-resolves per request, because the first write under a newly-set
|
|
5661
|
+
// passphrase encrypts a pre-existing plaintext file and a captured
|
|
5662
|
+
// descriptor would keep reporting the old state until a restart.
|
|
5663
|
+
secretStorageResolver: getSecretStorageInfo
|
|
3791
5664
|
});
|
|
3792
5665
|
resolvedAuthToken = config.dangerouslyOmitAuth ? "" : resolvedToken;
|
|
3793
5666
|
const originalClose = server.close.bind(server);
|
|
3794
5667
|
server.close = async () => {
|
|
3795
5668
|
await closeApi();
|
|
3796
5669
|
await sandboxController.close();
|
|
5670
|
+
await appOriginController.close();
|
|
3797
5671
|
return originalClose();
|
|
3798
5672
|
};
|
|
3799
5673
|
const sandboxUrl = sandboxController.getUrl();
|
|
@@ -3804,10 +5678,11 @@ function honoMiddlewarePlugin(config) {
|
|
|
3804
5678
|
config,
|
|
3805
5679
|
actualPort,
|
|
3806
5680
|
resolvedToken,
|
|
3807
|
-
sandboxUrl ?? void 0
|
|
5681
|
+
sandboxUrl ?? void 0,
|
|
5682
|
+
secretStorage
|
|
3808
5683
|
);
|
|
3809
5684
|
if (config.autoOpen) {
|
|
3810
|
-
|
|
5685
|
+
openBrowser(url);
|
|
3811
5686
|
}
|
|
3812
5687
|
};
|
|
3813
5688
|
server.httpServer.once("listening", () => {
|
|
@@ -3842,10 +5717,10 @@ function honoMiddlewarePlugin(config) {
|
|
|
3842
5717
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
3843
5718
|
const chunks = [];
|
|
3844
5719
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
3845
|
-
await new Promise((
|
|
3846
|
-
req.once("end", () =>
|
|
3847
|
-
req.once("error", () =>
|
|
3848
|
-
req.once("close", () =>
|
|
5720
|
+
await new Promise((resolve9) => {
|
|
5721
|
+
req.once("end", () => resolve9());
|
|
5722
|
+
req.once("error", () => resolve9());
|
|
5723
|
+
req.once("close", () => resolve9());
|
|
3849
5724
|
});
|
|
3850
5725
|
if (chunks.length > 0) {
|
|
3851
5726
|
init.body = Buffer.concat(chunks);
|
|
@@ -3887,10 +5762,10 @@ function honoMiddlewarePlugin(config) {
|
|
|
3887
5762
|
while (true) {
|
|
3888
5763
|
const { done, value } = await reader.read();
|
|
3889
5764
|
if (done || clientGone || res.destroyed) break;
|
|
3890
|
-
await new Promise((
|
|
5765
|
+
await new Promise((resolve9, reject) => {
|
|
3891
5766
|
res.write(
|
|
3892
5767
|
Buffer.from(value),
|
|
3893
|
-
(err) => err ? reject(err) :
|
|
5768
|
+
(err) => err ? reject(err) : resolve9()
|
|
3894
5769
|
);
|
|
3895
5770
|
});
|
|
3896
5771
|
}
|
|
@@ -3924,7 +5799,7 @@ function honoMiddlewarePlugin(config) {
|
|
|
3924
5799
|
|
|
3925
5800
|
// server/vite-base-config.ts
|
|
3926
5801
|
import { rmSync } from "fs";
|
|
3927
|
-
import { join as
|
|
5802
|
+
import { join as join6 } from "path";
|
|
3928
5803
|
var NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [
|
|
3929
5804
|
"@modelcontextprotocol/client/stdio",
|
|
3930
5805
|
// `atomically` is reached only through `core/storage/store-io.ts`,
|
|
@@ -3947,7 +5822,15 @@ var NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [
|
|
|
3947
5822
|
// (#1905). It's a native-binding package (no browser code path) so
|
|
3948
5823
|
// excluding it keeps Vite's dep scanner from chasing into the
|
|
3949
5824
|
// platform-specific binaries during dev startup.
|
|
3950
|
-
"@napi-rs/keyring"
|
|
5825
|
+
"@napi-rs/keyring",
|
|
5826
|
+
// `proper-lockfile` is reached only through `core/auth/node/file-lock.ts`
|
|
5827
|
+
// — the secrets file's cross-process lock (#2082) — which the Hono
|
|
5828
|
+
// `/api/servers` handlers pull in via `core/auth/node/file-secret-store.ts`.
|
|
5829
|
+
// Same node-only import chain as `atomically` above, and the same reason:
|
|
5830
|
+
// it is CJS with a `graceful-fs`/`signal-exit`/`retry` graph that Vite's
|
|
5831
|
+
// dev scanner has no business walking. Note the tsup `external` lists do
|
|
5832
|
+
// **not** cover this — they configure the production bundles, not `vite dev`.
|
|
5833
|
+
"proper-lockfile"
|
|
3951
5834
|
];
|
|
3952
5835
|
function getViteDevOptimizeDeps() {
|
|
3953
5836
|
return {
|
|
@@ -3958,21 +5841,21 @@ function getViteDevOptimizeDeps() {
|
|
|
3958
5841
|
};
|
|
3959
5842
|
}
|
|
3960
5843
|
function clearViteDepsCache(clientWebRoot) {
|
|
3961
|
-
rmSync(
|
|
5844
|
+
rmSync(join6(clientWebRoot, "node_modules", ".vite"), {
|
|
3962
5845
|
recursive: true,
|
|
3963
5846
|
force: true
|
|
3964
5847
|
});
|
|
3965
5848
|
}
|
|
3966
5849
|
|
|
3967
5850
|
// ../../vitest.shared.mts
|
|
3968
|
-
import
|
|
5851
|
+
import path5 from "path";
|
|
3969
5852
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
3970
5853
|
function vitestSharedPaths(clientDir) {
|
|
3971
|
-
const
|
|
3972
|
-
const repoRoot =
|
|
5854
|
+
const dirname10 = path5.resolve(clientDir);
|
|
5855
|
+
const repoRoot = path5.resolve(dirname10, "../..");
|
|
3973
5856
|
const sharedAliases = {
|
|
3974
|
-
"@inspector/core":
|
|
3975
|
-
"@modelcontextprotocol/inspector-test-server":
|
|
5857
|
+
"@inspector/core": path5.resolve(repoRoot, "core"),
|
|
5858
|
+
"@modelcontextprotocol/inspector-test-server": path5.resolve(
|
|
3976
5859
|
repoRoot,
|
|
3977
5860
|
"test-servers/build/index.js"
|
|
3978
5861
|
)
|
|
@@ -3986,58 +5869,58 @@ function vitestSharedPaths(clientDir) {
|
|
|
3986
5869
|
const nodeModulesAliases = [
|
|
3987
5870
|
{
|
|
3988
5871
|
find: /^react$/,
|
|
3989
|
-
replacement:
|
|
5872
|
+
replacement: path5.resolve(dirname10, "node_modules/react")
|
|
3990
5873
|
},
|
|
3991
5874
|
{
|
|
3992
5875
|
find: /^react\/jsx-runtime$/,
|
|
3993
|
-
replacement:
|
|
5876
|
+
replacement: path5.resolve(dirname10, "node_modules/react/jsx-runtime.js")
|
|
3994
5877
|
},
|
|
3995
5878
|
{
|
|
3996
5879
|
find: /^react\/jsx-dev-runtime$/,
|
|
3997
|
-
replacement:
|
|
3998
|
-
|
|
5880
|
+
replacement: path5.resolve(
|
|
5881
|
+
dirname10,
|
|
3999
5882
|
"node_modules/react/jsx-dev-runtime.js"
|
|
4000
5883
|
)
|
|
4001
5884
|
},
|
|
4002
5885
|
{
|
|
4003
5886
|
find: /^react-dom$/,
|
|
4004
|
-
replacement:
|
|
5887
|
+
replacement: path5.resolve(dirname10, "node_modules/react-dom")
|
|
4005
5888
|
},
|
|
4006
5889
|
{
|
|
4007
5890
|
find: /^react-dom\/client$/,
|
|
4008
|
-
replacement:
|
|
5891
|
+
replacement: path5.resolve(dirname10, "node_modules/react-dom/client.js")
|
|
4009
5892
|
},
|
|
4010
|
-
{ find: /^pino$/, replacement:
|
|
5893
|
+
{ find: /^pino$/, replacement: path5.resolve(dirname10, "node_modules/pino") },
|
|
4011
5894
|
{
|
|
4012
5895
|
find: /^pino\/browser\.js$/,
|
|
4013
|
-
replacement:
|
|
5896
|
+
replacement: path5.resolve(dirname10, "node_modules/pino/browser.js")
|
|
4014
5897
|
},
|
|
4015
5898
|
{
|
|
4016
5899
|
find: /^hono$/,
|
|
4017
|
-
replacement:
|
|
5900
|
+
replacement: path5.resolve(dirname10, "node_modules/hono/dist/index.js")
|
|
4018
5901
|
},
|
|
4019
5902
|
{
|
|
4020
5903
|
find: /^hono\/streaming$/,
|
|
4021
|
-
replacement:
|
|
4022
|
-
|
|
5904
|
+
replacement: path5.resolve(
|
|
5905
|
+
dirname10,
|
|
4023
5906
|
"node_modules/hono/dist/helper/streaming/index.js"
|
|
4024
5907
|
)
|
|
4025
5908
|
},
|
|
4026
5909
|
{
|
|
4027
5910
|
find: /^@hono\/node-server$/,
|
|
4028
|
-
replacement:
|
|
5911
|
+
replacement: path5.resolve(dirname10, "node_modules/@hono/node-server")
|
|
4029
5912
|
},
|
|
4030
5913
|
{
|
|
4031
5914
|
find: /^atomically$/,
|
|
4032
|
-
replacement:
|
|
5915
|
+
replacement: path5.resolve(dirname10, "node_modules/atomically")
|
|
4033
5916
|
},
|
|
4034
5917
|
{
|
|
4035
5918
|
find: /^chokidar$/,
|
|
4036
|
-
replacement:
|
|
5919
|
+
replacement: path5.resolve(dirname10, "node_modules/chokidar")
|
|
4037
5920
|
},
|
|
4038
5921
|
{
|
|
4039
5922
|
find: /^@napi-rs\/keyring$/,
|
|
4040
|
-
replacement:
|
|
5923
|
+
replacement: path5.resolve(dirname10, "node_modules/@napi-rs/keyring")
|
|
4041
5924
|
},
|
|
4042
5925
|
// `express` and `yaml` resolve from the **repo root**, unlike every pin
|
|
4043
5926
|
// above. Both are reached only through `test-servers/src` — express by the
|
|
@@ -4052,11 +5935,22 @@ function vitestSharedPaths(clientDir) {
|
|
|
4052
5935
|
// failed to resolve it.
|
|
4053
5936
|
{
|
|
4054
5937
|
find: /^express$/,
|
|
4055
|
-
replacement:
|
|
5938
|
+
replacement: path5.resolve(repoRoot, "node_modules/express")
|
|
4056
5939
|
},
|
|
4057
5940
|
{
|
|
4058
5941
|
find: /^yaml$/,
|
|
4059
|
-
replacement:
|
|
5942
|
+
replacement: path5.resolve(repoRoot, "node_modules/yaml")
|
|
5943
|
+
},
|
|
5944
|
+
// Same reasoning, one layer in: `proper-lockfile` is reached only through
|
|
5945
|
+
// `core/` (the secrets file's cross-process lock, #2082), which is the
|
|
5946
|
+
// other root-owned tree with no manifest of its own. Resolution finds the
|
|
5947
|
+
// root copy on its own today — nothing declares it in a client — and this
|
|
5948
|
+
// pin is what keeps that from depending on nothing ever arriving as some
|
|
5949
|
+
// client's transitive dependency, which would otherwise give a test two
|
|
5950
|
+
// copies of a module whose whole job is a single registry of held locks.
|
|
5951
|
+
{
|
|
5952
|
+
find: /^proper-lockfile$/,
|
|
5953
|
+
replacement: path5.resolve(repoRoot, "node_modules/proper-lockfile")
|
|
4060
5954
|
}
|
|
4061
5955
|
];
|
|
4062
5956
|
const projectResolve = {
|
|
@@ -4079,9 +5973,9 @@ function vitestSharedPaths(clientDir) {
|
|
|
4079
5973
|
}
|
|
4080
5974
|
|
|
4081
5975
|
// server/start-vite-dev-server.ts
|
|
4082
|
-
var __dirname2 =
|
|
5976
|
+
var __dirname2 = dirname7(fileURLToPath4(import.meta.url));
|
|
4083
5977
|
async function startViteDevServer(config) {
|
|
4084
|
-
const root =
|
|
5978
|
+
const root = resolve6(join7(__dirname2, ".."));
|
|
4085
5979
|
clearViteDepsCache(root);
|
|
4086
5980
|
const { repoRoot, sharedAliases, sharedDedupe, nodeModulesAliases } = vitestSharedPaths(root);
|
|
4087
5981
|
const inlineConfig = {
|
|
@@ -4115,7 +6009,7 @@ async function startViteDevServer(config) {
|
|
|
4115
6009
|
},
|
|
4116
6010
|
plugins: [react(), honoMiddlewarePlugin(config)]
|
|
4117
6011
|
};
|
|
4118
|
-
const server = await
|
|
6012
|
+
const server = await createServer3(inlineConfig);
|
|
4119
6013
|
await server.listen();
|
|
4120
6014
|
return {
|
|
4121
6015
|
async close() {
|
|
@@ -4125,16 +6019,15 @@ async function startViteDevServer(config) {
|
|
|
4125
6019
|
}
|
|
4126
6020
|
|
|
4127
6021
|
// server/server.ts
|
|
4128
|
-
import { readFileSync as
|
|
4129
|
-
import { join as
|
|
6022
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
6023
|
+
import { join as join8, dirname as dirname8 } from "path";
|
|
4130
6024
|
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
4131
|
-
import { randomBytes as
|
|
4132
|
-
import open2 from "open";
|
|
6025
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
4133
6026
|
import { serve } from "@hono/node-server";
|
|
4134
6027
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
4135
6028
|
import { Hono as Hono2 } from "hono";
|
|
4136
6029
|
var __filename = fileURLToPath5(import.meta.url);
|
|
4137
|
-
var __dirname3 =
|
|
6030
|
+
var __dirname3 = dirname8(__filename);
|
|
4138
6031
|
async function startHonoServer(config) {
|
|
4139
6032
|
const sandboxController = createSandboxController({
|
|
4140
6033
|
port: config.sandboxPort,
|
|
@@ -4142,8 +6035,18 @@ async function startHonoServer(config) {
|
|
|
4142
6035
|
allowedOrigins: config.allowedOrigins
|
|
4143
6036
|
});
|
|
4144
6037
|
await sandboxController.start();
|
|
4145
|
-
const
|
|
6038
|
+
const appOriginController = createAppOriginController({
|
|
6039
|
+
port: config.appOriginPort,
|
|
6040
|
+
host: config.sandboxHost,
|
|
6041
|
+
embedderOrigins: appDocumentEmbedders(
|
|
6042
|
+
sandboxController.getUrl(),
|
|
6043
|
+
config.allowedOrigins
|
|
6044
|
+
)
|
|
6045
|
+
});
|
|
6046
|
+
await appOriginController.start();
|
|
6047
|
+
const resolvedAuthToken = config.authToken || (config.dangerouslyOmitAuth ? "" : randomBytes4(32).toString("hex"));
|
|
4146
6048
|
const rootPath = config.staticRoot ?? __dirname3;
|
|
6049
|
+
const secretStorage = await getSecretStorageInfo();
|
|
4147
6050
|
const { app: apiApp, close: closeApi } = createRemoteApp({
|
|
4148
6051
|
authToken: config.dangerouslyOmitAuth ? void 0 : resolvedAuthToken,
|
|
4149
6052
|
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
|
|
@@ -4153,16 +6056,22 @@ async function startHonoServer(config) {
|
|
|
4153
6056
|
initialServers: config.initialServers ?? void 0,
|
|
4154
6057
|
allowedOrigins: config.allowedOrigins,
|
|
4155
6058
|
sandboxUrl: sandboxController.getUrl() ?? void 0,
|
|
6059
|
+
publishAppDocument: (doc) => appOriginController.publish(doc),
|
|
4156
6060
|
logger: config.logger,
|
|
4157
|
-
initialConfig: webServerConfigToInitialPayload(config)
|
|
6061
|
+
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
|
6062
|
+
// The startup value above is what the banner printed; the route
|
|
6063
|
+
// re-resolves per request, because the first write under a newly-set
|
|
6064
|
+
// passphrase encrypts a pre-existing plaintext file and a captured
|
|
6065
|
+
// descriptor would keep reporting the old state until a restart.
|
|
6066
|
+
secretStorageResolver: getSecretStorageInfo
|
|
4158
6067
|
});
|
|
4159
6068
|
const app = new Hono2();
|
|
4160
6069
|
app.use("/api/*", async (c) => {
|
|
4161
6070
|
return apiApp.fetch(c.req.raw);
|
|
4162
6071
|
});
|
|
4163
6072
|
const serveIndexHtml = (c) => {
|
|
4164
|
-
const indexPath =
|
|
4165
|
-
const html =
|
|
6073
|
+
const indexPath = join8(rootPath, "index.html");
|
|
6074
|
+
const html = readFileSync6(indexPath, "utf-8");
|
|
4166
6075
|
c.header("Cache-Control", "no-store");
|
|
4167
6076
|
return c.html(injectAuthToken(html, resolvedAuthToken));
|
|
4168
6077
|
};
|
|
@@ -4198,10 +6107,11 @@ async function startHonoServer(config) {
|
|
|
4198
6107
|
config,
|
|
4199
6108
|
info.port,
|
|
4200
6109
|
resolvedAuthToken,
|
|
4201
|
-
sandboxUrl ?? void 0
|
|
6110
|
+
sandboxUrl ?? void 0,
|
|
6111
|
+
secretStorage
|
|
4202
6112
|
);
|
|
4203
6113
|
if (config.autoOpen) {
|
|
4204
|
-
|
|
6114
|
+
openBrowser(url);
|
|
4205
6115
|
}
|
|
4206
6116
|
}
|
|
4207
6117
|
);
|
|
@@ -4219,19 +6129,20 @@ async function startHonoServer(config) {
|
|
|
4219
6129
|
async close() {
|
|
4220
6130
|
await closeApi();
|
|
4221
6131
|
await sandboxController.close();
|
|
6132
|
+
await appOriginController.close();
|
|
4222
6133
|
if ("closeAllConnections" in httpServer) {
|
|
4223
6134
|
httpServer.closeAllConnections();
|
|
4224
6135
|
}
|
|
4225
|
-
await new Promise((
|
|
4226
|
-
httpServer.close((err) => err ? reject(err) :
|
|
6136
|
+
await new Promise((resolve9, reject) => {
|
|
6137
|
+
httpServer.close((err) => err ? reject(err) : resolve9());
|
|
4227
6138
|
});
|
|
4228
6139
|
}
|
|
4229
6140
|
};
|
|
4230
6141
|
}
|
|
4231
6142
|
|
|
4232
6143
|
// server/ensure-web-build.ts
|
|
4233
|
-
import { existsSync as
|
|
4234
|
-
import { join as
|
|
6144
|
+
import { existsSync as existsSync4 } from "fs";
|
|
6145
|
+
import { join as join9 } from "path";
|
|
4235
6146
|
import { spawnSync } from "child_process";
|
|
4236
6147
|
function runViteBuild(webRoot, log) {
|
|
4237
6148
|
const result = spawnSync("npm", ["run", "build:client"], {
|
|
@@ -4246,10 +6157,10 @@ function runViteBuild(webRoot, log) {
|
|
|
4246
6157
|
return result.status;
|
|
4247
6158
|
}
|
|
4248
6159
|
function ensureWebBuild(webRoot, distRoot, deps = {}) {
|
|
4249
|
-
const exists = deps.exists ??
|
|
6160
|
+
const exists = deps.exists ?? existsSync4;
|
|
4250
6161
|
const build = deps.build ?? runViteBuild;
|
|
4251
6162
|
const log = deps.log ?? ((message) => console.log(message));
|
|
4252
|
-
const indexHtml =
|
|
6163
|
+
const indexHtml = join9(distRoot, "index.html");
|
|
4253
6164
|
if (exists(indexHtml)) return;
|
|
4254
6165
|
log(
|
|
4255
6166
|
"No production web build found at clients/web/dist. Building the web UI now (first run only; this can take a minute)..."
|
|
@@ -4263,10 +6174,10 @@ function ensureWebBuild(webRoot, distRoot, deps = {}) {
|
|
|
4263
6174
|
}
|
|
4264
6175
|
|
|
4265
6176
|
// server/run-web.ts
|
|
4266
|
-
var __dirname4 =
|
|
6177
|
+
var __dirname4 = dirname9(fileURLToPath6(import.meta.url));
|
|
4267
6178
|
function ensureStdioCwd(config) {
|
|
4268
6179
|
if ((config.type === "stdio" || config.type === void 0) && config.command && !config.cwd) {
|
|
4269
|
-
return { ...config, cwd:
|
|
6180
|
+
return { ...config, cwd: resolve7(process.cwd()) };
|
|
4270
6181
|
}
|
|
4271
6182
|
return config;
|
|
4272
6183
|
}
|
|
@@ -4324,7 +6235,7 @@ async function runWeb(argv) {
|
|
|
4324
6235
|
"Note: --server has no effect on the web UI yet; it lists every server in the catalog."
|
|
4325
6236
|
);
|
|
4326
6237
|
}
|
|
4327
|
-
mcpConfigPath =
|
|
6238
|
+
mcpConfigPath = resolve7(process.cwd(), catalogPath);
|
|
4328
6239
|
writable = true;
|
|
4329
6240
|
} else if (hasConfig) {
|
|
4330
6241
|
if (opts.server) {
|
|
@@ -4332,7 +6243,7 @@ async function runWeb(argv) {
|
|
|
4332
6243
|
"Note: --server has no effect on the web UI yet; it lists every server in the file."
|
|
4333
6244
|
);
|
|
4334
6245
|
}
|
|
4335
|
-
const resolvedConfigPath =
|
|
6246
|
+
const resolvedConfigPath = resolve7(process.cwd(), opts.config);
|
|
4336
6247
|
try {
|
|
4337
6248
|
resolveServerConfigs({ configPath: resolvedConfigPath }, "multi");
|
|
4338
6249
|
} catch (err) {
|
|
@@ -4353,14 +6264,14 @@ async function runWeb(argv) {
|
|
|
4353
6264
|
cwd: opts.cwd,
|
|
4354
6265
|
env: opts.e
|
|
4355
6266
|
};
|
|
4356
|
-
const
|
|
4357
|
-
if (!
|
|
6267
|
+
const resolved2 = resolveServerConfigs(serverOptions, "single")[0];
|
|
6268
|
+
if (!resolved2) {
|
|
4358
6269
|
console.error(
|
|
4359
6270
|
"Error: Could not resolve server config. Pass a command/URL, or use --catalog / --config to serve a file."
|
|
4360
6271
|
);
|
|
4361
6272
|
process.exit(1);
|
|
4362
6273
|
}
|
|
4363
|
-
config = ensureStdioCwd(
|
|
6274
|
+
config = ensureStdioCwd(resolved2);
|
|
4364
6275
|
} catch (err) {
|
|
4365
6276
|
const message = err instanceof Error ? err.message : "Could not resolve server config.";
|
|
4366
6277
|
console.error("Error:", message);
|
|
@@ -4400,8 +6311,8 @@ async function runWeb(argv) {
|
|
|
4400
6311
|
console.error("Error:", message);
|
|
4401
6312
|
process.exit(1);
|
|
4402
6313
|
}
|
|
4403
|
-
const webRoot =
|
|
4404
|
-
const distRoot =
|
|
6314
|
+
const webRoot = join10(__dirname4, "..");
|
|
6315
|
+
const distRoot = join10(webRoot, "dist");
|
|
4405
6316
|
if (!isDev) {
|
|
4406
6317
|
webConfig.staticRoot = distRoot;
|
|
4407
6318
|
}
|
|
@@ -4432,7 +6343,7 @@ async function runWeb(argv) {
|
|
|
4432
6343
|
|
|
4433
6344
|
// src/index.ts
|
|
4434
6345
|
var __filename2 = fileURLToPath7(import.meta.url);
|
|
4435
|
-
var isMain = process.argv[1] !== void 0 &&
|
|
6346
|
+
var isMain = process.argv[1] !== void 0 && resolve8(process.argv[1]) === resolve8(__filename2);
|
|
4436
6347
|
if (isMain) {
|
|
4437
6348
|
runWeb(process.argv).then((code) => process.exit(code ?? 0)).catch((e) => {
|
|
4438
6349
|
console.error(e);
|
|
@@ -4442,3 +6353,16 @@ if (isMain) {
|
|
|
4442
6353
|
export {
|
|
4443
6354
|
runWeb
|
|
4444
6355
|
};
|
|
6356
|
+
/* v8 ignore next 2 -- @preserve: the non-Error arm is unreachable via
|
|
6357
|
+
proper-lockfile, which rejects only with Errors. */
|
|
6358
|
+
/* v8 ignore next -- @preserve: node validates the cost parameters
|
|
6359
|
+
synchronously (an out-of-grammar `N` throws from the call itself,
|
|
6360
|
+
which the corrupt-KDF test exercises), so this asynchronous channel
|
|
6361
|
+
is left for allocation failure — not provokable deterministically. */
|
|
6362
|
+
/* v8 ignore next -- @preserve: unreachable, `encryptedEnvelopeProblem`
|
|
6363
|
+
above returns a reason for anything `decodeParts` would reject. Kept as
|
|
6364
|
+
the type narrowing `parts` needs. */
|
|
6365
|
+
/* v8 ignore next -- @preserve: the walk can only run out of ancestors if
|
|
6366
|
+
the filesystem root itself does not exist, which would mean the process
|
|
6367
|
+
has no cwd to have resolved against. Kept as the loop's termination
|
|
6368
|
+
guard rather than trusting that invariant. */
|