@modelcontextprotocol/inspector 2.3.0 → 2.5.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 +47 -419
- package/clients/cli/build/index.js +12710 -10802
- package/clients/launcher/build/index.js +6 -4
- package/clients/tui/build/index.js +4436 -1102
- package/clients/web/build/index.js +2143 -230
- package/clients/web/dist/assets/index-ZYqA4f7P.js +1486 -0
- package/clients/web/dist/assets/{index-CiUpWldH.css → index-dqp8Sd8W.css} +1 -1
- package/clients/web/dist/assets/{prism-light-DBxbqjnM.js → prism-light-_bvDBTQE.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 +29 -13
- package/clients/tui/build/chunk-MCKGQKYU.js +0 -15
- package/clients/tui/build/undici-KNXAVD2F.js +0 -26644
- package/clients/web/build/chunk-MCKGQKYU.js +0 -15
- package/clients/web/build/undici-KNXAVD2F.js +0 -26644
- package/clients/web/dist/assets/index-CwhDusR3.js +0 -237
- package/clients/web/dist/assets/json-BJ8dCynk.js +0 -1
|
@@ -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,68 @@ 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
|
+
}
|
|
183
260
|
function cleanAuthorizationParams(params) {
|
|
184
261
|
if (params === void 0) return void 0;
|
|
185
262
|
if (typeof params !== "object" || params === null || Array.isArray(params)) {
|
|
@@ -219,7 +296,7 @@ function storedFieldsToInspectorSettings(stored) {
|
|
|
219
296
|
const settings = {
|
|
220
297
|
headers: headersPairs,
|
|
221
298
|
env: envRecordToPairs(stored.env),
|
|
222
|
-
metadata: stored.metadata
|
|
299
|
+
metadata: normalizeStoredMetadata(stored.metadata),
|
|
223
300
|
connectionTimeout: stored.connectionTimeout ?? 0,
|
|
224
301
|
requestTimeout: stored.requestTimeout ?? 0,
|
|
225
302
|
// Unlike the timeouts (0 = "SDK default"), task TTL has a concrete product
|
|
@@ -268,6 +345,12 @@ function storedFieldsToInspectorSettings(stored) {
|
|
|
268
345
|
if (stored.oauth?.enterpriseManaged === true) {
|
|
269
346
|
settings.enterpriseManaged = true;
|
|
270
347
|
}
|
|
348
|
+
if (stored.oauth?.requestRefreshToken === false) {
|
|
349
|
+
settings.oauthRequestRefreshToken = false;
|
|
350
|
+
}
|
|
351
|
+
if (stored.oauth?.revokeOnClear === false) {
|
|
352
|
+
settings.oauthRevokeOnClear = false;
|
|
353
|
+
}
|
|
271
354
|
if (stored.cwd) settings.cwd = stored.cwd;
|
|
272
355
|
return settings;
|
|
273
356
|
}
|
|
@@ -281,9 +364,8 @@ function inspectorSettingsToStoredFields(settings) {
|
|
|
281
364
|
if (Object.keys(headersRecord).length > 0) {
|
|
282
365
|
out.headers = headersRecord;
|
|
283
366
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
out.metadata = metadataFiltered;
|
|
367
|
+
if (Object.keys(settings.metadata).length > 0) {
|
|
368
|
+
out.metadata = settings.metadata;
|
|
287
369
|
}
|
|
288
370
|
if (settings.connectionTimeout > 0) {
|
|
289
371
|
out.connectionTimeout = settings.connectionTimeout;
|
|
@@ -335,6 +417,12 @@ function inspectorSettingsToStoredFields(settings) {
|
|
|
335
417
|
if (settings.enterpriseManaged === true) {
|
|
336
418
|
oauthFields.enterpriseManaged = true;
|
|
337
419
|
}
|
|
420
|
+
if (settings.oauthRequestRefreshToken === false) {
|
|
421
|
+
oauthFields.requestRefreshToken = false;
|
|
422
|
+
}
|
|
423
|
+
if (settings.oauthRevokeOnClear === false) {
|
|
424
|
+
oauthFields.revokeOnClear = false;
|
|
425
|
+
}
|
|
338
426
|
if (Object.keys(oauthFields).length > 0) {
|
|
339
427
|
out.oauth = oauthFields;
|
|
340
428
|
}
|
|
@@ -672,6 +760,49 @@ var API_SERVER_ENV_VARS = {
|
|
|
672
760
|
};
|
|
673
761
|
var INSPECTOR_API_TOKEN_GLOBAL = "__INSPECTOR_API_TOKEN__";
|
|
674
762
|
|
|
763
|
+
// ../../core/auth/secret-storage-info.ts
|
|
764
|
+
function secretStorageLabel(info) {
|
|
765
|
+
switch (info.kind) {
|
|
766
|
+
case "keyring":
|
|
767
|
+
return "OS keychain";
|
|
768
|
+
case "file":
|
|
769
|
+
if (info.encryptionUnknown !== void 0) return "File (unreadable)";
|
|
770
|
+
return info.plaintext ? "File (unencrypted)" : "File (encrypted)";
|
|
771
|
+
case "memory":
|
|
772
|
+
return "Memory (this session only)";
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function secretStorageCaveat(info) {
|
|
776
|
+
if (info.kind === "memory") {
|
|
777
|
+
return "Secrets are not written anywhere and are lost on exit.";
|
|
778
|
+
}
|
|
779
|
+
if (info.looseMode !== void 0) {
|
|
780
|
+
const mode = info.looseMode.toString(8).padStart(4, "0");
|
|
781
|
+
if (info.encryptionUnknown !== void 0) {
|
|
782
|
+
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}).`;
|
|
783
|
+
}
|
|
784
|
+
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.`;
|
|
785
|
+
}
|
|
786
|
+
if (info.permissionsUnknown !== void 0) {
|
|
787
|
+
return `The secrets file's permissions could not be checked (${info.permissionsUnknown}), so it is not known whether others can read it.`;
|
|
788
|
+
}
|
|
789
|
+
if (info.encryptionUnknown !== void 0) {
|
|
790
|
+
return `The secrets file could not be read (${info.encryptionUnknown}). Saving a secret will fail rather than overwrite it.`;
|
|
791
|
+
}
|
|
792
|
+
if (info.plaintext) {
|
|
793
|
+
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.";
|
|
794
|
+
}
|
|
795
|
+
return void 0;
|
|
796
|
+
}
|
|
797
|
+
function secretStorageSummary(info) {
|
|
798
|
+
const parts = [secretStorageLabel(info)];
|
|
799
|
+
if (info.path) parts.push(`at ${info.path}`);
|
|
800
|
+
if (info.reason === "fallback") {
|
|
801
|
+
parts.push("\u2014 fell back from the OS keychain");
|
|
802
|
+
}
|
|
803
|
+
return parts.join(" ");
|
|
804
|
+
}
|
|
805
|
+
|
|
675
806
|
// ../../core/node/version.ts
|
|
676
807
|
import { readFileSync as readFileSync2 } from "fs";
|
|
677
808
|
import { dirname as dirname3, join as join2, parse } from "path";
|
|
@@ -771,8 +902,8 @@ var DEFAULT_BIND_HOST = "127.0.0.1";
|
|
|
771
902
|
function resolveBindHostname(env = process.env) {
|
|
772
903
|
const host = (env.HOST ?? DEFAULT_BIND_HOST).trim();
|
|
773
904
|
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
|
|
774
|
-
const
|
|
775
|
-
const shown = stripBrackets(
|
|
905
|
+
const resolved2 = canonicalUrlHost(host);
|
|
906
|
+
const shown = stripBrackets(resolved2) === stripBrackets(host.toLowerCase()) ? `HOST="${host}"` : `HOST="${host}" (resolves to ${resolved2})`;
|
|
776
907
|
throw new Error(
|
|
777
908
|
`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.`
|
|
778
909
|
);
|
|
@@ -785,7 +916,10 @@ var __dirname = dirname4(fileURLToPath2(import.meta.url));
|
|
|
785
916
|
var LOOPBACK_FRAME_ANCESTORS = ["http://127.0.0.1:*", "http://localhost:*"];
|
|
786
917
|
var CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]*]+$/i;
|
|
787
918
|
function sandboxFrameAncestors(allowedOrigins) {
|
|
788
|
-
|
|
919
|
+
return frameAncestorsDirective(allowedOrigins);
|
|
920
|
+
}
|
|
921
|
+
function frameAncestorsDirective(origins) {
|
|
922
|
+
const valid = (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
|
789
923
|
const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS;
|
|
790
924
|
return `frame-ancestors ${sources.join(" ")}`;
|
|
791
925
|
}
|
|
@@ -823,12 +957,12 @@ function createSandboxController(options) {
|
|
|
823
957
|
const p = parseInt(new URL(sandboxUrl).port, 10);
|
|
824
958
|
return { port: p, url: sandboxUrl };
|
|
825
959
|
}
|
|
826
|
-
return new Promise((
|
|
960
|
+
return new Promise((resolve9) => {
|
|
827
961
|
let settled = false;
|
|
828
962
|
const settle = (value) => {
|
|
829
963
|
if (settled) return;
|
|
830
964
|
settled = true;
|
|
831
|
-
|
|
965
|
+
resolve9(value);
|
|
832
966
|
};
|
|
833
967
|
server = createServer((req, res) => {
|
|
834
968
|
if (req.method !== "GET" || req.url !== "/sandbox" && req.url !== "/sandbox/") {
|
|
@@ -882,11 +1016,11 @@ function createSandboxController(options) {
|
|
|
882
1016
|
},
|
|
883
1017
|
async close() {
|
|
884
1018
|
if (!server) return;
|
|
885
|
-
return new Promise((
|
|
1019
|
+
return new Promise((resolve9) => {
|
|
886
1020
|
server.close(() => {
|
|
887
1021
|
server = null;
|
|
888
1022
|
sandboxUrl = null;
|
|
889
|
-
|
|
1023
|
+
resolve9();
|
|
890
1024
|
});
|
|
891
1025
|
});
|
|
892
1026
|
},
|
|
@@ -896,6 +1030,169 @@ function createSandboxController(options) {
|
|
|
896
1030
|
};
|
|
897
1031
|
}
|
|
898
1032
|
|
|
1033
|
+
// server/app-origin-controller.ts
|
|
1034
|
+
import { createServer as createServer2 } from "http";
|
|
1035
|
+
import { randomBytes } from "crypto";
|
|
1036
|
+
var DEFAULT_APP_ORIGIN_PORT = 6278;
|
|
1037
|
+
var MAX_DOCUMENTS = 32;
|
|
1038
|
+
var MAX_RETAINED_CHARS = 64 * 1024 * 1024;
|
|
1039
|
+
var DOCUMENT_TTL_MS = 60 * 60 * 1e3;
|
|
1040
|
+
function appDocumentEmbedders(sandboxUrl, allowedOrigins) {
|
|
1041
|
+
const origins = [];
|
|
1042
|
+
if (sandboxUrl) {
|
|
1043
|
+
try {
|
|
1044
|
+
origins.push(new URL(sandboxUrl).origin);
|
|
1045
|
+
} catch {
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
origins.push(...allowedOrigins ?? []);
|
|
1049
|
+
return origins.length > 0 ? origins : void 0;
|
|
1050
|
+
}
|
|
1051
|
+
function parseListenPort2(raw) {
|
|
1052
|
+
const v = raw?.trim();
|
|
1053
|
+
if (!v || !/^\d+$/.test(v)) return void 0;
|
|
1054
|
+
const n = parseInt(v, 10);
|
|
1055
|
+
return n <= 65535 ? n : void 0;
|
|
1056
|
+
}
|
|
1057
|
+
function resolveAppOriginPort() {
|
|
1058
|
+
const parsed = parseListenPort2(process.env.MCP_APP_ORIGIN_PORT);
|
|
1059
|
+
if (parsed === void 0 && process.env.MCP_APP_ORIGIN_PORT?.trim()) {
|
|
1060
|
+
console.warn(
|
|
1061
|
+
`Ignoring invalid MCP_APP_ORIGIN_PORT="${process.env.MCP_APP_ORIGIN_PORT}" (need an integer 0\u201365535); falling back.`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
return parsed ?? DEFAULT_APP_ORIGIN_PORT;
|
|
1065
|
+
}
|
|
1066
|
+
var DOC_PATH_PREFIX = "/app-document/";
|
|
1067
|
+
function parseDocumentId(url) {
|
|
1068
|
+
if (!url || !url.startsWith(DOC_PATH_PREFIX)) return null;
|
|
1069
|
+
const id = url.slice(DOC_PATH_PREFIX.length).split(/[?#]/)[0];
|
|
1070
|
+
return /^[0-9a-f]{32}$/.test(id ?? "") ? id ?? null : null;
|
|
1071
|
+
}
|
|
1072
|
+
function createAppOriginController(options) {
|
|
1073
|
+
const { port, host = DEFAULT_BIND_HOST, embedderOrigins } = options;
|
|
1074
|
+
let server = null;
|
|
1075
|
+
let origin = null;
|
|
1076
|
+
const documents = /* @__PURE__ */ new Map();
|
|
1077
|
+
let retainedChars = 0;
|
|
1078
|
+
function drop(id) {
|
|
1079
|
+
const doc = documents.get(id);
|
|
1080
|
+
if (!doc) return;
|
|
1081
|
+
retainedChars -= doc.size;
|
|
1082
|
+
documents.delete(id);
|
|
1083
|
+
}
|
|
1084
|
+
const FRAME_ANCESTORS = frameAncestorsDirective(embedderOrigins);
|
|
1085
|
+
function evictExpired(now) {
|
|
1086
|
+
for (const [id, doc] of documents) {
|
|
1087
|
+
if (doc.expiresAt <= now) drop(id);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return {
|
|
1091
|
+
async start() {
|
|
1092
|
+
if (server && origin) {
|
|
1093
|
+
return { port: parseInt(new URL(origin).port, 10), url: origin };
|
|
1094
|
+
}
|
|
1095
|
+
return new Promise((resolve9) => {
|
|
1096
|
+
let settled = false;
|
|
1097
|
+
const settle = (value) => {
|
|
1098
|
+
if (settled) return;
|
|
1099
|
+
settled = true;
|
|
1100
|
+
resolve9(value);
|
|
1101
|
+
};
|
|
1102
|
+
server = createServer2((req, res) => {
|
|
1103
|
+
const id = req.method === "GET" ? parseDocumentId(req.url) : null;
|
|
1104
|
+
const doc = id ? documents.get(id) : void 0;
|
|
1105
|
+
if (!doc || doc.expiresAt <= Date.now()) {
|
|
1106
|
+
if (id) drop(id);
|
|
1107
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1108
|
+
res.end("Not Found");
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
res.writeHead(200, {
|
|
1112
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1113
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
1114
|
+
Pragma: "no-cache",
|
|
1115
|
+
// `nosniff` matters more here than on a page we authored: these
|
|
1116
|
+
// bytes are server-supplied and untrusted.
|
|
1117
|
+
"X-Content-Type-Options": "nosniff",
|
|
1118
|
+
// The per-app policy as a real header. It is the same policy baked
|
|
1119
|
+
// into the document's <meta>; delivering both is deliberate —
|
|
1120
|
+
// multiple policies intersect, and two copies of one policy
|
|
1121
|
+
// intersect to itself, so this can only ever be at least as strict.
|
|
1122
|
+
"Content-Security-Policy": doc.csp ? `${doc.csp}; ${FRAME_ANCESTORS}` : FRAME_ANCESTORS
|
|
1123
|
+
});
|
|
1124
|
+
res.end(doc.html);
|
|
1125
|
+
});
|
|
1126
|
+
let retriedDynamic = false;
|
|
1127
|
+
server.on("error", (err) => {
|
|
1128
|
+
if (err.code === "EADDRINUSE") {
|
|
1129
|
+
if (!retriedDynamic && port !== 0) {
|
|
1130
|
+
retriedDynamic = true;
|
|
1131
|
+
console.warn(
|
|
1132
|
+
`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.`
|
|
1133
|
+
);
|
|
1134
|
+
server.listen(0, host);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
console.error(
|
|
1138
|
+
`App origin: port ${port || "dynamic"} in use. Apps declaring _meta.ui.domain will fall back to an opaque origin.`
|
|
1139
|
+
);
|
|
1140
|
+
} else {
|
|
1141
|
+
console.error("App origin server error:", err);
|
|
1142
|
+
}
|
|
1143
|
+
server = null;
|
|
1144
|
+
origin = null;
|
|
1145
|
+
documents.clear();
|
|
1146
|
+
retainedChars = 0;
|
|
1147
|
+
settle({ port: 0, url: "" });
|
|
1148
|
+
});
|
|
1149
|
+
server.listen(port, host, () => {
|
|
1150
|
+
const addr = server.address();
|
|
1151
|
+
if (typeof addr !== "object" || addr === null) {
|
|
1152
|
+
settle({ port: 0, url: "" });
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
const actualPort = addr.port;
|
|
1156
|
+
const canonicalHost = canonicalUrlHost(host);
|
|
1157
|
+
const urlHost = isAllInterfacesHost(canonicalHost) ? "localhost" : canonicalHost;
|
|
1158
|
+
origin = `http://${urlHost}:${actualPort}`;
|
|
1159
|
+
settle({ port: actualPort, url: origin });
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
},
|
|
1163
|
+
async close() {
|
|
1164
|
+
documents.clear();
|
|
1165
|
+
retainedChars = 0;
|
|
1166
|
+
if (!server) return;
|
|
1167
|
+
return new Promise((resolve9) => {
|
|
1168
|
+
server.close(() => {
|
|
1169
|
+
server = null;
|
|
1170
|
+
origin = null;
|
|
1171
|
+
resolve9();
|
|
1172
|
+
});
|
|
1173
|
+
});
|
|
1174
|
+
},
|
|
1175
|
+
getOrigin() {
|
|
1176
|
+
return origin;
|
|
1177
|
+
},
|
|
1178
|
+
publish(doc) {
|
|
1179
|
+
if (!origin) return null;
|
|
1180
|
+
const now = Date.now();
|
|
1181
|
+
evictExpired(now);
|
|
1182
|
+
const size = doc.html.length + (doc.csp?.length ?? 0);
|
|
1183
|
+
while (documents.size >= MAX_DOCUMENTS || documents.size > 0 && retainedChars + size > MAX_RETAINED_CHARS) {
|
|
1184
|
+
const oldest = documents.keys().next();
|
|
1185
|
+
if (oldest.done) break;
|
|
1186
|
+
drop(oldest.value);
|
|
1187
|
+
}
|
|
1188
|
+
const id = randomBytes(16).toString("hex");
|
|
1189
|
+
documents.set(id, { ...doc, expiresAt: now + DOCUMENT_TTL_MS, size });
|
|
1190
|
+
retainedChars += size;
|
|
1191
|
+
return { url: `${origin}${DOC_PATH_PREFIX}${id}` };
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
899
1196
|
// server/web-server-config.ts
|
|
900
1197
|
var inspectorVersion = readInspectorVersionSafe(import.meta.url);
|
|
901
1198
|
function defaultEnvironmentFromProcess(extra) {
|
|
@@ -925,8 +1222,16 @@ function defaultEnvironmentFromProcess(extra) {
|
|
|
925
1222
|
}
|
|
926
1223
|
return out;
|
|
927
1224
|
}
|
|
928
|
-
function webServerConfigToInitialPayload(config) {
|
|
929
|
-
return {
|
|
1225
|
+
function webServerConfigToInitialPayload(config, secretStorage) {
|
|
1226
|
+
return {
|
|
1227
|
+
...transportDefaults(config),
|
|
1228
|
+
version: inspectorVersion,
|
|
1229
|
+
// Omitted rather than defaulted when the caller didn't resolve a store:
|
|
1230
|
+
// the UI treats an absent descriptor as "unknown" and shows nothing,
|
|
1231
|
+
// which is the only honest rendering. A default of "keychain" would put a
|
|
1232
|
+
// confident wrong answer under the field where a secret is typed.
|
|
1233
|
+
...secretStorage ? { secretStorage } : {}
|
|
1234
|
+
};
|
|
930
1235
|
}
|
|
931
1236
|
function transportDefaults(config) {
|
|
932
1237
|
const mc = config.initialMcpConfig;
|
|
@@ -966,7 +1271,7 @@ function transportDefaults(config) {
|
|
|
966
1271
|
defaultEnvironment
|
|
967
1272
|
};
|
|
968
1273
|
}
|
|
969
|
-
function printServerBanner(config, actualPort, resolvedToken, sandboxUrl) {
|
|
1274
|
+
function printServerBanner(config, actualPort, resolvedToken, sandboxUrl, secretStorage) {
|
|
970
1275
|
const h = canonicalUrlHost(config.hostname);
|
|
971
1276
|
const bannerHost = isAllInterfacesHost(h) ? "localhost" : h;
|
|
972
1277
|
const baseUrl = httpOrigin(bannerHost, actualPort);
|
|
@@ -983,6 +1288,10 @@ MCP Inspector Web is up and running at:
|
|
|
983
1288
|
console.log(" Auth: disabled (DANGEROUSLY_OMIT_AUTH)\n");
|
|
984
1289
|
} else {
|
|
985
1290
|
console.log(` Auth token: ${resolvedToken}
|
|
1291
|
+
`);
|
|
1292
|
+
}
|
|
1293
|
+
if (secretStorage) {
|
|
1294
|
+
console.log(` Secrets: ${secretStorageSummary(secretStorage)}
|
|
986
1295
|
`);
|
|
987
1296
|
}
|
|
988
1297
|
if (config.autoOpen) {
|
|
@@ -1038,6 +1347,14 @@ function buildWebServerConfig(options = {}) {
|
|
|
1038
1347
|
const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH;
|
|
1039
1348
|
const authToken = dangerouslyOmitAuth ? "" : process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] ?? process.env[LEGACY_AUTH_TOKEN_ENV] ?? "";
|
|
1040
1349
|
const sandboxPort = resolveSandboxPort();
|
|
1350
|
+
const requestedAppOriginPort = resolveAppOriginPort();
|
|
1351
|
+
const appOriginCollidesWith = requestedAppOriginPort !== 0 && (requestedAppOriginPort === port ? "the web server (CLIENT_PORT)" : requestedAppOriginPort === sandboxPort ? "the MCP Apps sandbox (MCP_SANDBOX_PORT)" : null);
|
|
1352
|
+
if (appOriginCollidesWith) {
|
|
1353
|
+
console.warn(
|
|
1354
|
+
`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.`
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
const appOriginPort = appOriginCollidesWith ? 0 : requestedAppOriginPort;
|
|
1041
1358
|
let logger;
|
|
1042
1359
|
if (process.env.MCP_LOG_FILE) {
|
|
1043
1360
|
logger = pino(
|
|
@@ -1073,6 +1390,7 @@ function buildWebServerConfig(options = {}) {
|
|
|
1073
1390
|
allowedOrigins: configuredOrigins?.length ? configuredOrigins : defaultAllowedOrigins(hostname, port),
|
|
1074
1391
|
sandboxPort,
|
|
1075
1392
|
sandboxHost: hostname,
|
|
1393
|
+
appOriginPort,
|
|
1076
1394
|
logger,
|
|
1077
1395
|
autoOpen: resolveAutoOpen()
|
|
1078
1396
|
};
|
|
@@ -1085,18 +1403,19 @@ function resolveAutoOpen() {
|
|
|
1085
1403
|
}
|
|
1086
1404
|
|
|
1087
1405
|
// server/start-vite-dev-server.ts
|
|
1088
|
-
import { join as
|
|
1406
|
+
import { join as join7, dirname as dirname7, resolve as resolve6 } from "path";
|
|
1089
1407
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
1090
|
-
import { createServer as
|
|
1408
|
+
import { createServer as createServer3 } from "vite";
|
|
1091
1409
|
import react from "@vitejs/plugin-react";
|
|
1092
1410
|
|
|
1093
1411
|
// ../../core/mcp/remote/node/server.ts
|
|
1094
|
-
import { randomBytes, timingSafeEqual } from "crypto";
|
|
1095
|
-
import { existsSync as
|
|
1412
|
+
import { randomBytes as randomBytes3, timingSafeEqual } from "crypto";
|
|
1413
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
|
|
1096
1414
|
import { stat as fsStat } from "fs/promises";
|
|
1097
1415
|
import { homedir } from "os";
|
|
1098
1416
|
import { Hono } from "hono";
|
|
1099
1417
|
import { streamSSE } from "hono/streaming";
|
|
1418
|
+
import { bodyLimit } from "hono/body-limit";
|
|
1100
1419
|
import { watch as chokidarWatch } from "chokidar";
|
|
1101
1420
|
|
|
1102
1421
|
// ../../core/mcp/config.ts
|
|
@@ -1411,6 +1730,7 @@ function parseAuthChallengeFromResponse(response, context) {
|
|
|
1411
1730
|
return {
|
|
1412
1731
|
reason: reasonFromHttpResponse(status, bearer),
|
|
1413
1732
|
...requiredScopes.length > 0 ? { requiredScopes } : {},
|
|
1733
|
+
...bearer.resourceMetadata ? { resourceMetadataUrl: bearer.resourceMetadata } : {},
|
|
1414
1734
|
...bearer.errorDescription ? { message: bearer.errorDescription } : {},
|
|
1415
1735
|
...context ? { context } : {},
|
|
1416
1736
|
raw: {
|
|
@@ -1440,8 +1760,26 @@ function createAuthChallengeInterceptFetch(baseFetch) {
|
|
|
1440
1760
|
);
|
|
1441
1761
|
};
|
|
1442
1762
|
}
|
|
1763
|
+
function createAuthChallengeObserverFetch(baseFetch, onChallenge) {
|
|
1764
|
+
return async (input, init) => {
|
|
1765
|
+
const response = await baseFetch(input, init);
|
|
1766
|
+
if (response.status === 401 || response.status === 403) {
|
|
1767
|
+
const challenge = parseAuthChallengeFromResponse(response);
|
|
1768
|
+
if (challenge) {
|
|
1769
|
+
onChallenge(challenge);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return response;
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1443
1775
|
|
|
1444
|
-
// ../../core/mcp/node/
|
|
1776
|
+
// ../../core/mcp/node/proxyFetch.ts
|
|
1777
|
+
function asUndiciInit(init) {
|
|
1778
|
+
return init;
|
|
1779
|
+
}
|
|
1780
|
+
function asBodyInit(body) {
|
|
1781
|
+
return body;
|
|
1782
|
+
}
|
|
1445
1783
|
var PROXY_ENV_VARS = [
|
|
1446
1784
|
"HTTPS_PROXY",
|
|
1447
1785
|
"https_proxy",
|
|
@@ -1455,27 +1793,95 @@ function readProxyEnv() {
|
|
|
1455
1793
|
}
|
|
1456
1794
|
return void 0;
|
|
1457
1795
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1796
|
+
var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
|
|
1797
|
+
101,
|
|
1798
|
+
103,
|
|
1799
|
+
204,
|
|
1800
|
+
205,
|
|
1801
|
+
304
|
|
1802
|
+
]);
|
|
1803
|
+
function toGlobalResponse(res) {
|
|
1804
|
+
const headers = new Headers();
|
|
1805
|
+
for (const [key, value] of res.headers) headers.append(key, value);
|
|
1806
|
+
const out = new Response(
|
|
1807
|
+
NULL_BODY_STATUSES.has(res.status) ? null : asBodyInit(res.body),
|
|
1808
|
+
{
|
|
1809
|
+
status: res.status,
|
|
1810
|
+
statusText: res.statusText,
|
|
1811
|
+
headers
|
|
1812
|
+
}
|
|
1813
|
+
);
|
|
1814
|
+
for (const [key, value] of [
|
|
1815
|
+
["url", res.url],
|
|
1816
|
+
["redirected", res.redirected],
|
|
1817
|
+
["type", res.type]
|
|
1818
|
+
]) {
|
|
1819
|
+
Object.defineProperty(out, key, {
|
|
1820
|
+
value,
|
|
1821
|
+
configurable: true,
|
|
1822
|
+
enumerable: false
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
return out;
|
|
1826
|
+
}
|
|
1827
|
+
var loadedProxiedFetch;
|
|
1828
|
+
async function loadProxiedFetch() {
|
|
1829
|
+
let undici;
|
|
1830
|
+
try {
|
|
1831
|
+
undici = await import("undici");
|
|
1832
|
+
} catch (cause) {
|
|
1833
|
+
throw new Error(
|
|
1834
|
+
"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.",
|
|
1835
|
+
{ cause }
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
const dispatcher = new undici.EnvHttpProxyAgent();
|
|
1839
|
+
return async (input, init) => {
|
|
1840
|
+
const [target, requestInit] = toUndiciRequest(input, init);
|
|
1841
|
+
return toGlobalResponse(
|
|
1842
|
+
await undici.fetch(target, { ...asUndiciInit(requestInit), dispatcher })
|
|
1470
1843
|
);
|
|
1471
|
-
return dispatcherPromise;
|
|
1472
1844
|
};
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1845
|
+
}
|
|
1846
|
+
function toUndiciRequest(input, init) {
|
|
1847
|
+
if (input instanceof Request) {
|
|
1848
|
+
return [
|
|
1849
|
+
input.url,
|
|
1850
|
+
{
|
|
1851
|
+
method: input.method,
|
|
1852
|
+
headers: input.headers,
|
|
1853
|
+
// A stream body needs `duplex: "half"`; the field is undici's, not in
|
|
1854
|
+
// the DOM `RequestInit`, so it rides along as an extra property.
|
|
1855
|
+
...input.body ? { body: input.body, duplex: "half" } : {},
|
|
1856
|
+
signal: input.signal,
|
|
1857
|
+
// Everything else the Request carries. Dropping these would silently
|
|
1858
|
+
// change fetch semantics under a proxy — most visibly `redirect`, where
|
|
1859
|
+
// a `new Request(url, { redirect: "manual" })` would start following
|
|
1860
|
+
// redirects instead of surfacing them.
|
|
1861
|
+
redirect: input.redirect,
|
|
1862
|
+
cache: input.cache,
|
|
1863
|
+
credentials: input.credentials,
|
|
1864
|
+
integrity: input.integrity,
|
|
1865
|
+
keepalive: input.keepalive,
|
|
1866
|
+
mode: input.mode,
|
|
1867
|
+
referrer: input.referrer,
|
|
1868
|
+
referrerPolicy: input.referrerPolicy,
|
|
1869
|
+
// An explicit `init` still wins, per the fetch contract.
|
|
1870
|
+
...init
|
|
1871
|
+
}
|
|
1872
|
+
];
|
|
1873
|
+
}
|
|
1874
|
+
return [input instanceof URL ? input.href : input, init ?? {}];
|
|
1875
|
+
}
|
|
1876
|
+
function createProxyFetch() {
|
|
1877
|
+
if (readProxyEnv() === void 0) return void 0;
|
|
1878
|
+
return async (input, init) => {
|
|
1879
|
+
loadedProxiedFetch ??= loadProxiedFetch();
|
|
1880
|
+
return (await loadedProxiedFetch)(input, init);
|
|
1476
1881
|
};
|
|
1477
|
-
return proxiedFetch;
|
|
1478
1882
|
}
|
|
1883
|
+
|
|
1884
|
+
// ../../core/mcp/node/transport.ts
|
|
1479
1885
|
function headersFromSettings(settings) {
|
|
1480
1886
|
if (!settings || settings.headers.length === 0) return void 0;
|
|
1481
1887
|
const out = {};
|
|
@@ -1495,10 +1901,12 @@ function createTransportNode(config, options = {}) {
|
|
|
1495
1901
|
onFetchResponseBody,
|
|
1496
1902
|
authProvider,
|
|
1497
1903
|
settings,
|
|
1498
|
-
interceptAuthChallenges = false
|
|
1904
|
+
interceptAuthChallenges = false,
|
|
1905
|
+
onAuthChallengeObserved
|
|
1499
1906
|
} = options;
|
|
1500
|
-
const baseFetch =
|
|
1501
|
-
const
|
|
1907
|
+
const baseFetch = optionsFetchFn ?? createProxyFetch() ?? globalThis.fetch;
|
|
1908
|
+
const withChallengeObserver = (inner) => onAuthChallengeObserved ? createAuthChallengeObserverFetch(inner, onAuthChallengeObserved) : inner;
|
|
1909
|
+
const fetchWithOptionalAuthIntercept = interceptAuthChallenges ? createAuthChallengeInterceptFetch(withChallengeObserver(baseFetch)) : withChallengeObserver(baseFetch);
|
|
1502
1910
|
if (serverType === "stdio") {
|
|
1503
1911
|
const stdioConfig = config;
|
|
1504
1912
|
const transport = new StdioClientTransport({
|
|
@@ -1523,7 +1931,8 @@ function createTransportNode(config, options = {}) {
|
|
|
1523
1931
|
} else if (serverType === "sse") {
|
|
1524
1932
|
const sseConfig = config;
|
|
1525
1933
|
const url = new URL(sseConfig.url);
|
|
1526
|
-
const
|
|
1934
|
+
const configuredSseFetch = sseConfig.eventSourceInit?.fetch;
|
|
1935
|
+
const sseFetch = configuredSseFetch ? withChallengeObserver(configuredSseFetch) : fetchWithOptionalAuthIntercept;
|
|
1527
1936
|
const trackedFetch = onFetchRequest ? createFetchTracker(sseFetch, {
|
|
1528
1937
|
trackRequest: onFetchRequest,
|
|
1529
1938
|
updateResponseBody: onFetchResponseBody
|
|
@@ -1701,27 +2110,27 @@ function resolveImportSource(type, platform, homeDir, readFile2) {
|
|
|
1701
2110
|
const strategy = getImportStrategy(type);
|
|
1702
2111
|
if (!strategy) return null;
|
|
1703
2112
|
const searched = strategy.defaultPaths(platform, homeDir);
|
|
1704
|
-
for (const
|
|
2113
|
+
for (const path6 of searched) {
|
|
1705
2114
|
let raw;
|
|
1706
2115
|
try {
|
|
1707
|
-
raw = readFile2(
|
|
2116
|
+
raw = readFile2(path6);
|
|
1708
2117
|
} catch (err) {
|
|
1709
2118
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1710
2119
|
return {
|
|
1711
2120
|
type,
|
|
1712
2121
|
found: true,
|
|
1713
|
-
path:
|
|
1714
|
-
error: `Failed to read ${
|
|
2122
|
+
path: path6,
|
|
2123
|
+
error: `Failed to read ${path6}: ${msg}`,
|
|
1715
2124
|
searched
|
|
1716
2125
|
};
|
|
1717
2126
|
}
|
|
1718
2127
|
if (raw === null) continue;
|
|
1719
2128
|
try {
|
|
1720
2129
|
const config = strategy.parse(raw);
|
|
1721
|
-
return { type, found: true, path:
|
|
2130
|
+
return { type, found: true, path: path6, config, searched };
|
|
1722
2131
|
} catch (err) {
|
|
1723
2132
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1724
|
-
return { type, found: true, path:
|
|
2133
|
+
return { type, found: true, path: path6, error: msg, searched };
|
|
1725
2134
|
}
|
|
1726
2135
|
}
|
|
1727
2136
|
return { type, found: false, searched };
|
|
@@ -1845,7 +2254,7 @@ var RemoteSession = class _RemoteSession {
|
|
|
1845
2254
|
* `auth_challenge` or `ok: true` only after the MCP round-trip (or auth error).
|
|
1846
2255
|
*/
|
|
1847
2256
|
waitForRequestResponse(requestId, timeoutMs = 6e4) {
|
|
1848
|
-
return new Promise((
|
|
2257
|
+
return new Promise((resolve9, reject) => {
|
|
1849
2258
|
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
1850
2259
|
this.requestWaits.delete(requestId);
|
|
1851
2260
|
reject(
|
|
@@ -1859,7 +2268,7 @@ var RemoteSession = class _RemoteSession {
|
|
|
1859
2268
|
if (timer !== void 0) {
|
|
1860
2269
|
clearTimeout(timer);
|
|
1861
2270
|
}
|
|
1862
|
-
|
|
2271
|
+
resolve9();
|
|
1863
2272
|
},
|
|
1864
2273
|
reject: (error) => {
|
|
1865
2274
|
if (timer !== void 0) {
|
|
@@ -2086,7 +2495,13 @@ function parseAccount(account) {
|
|
|
2086
2495
|
};
|
|
2087
2496
|
}
|
|
2088
2497
|
var buildAccount = (serverId, field) => `${serverId}:${field}`;
|
|
2089
|
-
var
|
|
2498
|
+
var SecretStoreUnavailableError = class extends Error {
|
|
2499
|
+
constructor(message, options) {
|
|
2500
|
+
super(message, options);
|
|
2501
|
+
this.name = "SecretStoreUnavailableError";
|
|
2502
|
+
}
|
|
2503
|
+
};
|
|
2504
|
+
var KeychainUnavailableError = class extends SecretStoreUnavailableError {
|
|
2090
2505
|
constructor(cause) {
|
|
2091
2506
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
2092
2507
|
super(
|
|
@@ -2104,7 +2519,47 @@ var hintFor = (cause, message) => {
|
|
|
2104
2519
|
}
|
|
2105
2520
|
return `On Linux, install libsecret / gnome-keyring.`;
|
|
2106
2521
|
};
|
|
2522
|
+
async function probeKeyringAvailable() {
|
|
2523
|
+
const keyring = await loadKeyring();
|
|
2524
|
+
if (!keyring.ok) {
|
|
2525
|
+
const err = keyring.err;
|
|
2526
|
+
return {
|
|
2527
|
+
available: false,
|
|
2528
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
try {
|
|
2532
|
+
const entry = new keyring.mod.AsyncEntry(SERVICE_NAME, PROBE_ACCOUNT);
|
|
2533
|
+
await entry.getPassword();
|
|
2534
|
+
return { available: true };
|
|
2535
|
+
} catch (err) {
|
|
2536
|
+
return {
|
|
2537
|
+
available: false,
|
|
2538
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
var PROBE_ACCOUNT = "__inspector:probe";
|
|
2107
2543
|
var KeyringSecretStore = class {
|
|
2544
|
+
/**
|
|
2545
|
+
* The intolerant read. Same lookup as {@link get}, minus the catch — a
|
|
2546
|
+
* keychain that cannot answer must not be reported as an empty one to a
|
|
2547
|
+
* caller that is about to write based on the answer.
|
|
2548
|
+
*/
|
|
2549
|
+
async getStrict(serverId, field) {
|
|
2550
|
+
try {
|
|
2551
|
+
const keyring = await loadKeyring();
|
|
2552
|
+
if (!keyring.ok) throw new KeychainUnavailableError(keyring.err);
|
|
2553
|
+
const entry = new keyring.mod.AsyncEntry(
|
|
2554
|
+
SERVICE_NAME,
|
|
2555
|
+
buildAccount(serverId, field)
|
|
2556
|
+
);
|
|
2557
|
+
return await entry.getPassword() ?? null;
|
|
2558
|
+
} catch (err) {
|
|
2559
|
+
if (err instanceof KeychainUnavailableError) throw err;
|
|
2560
|
+
throw new KeychainUnavailableError(err);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2108
2563
|
async get(serverId, field) {
|
|
2109
2564
|
try {
|
|
2110
2565
|
const keyring = await loadKeyring();
|
|
@@ -2163,61 +2618,1257 @@ var KeyringSecretStore = class {
|
|
|
2163
2618
|
}
|
|
2164
2619
|
}
|
|
2165
2620
|
};
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
import { z } from "zod";
|
|
2169
|
-
function isAbsoluteHttpUrl(value) {
|
|
2170
|
-
let url;
|
|
2171
|
-
try {
|
|
2172
|
-
url = new URL(value.trim());
|
|
2173
|
-
} catch {
|
|
2174
|
-
return false;
|
|
2175
|
-
}
|
|
2176
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
2177
|
-
return isRealHost(url.hostname);
|
|
2621
|
+
async function secretStoreGetStrict(store, serverId, field) {
|
|
2622
|
+
return store.getStrict ? store.getStrict(serverId, field) : store.get(serverId, field);
|
|
2178
2623
|
}
|
|
2179
|
-
function
|
|
2180
|
-
if (
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2624
|
+
async function secretStoreGetMany(store, requests) {
|
|
2625
|
+
if (store.getMany) return store.getMany(requests);
|
|
2626
|
+
const out = {};
|
|
2627
|
+
await Promise.all(
|
|
2628
|
+
requests.map(async ({ serverId, fields }) => {
|
|
2629
|
+
const entries = await Promise.all(
|
|
2630
|
+
fields.map(
|
|
2631
|
+
async (field) => [field, await store.get(serverId, field)]
|
|
2632
|
+
)
|
|
2633
|
+
);
|
|
2634
|
+
const found = {};
|
|
2635
|
+
for (const [field, value] of entries) {
|
|
2636
|
+
if (value !== null) found[field] = value;
|
|
2637
|
+
}
|
|
2638
|
+
out[serverId] = found;
|
|
2639
|
+
})
|
|
2640
|
+
);
|
|
2641
|
+
return out;
|
|
2184
2642
|
}
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2643
|
+
async function secretStoreSetMany(store, serverId, values) {
|
|
2644
|
+
if (store.setMany) return store.setMany(serverId, values);
|
|
2645
|
+
await Promise.all(
|
|
2646
|
+
Object.entries(values).map(
|
|
2647
|
+
([field, value]) => store.set(serverId, field, value)
|
|
2648
|
+
)
|
|
2649
|
+
);
|
|
2650
|
+
}
|
|
2651
|
+
async function secretStoreIsDurable(store) {
|
|
2652
|
+
return store.isDurable ? store.isDurable() : true;
|
|
2653
|
+
}
|
|
2654
|
+
var InMemorySecretStore = class {
|
|
2655
|
+
map = /* @__PURE__ */ new Map();
|
|
2656
|
+
async get(serverId, field) {
|
|
2657
|
+
return this.map.get(buildAccount(serverId, field)) ?? null;
|
|
2192
2658
|
}
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
var CIMD_METADATA_URL_HTTPS_ERROR = "CIMD client metadata URL must use HTTPS";
|
|
2196
|
-
var CIMD_METADATA_URL_PATH_ERROR = "Must include a path (not the site root), like https://example.com/oauth/client.json";
|
|
2197
|
-
function getCimdClientMetadataUrlError(value) {
|
|
2198
|
-
const trimmed = value.trim();
|
|
2199
|
-
if (trimmed === "") return void 0;
|
|
2200
|
-
if (!isAbsoluteHttpUrl(trimmed)) {
|
|
2201
|
-
return CIMD_METADATA_URL_INVALID_ERROR;
|
|
2659
|
+
async set(serverId, field, value) {
|
|
2660
|
+
this.map.set(buildAccount(serverId, field), value);
|
|
2202
2661
|
}
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
|
|
2662
|
+
async delete(serverId, field) {
|
|
2663
|
+
this.map.delete(buildAccount(serverId, field));
|
|
2664
|
+
}
|
|
2665
|
+
async deleteAllForServer(serverId) {
|
|
2666
|
+
const prefix = `${serverId}:`;
|
|
2667
|
+
for (const key of [...this.map.keys()]) {
|
|
2668
|
+
if (key.startsWith(prefix)) this.map.delete(key);
|
|
2210
2669
|
}
|
|
2211
|
-
} catch {
|
|
2212
|
-
return CIMD_METADATA_URL_INVALID_ERROR;
|
|
2213
2670
|
}
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2671
|
+
};
|
|
2672
|
+
var SessionSecretStore = class extends InMemorySecretStore {
|
|
2673
|
+
async isDurable() {
|
|
2674
|
+
return false;
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
|
|
2678
|
+
// ../../core/auth/node/secret-store-selection.ts
|
|
2679
|
+
import { randomUUID } from "crypto";
|
|
2680
|
+
import * as fsSync from "fs";
|
|
2681
|
+
import * as fs4 from "fs/promises";
|
|
2682
|
+
import * as path4 from "path";
|
|
2683
|
+
|
|
2684
|
+
// ../../core/auth/node/file-secret-store.ts
|
|
2685
|
+
import * as crypto2 from "crypto";
|
|
2686
|
+
import * as fs3 from "fs/promises";
|
|
2687
|
+
import * as path3 from "path";
|
|
2688
|
+
|
|
2689
|
+
// ../../core/auth/node/file-lock.ts
|
|
2690
|
+
import nodeFs, { statSync, rmdirSync } from "fs";
|
|
2691
|
+
import * as fs2 from "fs/promises";
|
|
2692
|
+
import * as path2 from "path";
|
|
2693
|
+
import properLockfile from "proper-lockfile";
|
|
2694
|
+
var STALE_MS = 1e4;
|
|
2695
|
+
var RETRY = {
|
|
2696
|
+
retries: 20,
|
|
2697
|
+
factor: 2,
|
|
2698
|
+
minTimeout: 20,
|
|
2699
|
+
maxTimeout: 1e3
|
|
2700
|
+
};
|
|
2701
|
+
var RETRY_BUDGET_MS = (() => {
|
|
2702
|
+
let total = 0;
|
|
2703
|
+
let delay = RETRY.minTimeout;
|
|
2704
|
+
for (let i = 0; i < RETRY.retries; i++) {
|
|
2705
|
+
total += Math.min(delay, RETRY.maxTimeout);
|
|
2706
|
+
delay *= RETRY.factor;
|
|
2707
|
+
}
|
|
2708
|
+
return total;
|
|
2709
|
+
})();
|
|
2710
|
+
var warned = /* @__PURE__ */ new Set();
|
|
2711
|
+
function warnOnce(message) {
|
|
2712
|
+
if (warned.has(message)) return;
|
|
2713
|
+
warned.add(message);
|
|
2714
|
+
console.warn(`[mcp-inspector] ${message}`);
|
|
2715
|
+
}
|
|
2716
|
+
function isStuckOrHeld(err, lockPath) {
|
|
2717
|
+
return isHeldElsewhere(err) || identifySync(lockPath) !== null;
|
|
2718
|
+
}
|
|
2719
|
+
var isHeldElsewhere = (err) => err.code === "ELOCKED";
|
|
2720
|
+
var describeError = (err) => err instanceof Error ? err.message : String(err);
|
|
2721
|
+
var lockPathOf = (target) => `${target}.lock`;
|
|
2722
|
+
function identifySync(lockPath) {
|
|
2723
|
+
try {
|
|
2724
|
+
const stat2 = statSync(lockPath);
|
|
2725
|
+
return { ino: stat2.ino, birthtimeMs: stat2.birthtimeMs };
|
|
2726
|
+
} catch {
|
|
2727
|
+
return null;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
function guardedFs(lockPath, owned, onRefused) {
|
|
2731
|
+
const mine = () => {
|
|
2732
|
+
if (owned.id === null) return true;
|
|
2733
|
+
const now = identifySync(lockPath);
|
|
2734
|
+
if (now === null) return false;
|
|
2735
|
+
return now.ino === owned.id.ino && now.birthtimeMs === owned.id.birthtimeMs;
|
|
2736
|
+
};
|
|
2737
|
+
const removeIfMine = () => {
|
|
2738
|
+
if (!mine()) {
|
|
2739
|
+
onRefused();
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
rmdirSync(lockPath);
|
|
2743
|
+
};
|
|
2744
|
+
return {
|
|
2745
|
+
...nodeFs,
|
|
2746
|
+
// **Identity is captured here, not after `lock()` resolves.** `mkdir` is
|
|
2747
|
+
// the moment the directory becomes ours, and it is synchronous with
|
|
2748
|
+
// respect to this process: recording it in the callback, before yielding,
|
|
2749
|
+
// leaves no window. Capturing after the acquire promise settled — as this
|
|
2750
|
+
// did originally — spans the library's own `utimes`/`stat` probe, and a
|
|
2751
|
+
// waiter replacing our directory inside that span would have us record
|
|
2752
|
+
// *the winner's* identity as our own, after which the guard below would
|
|
2753
|
+
// cheerfully delete their lock.
|
|
2754
|
+
mkdir: (p, cb) => {
|
|
2755
|
+
nodeFs.mkdir(p, (err) => {
|
|
2756
|
+
if (!err) owned.id = identifySync(lockPath);
|
|
2757
|
+
cb(err);
|
|
2758
|
+
});
|
|
2759
|
+
},
|
|
2760
|
+
// Reported as success when refused: the library's bookkeeping should
|
|
2761
|
+
// forget this lock either way. Leaving it registered would hand the
|
|
2762
|
+
// `signal-exit` handler a record pointing at the winner's directory.
|
|
2763
|
+
rmdir: (_p, cb) => {
|
|
2764
|
+
try {
|
|
2765
|
+
removeIfMine();
|
|
2766
|
+
cb(null);
|
|
2767
|
+
} catch (err) {
|
|
2768
|
+
cb(err);
|
|
2769
|
+
}
|
|
2770
|
+
},
|
|
2771
|
+
/* v8 ignore next 3 -- @preserve: only reachable from proper-lockfile's
|
|
2772
|
+
signal-exit handler, i.e. at real process exit, which no in-process
|
|
2773
|
+
test can drive. Its logic is `removeIfMine`, covered via `rmdir`. */
|
|
2774
|
+
rmdirSync: () => {
|
|
2775
|
+
removeIfMine();
|
|
2776
|
+
}
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
function acquire(target, retries, fsShim) {
|
|
2780
|
+
return properLockfile.lock(target, {
|
|
2781
|
+
realpath: false,
|
|
2782
|
+
stale: STALE_MS,
|
|
2783
|
+
retries,
|
|
2784
|
+
// Every directory removal the library performs — on release and from its
|
|
2785
|
+
// exit handler — routes through here. See `guardedFs`.
|
|
2786
|
+
fs: fsShim,
|
|
2787
|
+
// The library's default `onCompromised` *throws* — from a timer, with no
|
|
2788
|
+
// caller on the stack, so it lands as an uncaught exception and takes the
|
|
2789
|
+
// process down. This is also the library's *only* signal for the
|
|
2790
|
+
// stale-takeover race described at the top of this file — someone
|
|
2791
|
+
// declared our lock stale and replaced it — so it is the one place a user
|
|
2792
|
+
// learns the guarantee was lost. Worth saying; not worth killing an
|
|
2793
|
+
// Inspector session over.
|
|
2794
|
+
onCompromised: (err) => warnOnce(
|
|
2795
|
+
`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.`
|
|
2796
|
+
)
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
async function isFileLockHeld(filePath) {
|
|
2800
|
+
try {
|
|
2801
|
+
return await properLockfile.check(path2.resolve(filePath), {
|
|
2802
|
+
realpath: false,
|
|
2803
|
+
stale: STALE_MS
|
|
2804
|
+
});
|
|
2805
|
+
} catch {
|
|
2806
|
+
return false;
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
async function openSecretFileLock(filePath) {
|
|
2810
|
+
const target = path2.resolve(filePath);
|
|
2811
|
+
await fs2.mkdir(path2.dirname(target), { recursive: true }).catch(() => {
|
|
2812
|
+
});
|
|
2813
|
+
const owned = { id: null };
|
|
2814
|
+
const fsShim = guardedFs(
|
|
2815
|
+
lockPathOf(target),
|
|
2816
|
+
owned,
|
|
2817
|
+
() => warnOnce(
|
|
2818
|
+
`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.`
|
|
2819
|
+
)
|
|
2820
|
+
);
|
|
2821
|
+
let release;
|
|
2822
|
+
try {
|
|
2823
|
+
release = await acquire(target, 0, fsShim).catch((err) => {
|
|
2824
|
+
if (!isStuckOrHeld(err, lockPathOf(target))) throw err;
|
|
2825
|
+
return acquire(target, RETRY, fsShim);
|
|
2826
|
+
});
|
|
2827
|
+
} catch (err) {
|
|
2828
|
+
if (isStuckOrHeld(err, lockPathOf(target))) {
|
|
2829
|
+
throw new SecretStoreUnavailableError(
|
|
2830
|
+
`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.`
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
warnOnce(
|
|
2834
|
+
`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.`
|
|
2835
|
+
);
|
|
2836
|
+
return null;
|
|
2837
|
+
}
|
|
2838
|
+
return async () => {
|
|
2839
|
+
try {
|
|
2840
|
+
await release();
|
|
2841
|
+
} catch (err) {
|
|
2842
|
+
if (err.code === "ERELEASED") return;
|
|
2843
|
+
warnOnce(
|
|
2844
|
+
// **Conditional, not an instruction.** Two blanket versions of this
|
|
2845
|
+
// have now been wrong in opposite directions: "it expires on its own"
|
|
2846
|
+
// (false whenever the same `rmdir` that blocked us also blocks stale
|
|
2847
|
+
// takeover — `ENOTEMPTY`, `EACCES`, `EPERM`, `EROFS`), and "remove it
|
|
2848
|
+
// by hand" (false whenever the failure was transient, since the path
|
|
2849
|
+
// may by then hold a *different, live* Inspector's lock, and deleting
|
|
2850
|
+
// that destroys the exclusion of a process that did nothing wrong).
|
|
2851
|
+
//
|
|
2852
|
+
// `proper-lockfile` forwards whatever the filesystem returned, and
|
|
2853
|
+
// nothing here can tell a permanent refusal from a passing one. So
|
|
2854
|
+
// this reports what happened and states the two conditions the
|
|
2855
|
+
// operator can check for themselves, rather than asserting an outcome
|
|
2856
|
+
// this code does not know.
|
|
2857
|
+
`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.`
|
|
2858
|
+
);
|
|
2859
|
+
}
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
async function withSecretFileLock(filePath, fn) {
|
|
2863
|
+
const release = await openSecretFileLock(filePath);
|
|
2864
|
+
if (release === null) return fn();
|
|
2865
|
+
try {
|
|
2866
|
+
return await fn();
|
|
2867
|
+
} finally {
|
|
2868
|
+
await release();
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
// ../../core/auth/node/file-secret-store.ts
|
|
2873
|
+
var scrypt2 = (passphrase, salt, keylen, options) => new Promise((resolve9, reject) => {
|
|
2874
|
+
crypto2.scrypt(passphrase, salt, keylen, options, (err, key) => {
|
|
2875
|
+
if (err) reject(err);
|
|
2876
|
+
else resolve9(key);
|
|
2877
|
+
});
|
|
2878
|
+
});
|
|
2879
|
+
var FORMAT_VERSION = 1;
|
|
2880
|
+
var CIPHER = "aes-256-gcm";
|
|
2881
|
+
var KEY_BYTES = 32;
|
|
2882
|
+
var IV_BYTES = 12;
|
|
2883
|
+
var GCM_TAG_BYTES = 16;
|
|
2884
|
+
var SCRYPT_N = 16384;
|
|
2885
|
+
var SCRYPT_R = 8;
|
|
2886
|
+
var SCRYPT_P = 1;
|
|
2887
|
+
var SECRET_KEY_ENV = "MCP_INSPECTOR_SECRET_KEY";
|
|
2888
|
+
var buildAccount2 = (serverId, field) => `${serverId}:${field}`;
|
|
2889
|
+
function asSecretMap(value, filePath) {
|
|
2890
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2891
|
+
throw new SecretStoreUnavailableError(
|
|
2892
|
+
`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.`
|
|
2893
|
+
);
|
|
2894
|
+
}
|
|
2895
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
2896
|
+
if (typeof entry !== "string") {
|
|
2897
|
+
throw new SecretStoreUnavailableError(
|
|
2898
|
+
`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.`
|
|
2899
|
+
);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
return { ...value };
|
|
2903
|
+
}
|
|
2904
|
+
var SecretFileKeyMismatchError = class extends SecretStoreUnavailableError {
|
|
2905
|
+
constructor(filePath, hasKey) {
|
|
2906
|
+
super(
|
|
2907
|
+
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.`
|
|
2908
|
+
);
|
|
2909
|
+
this.name = "SecretFileKeyMismatchError";
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
var encodeParts = (iv, tag, body) => `${iv.toString("base64")}.${tag.toString("base64")}.${body.toString("base64")}`;
|
|
2913
|
+
function decodeParts(payload) {
|
|
2914
|
+
const parts = payload.split(".");
|
|
2915
|
+
if (parts.length !== 3) return null;
|
|
2916
|
+
const [iv, tag, body] = parts;
|
|
2917
|
+
return {
|
|
2918
|
+
iv: Buffer.from(iv, "base64"),
|
|
2919
|
+
tag: Buffer.from(tag, "base64"),
|
|
2920
|
+
body: Buffer.from(body, "base64")
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
var MAX_WRITE_ATTEMPTS = 5;
|
|
2924
|
+
var fileQueues = /* @__PURE__ */ new Map();
|
|
2925
|
+
function sameMap(a, b) {
|
|
2926
|
+
const ak = Object.keys(a);
|
|
2927
|
+
if (ak.length !== Object.keys(b).length) return false;
|
|
2928
|
+
return ak.every(
|
|
2929
|
+
(k) => Object.prototype.hasOwnProperty.call(b, k) && a[k] === b[k]
|
|
2930
|
+
);
|
|
2931
|
+
}
|
|
2932
|
+
var isPositiveInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
|
|
2933
|
+
function encryptedEnvelopeProblem(parsed) {
|
|
2934
|
+
if (!parsed.kdf || typeof parsed.data !== "string") {
|
|
2935
|
+
return "encrypted envelope is missing its kdf or data";
|
|
2936
|
+
}
|
|
2937
|
+
const parts = decodeParts(parsed.data);
|
|
2938
|
+
if (!parts) return "encrypted payload is not iv.tag.ciphertext";
|
|
2939
|
+
if (parts.iv.length !== IV_BYTES) {
|
|
2940
|
+
return `initialization vector is ${parts.iv.length} bytes, expected ${IV_BYTES}`;
|
|
2941
|
+
}
|
|
2942
|
+
if (parts.tag.length !== GCM_TAG_BYTES) {
|
|
2943
|
+
return `authentication tag is ${parts.tag.length} bytes, expected ${GCM_TAG_BYTES}`;
|
|
2944
|
+
}
|
|
2945
|
+
if (parts.body.length === 0) return "ciphertext is empty";
|
|
2946
|
+
const { algorithm, salt, N, r, p } = parsed.kdf;
|
|
2947
|
+
if (algorithm !== "scrypt") return `unsupported kdf "${String(algorithm)}"`;
|
|
2948
|
+
if (typeof salt !== "string" || Buffer.from(salt, "base64").length === 0) {
|
|
2949
|
+
return "kdf salt is missing or not base64";
|
|
2950
|
+
}
|
|
2951
|
+
if (!isPositiveInt(N) || N < 2 || (N & N - 1) !== 0) {
|
|
2952
|
+
return `kdf N is ${String(N)}, which is not a power of two above 1`;
|
|
2953
|
+
}
|
|
2954
|
+
if (!isPositiveInt(r) || !isPositiveInt(p)) {
|
|
2955
|
+
return `kdf r/p are ${String(r)}/${String(p)}, which are not positive integers`;
|
|
2956
|
+
}
|
|
2957
|
+
if (N > SCRYPT_N || r > SCRYPT_R || p > SCRYPT_P) {
|
|
2958
|
+
return `kdf cost parameters (N=${N}, r=${r}, p=${p}) exceed the supported maximum (N=${SCRYPT_N}, r=${SCRYPT_R}, p=${SCRYPT_P})`;
|
|
2959
|
+
}
|
|
2960
|
+
return null;
|
|
2961
|
+
}
|
|
2962
|
+
var FileSecretStore = class {
|
|
2963
|
+
filePath;
|
|
2964
|
+
passphrase;
|
|
2965
|
+
constructor(options) {
|
|
2966
|
+
this.filePath = options.filePath;
|
|
2967
|
+
const raw = options.passphrase ?? process.env[SECRET_KEY_ENV];
|
|
2968
|
+
this.passphrase = raw && raw.trim() ? raw : void 0;
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* True when the *next write* will encrypt. This is the configured policy,
|
|
2972
|
+
* not the state of the file — see {@link readOnDiskEncryption}, which is
|
|
2973
|
+
* what the user-facing descriptor is built from.
|
|
2974
|
+
*/
|
|
2975
|
+
get encrypted() {
|
|
2976
|
+
return this.passphrase !== void 0;
|
|
2977
|
+
}
|
|
2978
|
+
/**
|
|
2979
|
+
* What the file on disk is *currently* written as — `null` when there is
|
|
2980
|
+
* no file yet, or when it cannot be read or parsed.
|
|
2981
|
+
*
|
|
2982
|
+
* Separate from {@link encrypted} because the two genuinely disagree for a
|
|
2983
|
+
* whole session: adding `MCP_INSPECTOR_SECRET_KEY` to an install that
|
|
2984
|
+
* already has a plaintext file flips `encrypted` to true immediately,
|
|
2985
|
+
* while the bytes stay readable until the next `set`. A descriptor built
|
|
2986
|
+
* from the policy would tell that user "File (encrypted)" while their
|
|
2987
|
+
* existing secrets sat in the clear — the precise reassurance this whole
|
|
2988
|
+
* subsystem exists not to give.
|
|
2989
|
+
*
|
|
2990
|
+
* Reads the envelope, and for an encrypted one **attempts to open it**.
|
|
2991
|
+
* Structure alone is not access: a well-formed file this passphrase cannot
|
|
2992
|
+
* decrypt answers `null` from every `get` and refuses every `set`, so
|
|
2993
|
+
* reporting it as a healthy "encrypted" store hands the quiet footer to
|
|
2994
|
+
* exactly the state the unreadable one exists to warn about. The
|
|
2995
|
+
* authentication costs one scrypt derivation per call, which is once per
|
|
2996
|
+
* `/api/config`, and the decrypted bytes are discarded.
|
|
2997
|
+
*
|
|
2998
|
+
* `absent` and `unreadable` are kept apart deliberately. Collapsing them
|
|
2999
|
+
* (as a bare `null` did) means an existing file that is corrupt, or
|
|
3000
|
+
* written in an envelope this build does not know, is reported as "no
|
|
3001
|
+
* file yet" — and the descriptor then falls back to the configured write
|
|
3002
|
+
* policy, so a passphrase-configured install claims "Encrypted file"
|
|
3003
|
+
* about a file whose encryption state was never established and which
|
|
3004
|
+
* `set` is in fact about to refuse.
|
|
3005
|
+
*/
|
|
3006
|
+
async readOnDiskEncryption() {
|
|
3007
|
+
let raw;
|
|
3008
|
+
try {
|
|
3009
|
+
raw = await readStoreFile(this.filePath);
|
|
3010
|
+
} catch (err) {
|
|
3011
|
+
return {
|
|
3012
|
+
state: "unreadable",
|
|
3013
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
if (raw === null) return { state: "absent" };
|
|
3017
|
+
try {
|
|
3018
|
+
const parsed = JSON.parse(raw);
|
|
3019
|
+
if (parsed.version !== FORMAT_VERSION) {
|
|
3020
|
+
return {
|
|
3021
|
+
state: "unreadable",
|
|
3022
|
+
detail: `format version ${String(parsed.version)}, expected ${FORMAT_VERSION}`
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
3025
|
+
if (parsed.encryption === "none") {
|
|
3026
|
+
try {
|
|
3027
|
+
asSecretMap(parsed.secrets ?? {}, this.filePath);
|
|
3028
|
+
} catch (err) {
|
|
3029
|
+
return {
|
|
3030
|
+
state: "unreadable",
|
|
3031
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
return { state: "plaintext" };
|
|
3035
|
+
}
|
|
3036
|
+
if (parsed.encryption === CIPHER) {
|
|
3037
|
+
const problem = encryptedEnvelopeProblem(parsed);
|
|
3038
|
+
if (problem) return { state: "unreadable", detail: problem };
|
|
3039
|
+
try {
|
|
3040
|
+
await this.readMap();
|
|
3041
|
+
} catch (err) {
|
|
3042
|
+
return {
|
|
3043
|
+
state: "unreadable",
|
|
3044
|
+
detail: err instanceof SecretFileKeyMismatchError ? `it cannot be decrypted with the current ${SECRET_KEY_ENV}` : err instanceof Error ? err.message : String(err)
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
return { state: "encrypted" };
|
|
3048
|
+
}
|
|
3049
|
+
return {
|
|
3050
|
+
state: "unreadable",
|
|
3051
|
+
detail: `unsupported envelope (encryption="${String(parsed.encryption)}", version=${String(parsed.version)})`
|
|
3052
|
+
};
|
|
3053
|
+
} catch {
|
|
3054
|
+
return { state: "unreadable", detail: "not valid JSON" };
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
async deriveKey(kdf) {
|
|
3058
|
+
return scrypt2(
|
|
3059
|
+
// Only reached when the passphrase is set — every caller checks first,
|
|
3060
|
+
// which is what makes the non-null assertion safe here rather than a
|
|
3061
|
+
// widened `string | undefined` threaded through the crypto helpers.
|
|
3062
|
+
this.passphrase,
|
|
3063
|
+
Buffer.from(kdf.salt, "base64"),
|
|
3064
|
+
KEY_BYTES,
|
|
3065
|
+
{ N: kdf.N, r: kdf.r, p: kdf.p }
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
3068
|
+
/**
|
|
3069
|
+
* Read and decode the file into a plain map.
|
|
3070
|
+
*
|
|
3071
|
+
* Distinguishes three outcomes that callers treat differently: `null` for
|
|
3072
|
+
* "no file yet" (a first write should create one), a map for success, and a
|
|
3073
|
+
* throw for "there is a file and we cannot read it" — which must never be
|
|
3074
|
+
* flattened into an empty map, or `set` would cheerfully overwrite it.
|
|
3075
|
+
*/
|
|
3076
|
+
/**
|
|
3077
|
+
* Every secret in the file, keyed by `${serverId}:${field}` — `null` when
|
|
3078
|
+
* there is no file.
|
|
3079
|
+
*
|
|
3080
|
+
* Exists for one caller: the hand-off performed when a keychain becomes
|
|
3081
|
+
* available on a box that had been falling back to a file (see
|
|
3082
|
+
* `absorbFileSecretsIntoKeyring`). Nothing in the normal request path
|
|
3083
|
+
* enumerates secrets, and deliberately so — this is the widest possible
|
|
3084
|
+
* read of the store and it should stay a one-purpose seam rather than a
|
|
3085
|
+
* general convenience.
|
|
3086
|
+
*
|
|
3087
|
+
* Throws for the same reasons `get` swallows: an unreadable or
|
|
3088
|
+
* undecryptable file must not present as "no secrets", because the caller
|
|
3089
|
+
* would conclude there was nothing to migrate and move on.
|
|
3090
|
+
*/
|
|
3091
|
+
async readAll() {
|
|
3092
|
+
return this.serialize(() => this.readMap());
|
|
3093
|
+
}
|
|
3094
|
+
async readMap() {
|
|
3095
|
+
const raw = await readStoreFile(this.filePath);
|
|
3096
|
+
if (raw === null) return null;
|
|
3097
|
+
let parsed;
|
|
3098
|
+
try {
|
|
3099
|
+
parsed = JSON.parse(raw);
|
|
3100
|
+
} catch (err) {
|
|
3101
|
+
throw new SecretStoreUnavailableError(
|
|
3102
|
+
`The secrets file at ${this.filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
3103
|
+
);
|
|
3104
|
+
}
|
|
3105
|
+
if (parsed.version !== FORMAT_VERSION) {
|
|
3106
|
+
throw new SecretStoreUnavailableError(
|
|
3107
|
+
`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.`
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
3110
|
+
if (parsed.encryption === "none") {
|
|
3111
|
+
return asSecretMap(parsed.secrets ?? {}, this.filePath);
|
|
3112
|
+
}
|
|
3113
|
+
if (parsed.encryption !== CIPHER || !parsed.kdf || !parsed.data) {
|
|
3114
|
+
throw new SecretStoreUnavailableError(
|
|
3115
|
+
`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.`
|
|
3116
|
+
);
|
|
3117
|
+
}
|
|
3118
|
+
if (!this.passphrase)
|
|
3119
|
+
throw new SecretFileKeyMismatchError(this.filePath, false);
|
|
3120
|
+
const structural = encryptedEnvelopeProblem(parsed);
|
|
3121
|
+
if (structural) {
|
|
3122
|
+
throw new SecretStoreUnavailableError(
|
|
3123
|
+
`The secrets file at ${this.filePath} is not readable: ${structural}. Restoring a passphrase will not repair it; the file itself is malformed.`
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
const parts = decodeParts(parsed.data);
|
|
3127
|
+
if (!parts) throw new SecretFileKeyMismatchError(this.filePath, true);
|
|
3128
|
+
let plain;
|
|
3129
|
+
try {
|
|
3130
|
+
const key = await this.deriveKey(parsed.kdf);
|
|
3131
|
+
const decipher = crypto2.createDecipheriv(CIPHER, key, parts.iv);
|
|
3132
|
+
decipher.setAuthTag(parts.tag);
|
|
3133
|
+
plain = Buffer.concat([
|
|
3134
|
+
decipher.update(parts.body),
|
|
3135
|
+
decipher.final()
|
|
3136
|
+
]).toString("utf-8");
|
|
3137
|
+
} catch {
|
|
3138
|
+
throw new SecretFileKeyMismatchError(this.filePath, true);
|
|
3139
|
+
}
|
|
3140
|
+
let payload;
|
|
3141
|
+
try {
|
|
3142
|
+
payload = JSON.parse(plain);
|
|
3143
|
+
} catch (err) {
|
|
3144
|
+
throw new SecretStoreUnavailableError(
|
|
3145
|
+
`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.`
|
|
3146
|
+
);
|
|
3147
|
+
}
|
|
3148
|
+
return asSecretMap(payload, this.filePath);
|
|
3149
|
+
}
|
|
3150
|
+
async writeMap(map) {
|
|
3151
|
+
let file;
|
|
3152
|
+
if (this.passphrase) {
|
|
3153
|
+
const kdf = {
|
|
3154
|
+
algorithm: "scrypt",
|
|
3155
|
+
// A fresh salt per write, not per file: rewriting with the same salt
|
|
3156
|
+
// and a fresh IV would still be safe for GCM, but re-salting costs one
|
|
3157
|
+
// scrypt derivation we were doing anyway and removes the question.
|
|
3158
|
+
salt: crypto2.randomBytes(16).toString("base64"),
|
|
3159
|
+
N: SCRYPT_N,
|
|
3160
|
+
r: SCRYPT_R,
|
|
3161
|
+
p: SCRYPT_P
|
|
3162
|
+
};
|
|
3163
|
+
const key = await this.deriveKey(kdf);
|
|
3164
|
+
const iv = crypto2.randomBytes(IV_BYTES);
|
|
3165
|
+
const cipher = crypto2.createCipheriv(CIPHER, key, iv);
|
|
3166
|
+
const body = Buffer.concat([
|
|
3167
|
+
cipher.update(JSON.stringify(map), "utf-8"),
|
|
3168
|
+
cipher.final()
|
|
3169
|
+
]);
|
|
3170
|
+
file = {
|
|
3171
|
+
version: FORMAT_VERSION,
|
|
3172
|
+
encryption: CIPHER,
|
|
3173
|
+
kdf,
|
|
3174
|
+
data: encodeParts(iv, cipher.getAuthTag(), body)
|
|
3175
|
+
};
|
|
3176
|
+
} else {
|
|
3177
|
+
file = { version: FORMAT_VERSION, encryption: "none", secrets: map };
|
|
3178
|
+
}
|
|
3179
|
+
await writeStoreFile(this.filePath, `${JSON.stringify(file, null, 2)}
|
|
3180
|
+
`);
|
|
3181
|
+
}
|
|
3182
|
+
/**
|
|
3183
|
+
* Apply a mutation to the file, under a cross-process lock, and confirm it
|
|
3184
|
+
* survived.
|
|
3185
|
+
*
|
|
3186
|
+
* **Two mechanisms, and they answer different questions** — this is not
|
|
3187
|
+
* belt-and-braces.
|
|
3188
|
+
*
|
|
3189
|
+
* The lock ({@link withSecretFileLock}, #2082) is the one that provides
|
|
3190
|
+
* mutual exclusion: a durable secrets file is shared state, and a second
|
|
3191
|
+
* Inspector on the same box — a CLI run beside a web session is the
|
|
3192
|
+
* ordinary case, not a contrived one — otherwise reads the same "before"
|
|
3193
|
+
* map and then atomically replaces the file, dropping whatever the first
|
|
3194
|
+
* process had just added. Both writes report success; one secret is simply
|
|
3195
|
+
* gone. Held across the read *and* the write, so no other holder can
|
|
3196
|
+
* observe or replace the map in between.
|
|
3197
|
+
*
|
|
3198
|
+
* The verify covers what a lock structurally cannot. A lock is an advisory
|
|
3199
|
+
* convention between the processes that take it, so it says nothing about
|
|
3200
|
+
* a writer that does not: an editor, a restored backup, a `jq` one-liner,
|
|
3201
|
+
* or an Inspector predating #2082. It is also the fallback when the lock
|
|
3202
|
+
* is unavailable at all — `withSecretFileLock` runs the body anyway on a
|
|
3203
|
+
* read-only or lock-hostile directory rather than failing a `set` on
|
|
3204
|
+
* exactly the deployments this store exists for (#1848, #1905).
|
|
3205
|
+
*
|
|
3206
|
+
* So: read `M0`, apply the mutation to get `M1`, write it, then read back
|
|
3207
|
+
* `M2`. If `M2` equals `M1` nothing interleaved. If it does not, someone
|
|
3208
|
+
* wrote between our write and our read — so re-apply onto what they left
|
|
3209
|
+
* and try again.
|
|
3210
|
+
*
|
|
3211
|
+
* The comparison is over the **whole map**, not just the entry we touched.
|
|
3212
|
+
* Checking only our own key would pass in precisely the case that loses
|
|
3213
|
+
* data: our entry is present, and it is the *other* writer's that is gone.
|
|
3214
|
+
* When the verify does fire, the writer that was clobbered is the one that
|
|
3215
|
+
* repairs it — A writes `MA`, B writes `MB` over it, B verifies `MB`
|
|
3216
|
+
* correctly, A verifies, sees `M2 !== MA`, re-applies onto `MB`.
|
|
3217
|
+
*
|
|
3218
|
+
* **What is left, stated without overselling.** Against an unlocked writer
|
|
3219
|
+
* the verify is not mutual exclusion: it only catches a clobber that has
|
|
3220
|
+
* already landed, so ordering the two as write-A, verify-A, write-B,
|
|
3221
|
+
* verify-B loses A's entry with both reporting success. That residual is
|
|
3222
|
+
* now confined to a writer outside this codebase, or to a directory where
|
|
3223
|
+
* no lock could be taken — and the latter announces itself, once, on the
|
|
3224
|
+
* console.
|
|
3225
|
+
*
|
|
3226
|
+
* Reads deliberately do not participate: `writeStoreFile` is atomic
|
|
3227
|
+
* (write-temp-then-rename), so a reader sees either the old file or the
|
|
3228
|
+
* new one, never a torn one. Taking the lock for them would serialize
|
|
3229
|
+
* every `GET /api/servers` behind whatever else holds it, to prevent
|
|
3230
|
+
* nothing.
|
|
3231
|
+
*/
|
|
3232
|
+
async mutate(apply) {
|
|
3233
|
+
await withSecretFileLock(this.filePath, () => this.mutateLocked(apply));
|
|
3234
|
+
}
|
|
3235
|
+
/** {@link mutate}'s body, with the lock already held. */
|
|
3236
|
+
async mutateLocked(apply) {
|
|
3237
|
+
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
|
|
3238
|
+
let current;
|
|
3239
|
+
try {
|
|
3240
|
+
current = await this.readMap() ?? {};
|
|
3241
|
+
} catch (err) {
|
|
3242
|
+
if (err instanceof SecretStoreUnavailableError) throw err;
|
|
3243
|
+
throw new SecretStoreUnavailableError(
|
|
3244
|
+
`Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
3245
|
+
);
|
|
3246
|
+
}
|
|
3247
|
+
const next = apply(current);
|
|
3248
|
+
if (next === null) return;
|
|
3249
|
+
try {
|
|
3250
|
+
await this.writeMap(next);
|
|
3251
|
+
} catch (err) {
|
|
3252
|
+
throw new SecretStoreUnavailableError(
|
|
3253
|
+
`Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
3254
|
+
);
|
|
3255
|
+
}
|
|
3256
|
+
let observed;
|
|
3257
|
+
try {
|
|
3258
|
+
observed = await this.readMap() ?? {};
|
|
3259
|
+
} catch {
|
|
3260
|
+
continue;
|
|
3261
|
+
}
|
|
3262
|
+
if (sameMap(next, observed)) return;
|
|
3263
|
+
}
|
|
3264
|
+
throw new SecretStoreUnavailableError(
|
|
3265
|
+
`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.`
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* Run `fn` with the file to itself, so concurrent callers can't lose
|
|
3270
|
+
* writes.
|
|
3271
|
+
*
|
|
3272
|
+
* Keyed on the **resolved path, process-wide** rather than on this
|
|
3273
|
+
* instance. Two `FileSecretStore`s pointing at one file are ordinary —
|
|
3274
|
+
* the resolved store holds one and `absorbFileSecretsIntoKeyring`
|
|
3275
|
+
* constructs another — and a per-instance queue does not order them
|
|
3276
|
+
* against each other at all. They then race in-process, which the
|
|
3277
|
+
* optimistic verify is only *supposed* to catch for writers in different
|
|
3278
|
+
* processes: both read the same map, both write, and whichever verifies
|
|
3279
|
+
* before the other's write lands sees its own value and returns happy.
|
|
3280
|
+
* Measured, not theorised — two instances racing `set` lost an entry on
|
|
3281
|
+
* the first run of the convergence test below.
|
|
3282
|
+
*
|
|
3283
|
+
* So the in-process case is made correct by construction here, and
|
|
3284
|
+
* {@link mutate}'s cross-process lock covers what this cannot see: a
|
|
3285
|
+
* second Inspector process.
|
|
3286
|
+
*
|
|
3287
|
+
* The queue is *also* what keeps that lock usable. `proper-lockfile` is
|
|
3288
|
+
* not reentrant — a second `lock()` on a path this process already holds
|
|
3289
|
+
* fails `ELOCKED`, which is indistinguishable from a genuine remote
|
|
3290
|
+
* holder. Serializing here means the lock is only ever contended between
|
|
3291
|
+
* processes, which is the only contention it is asked to arbitrate.
|
|
3292
|
+
*/
|
|
3293
|
+
serialize(fn) {
|
|
3294
|
+
const key = path3.resolve(this.filePath);
|
|
3295
|
+
const run = (fileQueues.get(key) ?? Promise.resolve()).then(fn);
|
|
3296
|
+
const settled = run.then(
|
|
3297
|
+
() => {
|
|
3298
|
+
},
|
|
3299
|
+
() => {
|
|
3300
|
+
}
|
|
3301
|
+
);
|
|
3302
|
+
fileQueues.set(key, settled);
|
|
3303
|
+
void settled.then(() => {
|
|
3304
|
+
if (fileQueues.get(key) === settled) fileQueues.delete(key);
|
|
3305
|
+
});
|
|
3306
|
+
return run;
|
|
3307
|
+
}
|
|
3308
|
+
/**
|
|
3309
|
+
* One read and one derivation for an entire rehydration.
|
|
3310
|
+
*
|
|
3311
|
+
* `get` reads and decrypts the whole file, so the cost is per *call*, not
|
|
3312
|
+
* per field — which is why the batch has to span servers. A per-server
|
|
3313
|
+
* version of this shipped first and left a 20-server catalog paying 20
|
|
3314
|
+
* serialized derivations, the same stall it was written to remove.
|
|
3315
|
+
* Tolerant like `get`: an unreadable store yields no fields for anyone
|
|
3316
|
+
* rather than throwing.
|
|
3317
|
+
*/
|
|
3318
|
+
async getMany(requests) {
|
|
3319
|
+
let map;
|
|
3320
|
+
try {
|
|
3321
|
+
map = await this.serialize(() => this.readMap());
|
|
3322
|
+
} catch {
|
|
3323
|
+
map = null;
|
|
3324
|
+
}
|
|
3325
|
+
const out = {};
|
|
3326
|
+
for (const { serverId, fields } of requests) {
|
|
3327
|
+
const found = {};
|
|
3328
|
+
for (const field of fields) {
|
|
3329
|
+
const value = map?.[buildAccount2(serverId, field)];
|
|
3330
|
+
if (value !== void 0) found[field] = value;
|
|
3331
|
+
}
|
|
3332
|
+
out[serverId] = found;
|
|
3333
|
+
}
|
|
3334
|
+
return out;
|
|
3335
|
+
}
|
|
3336
|
+
/**
|
|
3337
|
+
* The intolerant read — same lookup as {@link get}, minus the catch.
|
|
3338
|
+
*
|
|
3339
|
+
* Without this, `secretStoreGetStrict` fell back to `get` for the file
|
|
3340
|
+
* store and turned every read failure into `null`. Both plaintext
|
|
3341
|
+
* migrations *write* on `null` and then strip the disk copy, so a
|
|
3342
|
+
* transient read error that cleared before the write let an older
|
|
3343
|
+
* on-disk value replace a newer stored one — the same inversion the
|
|
3344
|
+
* strict seam was introduced to prevent, still open on the store the
|
|
3345
|
+
* seam was introduced for.
|
|
3346
|
+
*/
|
|
3347
|
+
async getStrict(serverId, field) {
|
|
3348
|
+
try {
|
|
3349
|
+
const map = await this.serialize(() => this.readMap());
|
|
3350
|
+
return map?.[buildAccount2(serverId, field)] ?? null;
|
|
3351
|
+
} catch (err) {
|
|
3352
|
+
if (err instanceof SecretStoreUnavailableError) throw err;
|
|
3353
|
+
throw new SecretStoreUnavailableError(
|
|
3354
|
+
`Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
3355
|
+
);
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
async get(serverId, field) {
|
|
3359
|
+
try {
|
|
3360
|
+
const map = await this.serialize(() => this.readMap());
|
|
3361
|
+
return map?.[buildAccount2(serverId, field)] ?? null;
|
|
3362
|
+
} catch {
|
|
3363
|
+
return null;
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
/**
|
|
3367
|
+
* One read-modify-write-verify cycle for a whole server's fields.
|
|
3368
|
+
*
|
|
3369
|
+
* `set` pays that cycle — and, encrypted, three scrypt derivations — per
|
|
3370
|
+
* call, serialized behind this store's queue. The settings form resends a
|
|
3371
|
+
* server's full `env` map on any edit, so a 10-variable stdio server spent
|
|
3372
|
+
* ~30 derivations saving an unrelated timeout change.
|
|
3373
|
+
*/
|
|
3374
|
+
async setMany(serverId, values) {
|
|
3375
|
+
if (Object.keys(values).length === 0) return;
|
|
3376
|
+
await this.serialize(
|
|
3377
|
+
() => this.mutate((map) => {
|
|
3378
|
+
const next = { ...map };
|
|
3379
|
+
for (const [field, value] of Object.entries(values)) {
|
|
3380
|
+
next[buildAccount2(serverId, field)] = value;
|
|
3381
|
+
}
|
|
3382
|
+
return next;
|
|
3383
|
+
})
|
|
3384
|
+
);
|
|
3385
|
+
}
|
|
3386
|
+
async set(serverId, field, value) {
|
|
3387
|
+
await this.serialize(
|
|
3388
|
+
() => this.mutate((map) => ({
|
|
3389
|
+
...map,
|
|
3390
|
+
[buildAccount2(serverId, field)]: value
|
|
3391
|
+
}))
|
|
3392
|
+
);
|
|
3393
|
+
}
|
|
3394
|
+
async delete(serverId, field) {
|
|
3395
|
+
await this.deleteWhere((key) => key === buildAccount2(serverId, field));
|
|
3396
|
+
}
|
|
3397
|
+
async deleteAllForServer(serverId) {
|
|
3398
|
+
const prefix = `${serverId}:`;
|
|
3399
|
+
await this.deleteWhere((key) => key.startsWith(prefix));
|
|
3400
|
+
}
|
|
3401
|
+
/**
|
|
3402
|
+
* Shared delete body. Silent on every failure, matching the keyring store:
|
|
3403
|
+
* every reason a delete can fail collapses to "the entry isn't there
|
|
3404
|
+
* anymore", and `set` is the operation that reports a broken store.
|
|
3405
|
+
*/
|
|
3406
|
+
async deleteWhere(match) {
|
|
3407
|
+
try {
|
|
3408
|
+
await this.serialize(
|
|
3409
|
+
() => this.mutate((map) => {
|
|
3410
|
+
const keys = Object.keys(map).filter(match);
|
|
3411
|
+
if (keys.length === 0) return null;
|
|
3412
|
+
const next = { ...map };
|
|
3413
|
+
for (const key of keys) delete next[key];
|
|
3414
|
+
return next;
|
|
3415
|
+
})
|
|
3416
|
+
);
|
|
3417
|
+
} catch {
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
};
|
|
3421
|
+
async function tightenSecretFilePermissions(filePath) {
|
|
3422
|
+
try {
|
|
3423
|
+
await fs3.chmod(filePath, 384);
|
|
3424
|
+
} catch {
|
|
3425
|
+
}
|
|
3426
|
+
return readSecretFilePermissions(filePath);
|
|
3427
|
+
}
|
|
3428
|
+
async function readSecretFilePermissions(filePath) {
|
|
3429
|
+
try {
|
|
3430
|
+
if (process.platform === "win32") {
|
|
3431
|
+
await fs3.stat(filePath);
|
|
3432
|
+
return { state: "unknown", detail: "not checked on Windows" };
|
|
3433
|
+
}
|
|
3434
|
+
const mode = (await fs3.stat(filePath)).mode & 511;
|
|
3435
|
+
if (mode === 384) return { state: "ok" };
|
|
3436
|
+
return { state: "loose", mode };
|
|
3437
|
+
} catch (err) {
|
|
3438
|
+
const code = err.code;
|
|
3439
|
+
if (code === "ENOENT") {
|
|
3440
|
+
return { state: "absent" };
|
|
3441
|
+
}
|
|
3442
|
+
return { state: "unknown", detail: code ?? "unknown error" };
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
// ../../core/auth/node/secret-store-selection.ts
|
|
3447
|
+
var SECRET_STORE_ENV = "MCP_INSPECTOR_SECRET_STORE";
|
|
3448
|
+
var SECRET_FILE_ENV = "MCP_INSPECTOR_SECRET_FILE";
|
|
3449
|
+
var STORAGE_DIR_ENV = "MCP_STORAGE_DIR";
|
|
3450
|
+
var KINDS = ["keyring", "file", "memory"];
|
|
3451
|
+
function parseSecretStoreEnv(raw) {
|
|
3452
|
+
const value = raw?.trim().toLowerCase();
|
|
3453
|
+
if (!value) return void 0;
|
|
3454
|
+
if (KINDS.includes(value)) return value;
|
|
3455
|
+
console.warn(
|
|
3456
|
+
`[mcp-inspector] Ignoring ${SECRET_STORE_ENV}="${raw}": expected one of ${KINDS.join(", ")}.`
|
|
3457
|
+
);
|
|
3458
|
+
return void 0;
|
|
3459
|
+
}
|
|
3460
|
+
function defaultSecretFilePath() {
|
|
3461
|
+
const override = process.env[SECRET_FILE_ENV]?.trim();
|
|
3462
|
+
if (override) return path4.resolve(override);
|
|
3463
|
+
const storageDir = process.env[STORAGE_DIR_ENV]?.trim();
|
|
3464
|
+
if (storageDir) return path4.resolve(storageDir, "secrets.json");
|
|
3465
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
|
|
3466
|
+
return path4.resolve(homeDir, ".mcp-inspector", "secrets.json");
|
|
3467
|
+
}
|
|
3468
|
+
function isContainer() {
|
|
3469
|
+
if (process.env.KUBERNETES_SERVICE_HOST) return true;
|
|
3470
|
+
try {
|
|
3471
|
+
if (fsSync.existsSync("/.dockerenv") || fsSync.existsSync("/run/.containerenv")) {
|
|
3472
|
+
return true;
|
|
3473
|
+
}
|
|
3474
|
+
} catch {
|
|
3475
|
+
}
|
|
3476
|
+
try {
|
|
3477
|
+
const cgroup = fsSync.readFileSync("/proc/1/cgroup", "utf-8");
|
|
3478
|
+
return /\b(docker|containerd|kubepods|podman|libpod|lxc)\b/.test(cgroup);
|
|
3479
|
+
} catch {
|
|
3480
|
+
return false;
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
function nearestExisting(dir) {
|
|
3484
|
+
let current = path4.resolve(dir);
|
|
3485
|
+
while (!fsSync.existsSync(current)) {
|
|
3486
|
+
const parent = path4.dirname(current);
|
|
3487
|
+
if (parent === current) return null;
|
|
3488
|
+
current = parent;
|
|
3489
|
+
}
|
|
3490
|
+
return current;
|
|
3491
|
+
}
|
|
3492
|
+
function unescapeMountPoint(point) {
|
|
3493
|
+
return point.replace(
|
|
3494
|
+
/\\(\d{3})/g,
|
|
3495
|
+
(_match, octal) => String.fromCharCode(parseInt(octal, 8))
|
|
3496
|
+
);
|
|
3497
|
+
}
|
|
3498
|
+
var EPHEMERAL_FSTYPES = /* @__PURE__ */ new Set(["tmpfs", "ramfs"]);
|
|
3499
|
+
function readMountPoints() {
|
|
3500
|
+
try {
|
|
3501
|
+
return fsSync.readFileSync("/proc/self/mountinfo", "utf-8").split("\n").flatMap((line) => {
|
|
3502
|
+
const point = line.split(" ")[4];
|
|
3503
|
+
if (!point) return [];
|
|
3504
|
+
const after = line.split(" - ")[1];
|
|
3505
|
+
const fstype = after?.trim().split(/\s+/)[0];
|
|
3506
|
+
if (fstype && EPHEMERAL_FSTYPES.has(fstype)) return [];
|
|
3507
|
+
return [unescapeMountPoint(point)];
|
|
3508
|
+
}).filter((point) => point !== "/");
|
|
3509
|
+
} catch {
|
|
3510
|
+
return null;
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
function isOnMountPoint(dir) {
|
|
3514
|
+
try {
|
|
3515
|
+
const nearest = nearestExisting(dir);
|
|
3516
|
+
if (nearest === null) return false;
|
|
3517
|
+
let current = nearest;
|
|
3518
|
+
try {
|
|
3519
|
+
current = fsSync.realpathSync(nearest);
|
|
3520
|
+
} catch {
|
|
3521
|
+
}
|
|
3522
|
+
const mounts = readMountPoints();
|
|
3523
|
+
if (mounts !== null) {
|
|
3524
|
+
return mounts.some(
|
|
3525
|
+
(point) => current === point || current.startsWith(`${point}/`)
|
|
3526
|
+
);
|
|
3527
|
+
}
|
|
3528
|
+
const parent = path4.dirname(current);
|
|
3529
|
+
if (parent === current) return false;
|
|
3530
|
+
return fsSync.statSync(current).dev !== fsSync.statSync("/").dev;
|
|
3531
|
+
} catch {
|
|
3532
|
+
return false;
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
async function buildStore(kind, reason, detail) {
|
|
3536
|
+
if (kind === "keyring") {
|
|
3537
|
+
return {
|
|
3538
|
+
store: new KeyringSecretStore(),
|
|
3539
|
+
info: { kind, reason, durable: true }
|
|
3540
|
+
};
|
|
3541
|
+
}
|
|
3542
|
+
if (kind === "memory") {
|
|
3543
|
+
return {
|
|
3544
|
+
store: new SessionSecretStore(),
|
|
3545
|
+
info: { kind, reason, durable: false, detail }
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3548
|
+
const filePath = defaultSecretFilePath();
|
|
3549
|
+
const store = new FileSecretStore({ filePath });
|
|
3550
|
+
await tightenSecretFilePermissions(filePath);
|
|
3551
|
+
return {
|
|
3552
|
+
store,
|
|
3553
|
+
info: await describeFileStore(store, filePath, kind, reason, detail)
|
|
3554
|
+
};
|
|
3555
|
+
}
|
|
3556
|
+
async function describeFileStore(store, filePath, kind, reason, detail) {
|
|
3557
|
+
const onDisk = await store.readOnDiskEncryption();
|
|
3558
|
+
const unreadable = onDisk.state === "unreadable" ? onDisk.detail : void 0;
|
|
3559
|
+
const plaintext = onDisk.state === "absent" ? !store.encrypted : onDisk.state === "plaintext";
|
|
3560
|
+
const perms = await readSecretFilePermissions(filePath);
|
|
3561
|
+
return {
|
|
3562
|
+
kind,
|
|
3563
|
+
reason,
|
|
3564
|
+
path: filePath,
|
|
3565
|
+
// Omitted entirely when the envelope could not be read, so no consumer
|
|
3566
|
+
// can read a default out of it.
|
|
3567
|
+
...unreadable === void 0 ? { plaintext } : {},
|
|
3568
|
+
...unreadable !== void 0 ? { encryptionUnknown: unreadable } : {},
|
|
3569
|
+
...perms.state === "loose" ? { looseMode: perms.mode } : {},
|
|
3570
|
+
...perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {},
|
|
3571
|
+
// Only meaningful while the two disagree; omitted otherwise so the
|
|
3572
|
+
// payload does not carry a flag that says nothing.
|
|
3573
|
+
...unreadable === void 0 && plaintext && store.encrypted ? { pendingEncryption: true } : {},
|
|
3574
|
+
durable: true,
|
|
3575
|
+
detail
|
|
3576
|
+
};
|
|
3577
|
+
}
|
|
3578
|
+
function chooseFallbackKind(opts) {
|
|
3579
|
+
return opts.container && !opts.mounted ? "memory" : "file";
|
|
3580
|
+
}
|
|
3581
|
+
function warnAboutSecretStorage(info) {
|
|
3582
|
+
if (info.reason === "fallback") {
|
|
3583
|
+
console.warn(
|
|
3584
|
+
`
|
|
3585
|
+
[mcp-inspector] The OS keychain is not available, so secrets will be kept in: ${secretStorageSummary(info)}.` + (info.detail ? `
|
|
3586
|
+
[mcp-inspector] Keychain error: ${info.detail}` : "") + `
|
|
3587
|
+
[mcp-inspector] Set ${SECRET_STORE_ENV}=keyring|file|memory to choose explicitly.`
|
|
3588
|
+
);
|
|
3589
|
+
}
|
|
3590
|
+
const caveat = secretStorageCaveat(info);
|
|
3591
|
+
if (caveat) console.warn(`[mcp-inspector] ${caveat}`);
|
|
3592
|
+
}
|
|
3593
|
+
var resolved;
|
|
3594
|
+
function resolveSecretStore() {
|
|
3595
|
+
resolved ??= (async () => {
|
|
3596
|
+
const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]);
|
|
3597
|
+
if (configured) {
|
|
3598
|
+
const result2 = await buildStore(configured, "configured");
|
|
3599
|
+
if (configured === "keyring") {
|
|
3600
|
+
await absorbFileSecretsIntoKeyring(result2.store);
|
|
3601
|
+
}
|
|
3602
|
+
warnAboutSecretStorage(result2.info);
|
|
3603
|
+
return result2;
|
|
3604
|
+
}
|
|
3605
|
+
const probe = await probeKeyringAvailable();
|
|
3606
|
+
if (probe.available) {
|
|
3607
|
+
const result2 = await buildStore("keyring", "default");
|
|
3608
|
+
await absorbFileSecretsIntoKeyring(result2.store);
|
|
3609
|
+
return result2;
|
|
3610
|
+
}
|
|
3611
|
+
const kind = chooseFallbackKind({
|
|
3612
|
+
container: isContainer(),
|
|
3613
|
+
mounted: isOnMountPoint(path4.dirname(defaultSecretFilePath()))
|
|
3614
|
+
});
|
|
3615
|
+
const result = await buildStore(kind, "fallback", probe.detail);
|
|
3616
|
+
warnAboutSecretStorage(result.info);
|
|
3617
|
+
return result;
|
|
3618
|
+
})();
|
|
3619
|
+
return resolved;
|
|
3620
|
+
}
|
|
3621
|
+
async function absorbFileSecretsIntoKeyring(keyring) {
|
|
3622
|
+
const filePath = defaultSecretFilePath();
|
|
3623
|
+
if (!await anythingToMigrate(filePath)) return;
|
|
3624
|
+
const claimed = await withSecretFileLock(filePath, async () => {
|
|
3625
|
+
await recoverOrphanedSnapshots(filePath);
|
|
3626
|
+
if (!fsSync.existsSync(filePath)) return null;
|
|
3627
|
+
const staged = `${filePath}.migrating-${process.pid}-${randomUUID()}`;
|
|
3628
|
+
try {
|
|
3629
|
+
await fs4.rename(filePath, staged);
|
|
3630
|
+
} catch (err) {
|
|
3631
|
+
const code = err.code;
|
|
3632
|
+
if (code !== "ENOENT") {
|
|
3633
|
+
console.warn(
|
|
3634
|
+
`
|
|
3635
|
+
[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.`
|
|
3636
|
+
);
|
|
3637
|
+
}
|
|
3638
|
+
return null;
|
|
3639
|
+
}
|
|
3640
|
+
const releaseSnapshot = await openSecretFileLock(staged).catch(() => null);
|
|
3641
|
+
return { staged, releaseSnapshot };
|
|
3642
|
+
}).catch((err) => {
|
|
3643
|
+
console.warn(
|
|
3644
|
+
`
|
|
3645
|
+
[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.`
|
|
3646
|
+
);
|
|
3647
|
+
return null;
|
|
3648
|
+
});
|
|
3649
|
+
if (claimed === null) return;
|
|
3650
|
+
try {
|
|
3651
|
+
await handOffStagedSecrets(claimed.staged, filePath, keyring);
|
|
3652
|
+
} finally {
|
|
3653
|
+
await claimed.releaseSnapshot?.();
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
async function handOffStagedSecrets(staged, filePath, keyring) {
|
|
3657
|
+
const file = new FileSecretStore({ filePath: staged });
|
|
3658
|
+
const absorbed = await handOff(file, staged, keyring);
|
|
3659
|
+
if (absorbed) {
|
|
3660
|
+
try {
|
|
3661
|
+
await fs4.rm(staged, { force: true });
|
|
3662
|
+
console.warn(
|
|
3663
|
+
`
|
|
3664
|
+
[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.`
|
|
3665
|
+
);
|
|
3666
|
+
} catch (err) {
|
|
3667
|
+
console.warn(
|
|
3668
|
+
`
|
|
3669
|
+
[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.`
|
|
3670
|
+
);
|
|
3671
|
+
}
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
try {
|
|
3675
|
+
await fs4.link(staged, filePath);
|
|
3676
|
+
await fs4.rm(staged, { force: true });
|
|
3677
|
+
} catch {
|
|
3678
|
+
console.warn(
|
|
3679
|
+
`
|
|
3680
|
+
[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.`
|
|
3681
|
+
);
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
var isSnapshotName = (name, base) => name.startsWith(`${base}.migrating-`) && !name.endsWith(".lock");
|
|
3685
|
+
async function anythingToMigrate(filePath) {
|
|
3686
|
+
const base = path4.basename(filePath);
|
|
3687
|
+
try {
|
|
3688
|
+
return (await fs4.readdir(path4.dirname(filePath))).some(
|
|
3689
|
+
(name) => name === base || isSnapshotName(name, base)
|
|
3690
|
+
);
|
|
3691
|
+
} catch (err) {
|
|
3692
|
+
const code = err.code;
|
|
3693
|
+
return code !== "ENOENT" && code !== "ENOTDIR";
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
async function recoverOrphanedSnapshots(filePath) {
|
|
3697
|
+
const dir = path4.dirname(filePath);
|
|
3698
|
+
const base = path4.basename(filePath);
|
|
3699
|
+
let names;
|
|
3700
|
+
try {
|
|
3701
|
+
names = (await fs4.readdir(dir)).filter((n) => isSnapshotName(n, base));
|
|
3702
|
+
} catch {
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
for (const name of names) {
|
|
3706
|
+
const orphan = path4.join(dir, name);
|
|
3707
|
+
if (await isFileLockHeld(orphan)) continue;
|
|
3708
|
+
try {
|
|
3709
|
+
await fs4.link(orphan, filePath);
|
|
3710
|
+
await fs4.rm(orphan, { force: true });
|
|
3711
|
+
console.warn(
|
|
3712
|
+
`
|
|
3713
|
+
[mcp-inspector] Recovered secrets left behind by an interrupted migration (${orphan} \u2192 ${filePath}).`
|
|
3714
|
+
);
|
|
3715
|
+
} catch {
|
|
3716
|
+
console.warn(
|
|
3717
|
+
`
|
|
3718
|
+
[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.`
|
|
3719
|
+
);
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
async function handOff(file, filePath, keyring) {
|
|
3724
|
+
let entries;
|
|
3725
|
+
try {
|
|
3726
|
+
entries = await file.readAll();
|
|
3727
|
+
} catch (err) {
|
|
3728
|
+
console.warn(
|
|
3729
|
+
`
|
|
3730
|
+
[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)}`
|
|
3731
|
+
);
|
|
3732
|
+
return false;
|
|
3733
|
+
}
|
|
3734
|
+
if (!entries || Object.keys(entries).length === 0) return false;
|
|
3735
|
+
let complete = true;
|
|
3736
|
+
try {
|
|
3737
|
+
for (const [account, value] of Object.entries(entries)) {
|
|
3738
|
+
const parsed = parseAccount(account);
|
|
3739
|
+
if (!parsed) {
|
|
3740
|
+
complete = false;
|
|
3741
|
+
continue;
|
|
3742
|
+
}
|
|
3743
|
+
const existing = await secretStoreGetStrict(
|
|
3744
|
+
keyring,
|
|
3745
|
+
parsed.serverId,
|
|
3746
|
+
parsed.field
|
|
3747
|
+
);
|
|
3748
|
+
if (existing === null) {
|
|
3749
|
+
await keyring.set(parsed.serverId, parsed.field, value);
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
} catch (err) {
|
|
3753
|
+
console.warn(
|
|
3754
|
+
`
|
|
3755
|
+
[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)}`
|
|
3756
|
+
);
|
|
3757
|
+
return false;
|
|
3758
|
+
}
|
|
3759
|
+
if (!complete) {
|
|
3760
|
+
console.warn(
|
|
3761
|
+
`
|
|
3762
|
+
[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.`
|
|
3763
|
+
);
|
|
3764
|
+
return false;
|
|
3765
|
+
}
|
|
3766
|
+
return true;
|
|
3767
|
+
}
|
|
3768
|
+
async function getSecretStorageInfo() {
|
|
3769
|
+
const { store, info } = await resolveSecretStore();
|
|
3770
|
+
if (info.kind !== "file" || !(store instanceof FileSecretStore)) return info;
|
|
3771
|
+
return describeFileStore(
|
|
3772
|
+
store,
|
|
3773
|
+
store.filePath,
|
|
3774
|
+
"file",
|
|
3775
|
+
info.reason,
|
|
3776
|
+
info.detail
|
|
3777
|
+
);
|
|
3778
|
+
}
|
|
3779
|
+
var DeferredSecretStore = class {
|
|
3780
|
+
async target() {
|
|
3781
|
+
return (await resolveSecretStore()).store;
|
|
3782
|
+
}
|
|
3783
|
+
async isDurable() {
|
|
3784
|
+
return secretStoreIsDurable(await this.target());
|
|
3785
|
+
}
|
|
3786
|
+
/**
|
|
3787
|
+
* Forwarded, and its absence was a real defect rather than an omission:
|
|
3788
|
+
* this is the store production actually uses, so a `secretStoreGetStrict`
|
|
3789
|
+
* call against it fell through to the tolerant `get` and the strictness
|
|
3790
|
+
* existed only in tests that injected a concrete store.
|
|
3791
|
+
*/
|
|
3792
|
+
async getStrict(serverId, field) {
|
|
3793
|
+
return secretStoreGetStrict(await this.target(), serverId, field);
|
|
3794
|
+
}
|
|
3795
|
+
async getMany(requests) {
|
|
3796
|
+
return secretStoreGetMany(await this.target(), requests);
|
|
3797
|
+
}
|
|
3798
|
+
async get(serverId, field) {
|
|
3799
|
+
return (await this.target()).get(serverId, field);
|
|
3800
|
+
}
|
|
3801
|
+
async set(serverId, field, value) {
|
|
3802
|
+
await (await this.target()).set(serverId, field, value);
|
|
3803
|
+
}
|
|
3804
|
+
async setMany(serverId, values) {
|
|
3805
|
+
await secretStoreSetMany(await this.target(), serverId, values);
|
|
3806
|
+
}
|
|
3807
|
+
async delete(serverId, field) {
|
|
3808
|
+
await (await this.target()).delete(serverId, field);
|
|
3809
|
+
}
|
|
3810
|
+
async deleteAllForServer(serverId) {
|
|
3811
|
+
await (await this.target()).deleteAllForServer(serverId);
|
|
3812
|
+
}
|
|
3813
|
+
};
|
|
3814
|
+
function defaultSecretStore() {
|
|
3815
|
+
return new DeferredSecretStore();
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
// ../../core/client/config-parse.ts
|
|
3819
|
+
import { z } from "zod";
|
|
3820
|
+
function isAbsoluteHttpUrl(value) {
|
|
3821
|
+
let url;
|
|
3822
|
+
try {
|
|
3823
|
+
url = new URL(value.trim());
|
|
3824
|
+
} catch {
|
|
3825
|
+
return false;
|
|
3826
|
+
}
|
|
3827
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
3828
|
+
return isRealHost(url.hostname);
|
|
3829
|
+
}
|
|
3830
|
+
function isRealHost(hostname) {
|
|
3831
|
+
if (hostname === "localhost") return true;
|
|
3832
|
+
if (hostname.startsWith("[")) return hostname.endsWith("]");
|
|
3833
|
+
const labels = hostname.split(".");
|
|
3834
|
+
return labels.length >= 2 && labels.every((label) => label !== "");
|
|
3835
|
+
}
|
|
3836
|
+
var HttpUrlStringSchema = z.string().min(1).superRefine((val, ctx) => {
|
|
3837
|
+
const trimmed = val.trim();
|
|
3838
|
+
if (!isAbsoluteHttpUrl(trimmed)) {
|
|
3839
|
+
ctx.addIssue({
|
|
3840
|
+
code: z.ZodIssueCode.custom,
|
|
3841
|
+
message: `Invalid URL: "${trimmed}" \u2014 must be an http(s) URL (e.g. https://idp.example.com)`
|
|
3842
|
+
});
|
|
3843
|
+
}
|
|
3844
|
+
});
|
|
3845
|
+
var CIMD_METADATA_URL_INVALID_ERROR = "Must be a valid URL, like https://example.com/oauth/client.json";
|
|
3846
|
+
var CIMD_METADATA_URL_HTTPS_ERROR = "CIMD client metadata URL must use HTTPS";
|
|
3847
|
+
var CIMD_METADATA_URL_PATH_ERROR = "Must include a path (not the site root), like https://example.com/oauth/client.json";
|
|
3848
|
+
function getCimdClientMetadataUrlError(value) {
|
|
3849
|
+
const trimmed = value.trim();
|
|
3850
|
+
if (trimmed === "") return void 0;
|
|
3851
|
+
if (!isAbsoluteHttpUrl(trimmed)) {
|
|
3852
|
+
return CIMD_METADATA_URL_INVALID_ERROR;
|
|
3853
|
+
}
|
|
3854
|
+
try {
|
|
3855
|
+
const url = new URL(trimmed);
|
|
3856
|
+
if (url.protocol !== "https:") {
|
|
3857
|
+
return CIMD_METADATA_URL_HTTPS_ERROR;
|
|
3858
|
+
}
|
|
3859
|
+
if (url.pathname === "/" || url.pathname === "") {
|
|
3860
|
+
return CIMD_METADATA_URL_PATH_ERROR;
|
|
3861
|
+
}
|
|
3862
|
+
} catch {
|
|
3863
|
+
return CIMD_METADATA_URL_INVALID_ERROR;
|
|
3864
|
+
}
|
|
3865
|
+
return void 0;
|
|
3866
|
+
}
|
|
3867
|
+
function refineCimdMetadataUrl(val, ctx, required) {
|
|
3868
|
+
const trimmed = val.trim();
|
|
3869
|
+
if (trimmed === "") {
|
|
3870
|
+
if (required) {
|
|
3871
|
+
ctx.addIssue({
|
|
2221
3872
|
code: z.ZodIssueCode.custom,
|
|
2222
3873
|
message: "CIMD client metadata URL is required when CIMD is enabled"
|
|
2223
3874
|
});
|
|
@@ -2325,7 +3976,8 @@ async function migrateClientPlaintextSecret(filePath, config, secretStore) {
|
|
|
2325
3976
|
const value = secrets[SECRET_FIELD_IDP_CLIENT_SECRET];
|
|
2326
3977
|
if (!value) return config;
|
|
2327
3978
|
try {
|
|
2328
|
-
const existing = await
|
|
3979
|
+
const existing = await secretStoreGetStrict(
|
|
3980
|
+
secretStore,
|
|
2329
3981
|
CLIENT_KEYCHAIN_ID,
|
|
2330
3982
|
SECRET_FIELD_IDP_CLIENT_SECRET
|
|
2331
3983
|
);
|
|
@@ -2336,10 +3988,11 @@ async function migrateClientPlaintextSecret(filePath, config, secretStore) {
|
|
|
2336
3988
|
value
|
|
2337
3989
|
);
|
|
2338
3990
|
}
|
|
3991
|
+
if (!await secretStoreIsDurable(secretStore)) return config;
|
|
2339
3992
|
await writeStoreFile(filePath, serializeStore(stripped));
|
|
2340
3993
|
return stripped;
|
|
2341
3994
|
} catch (err) {
|
|
2342
|
-
if (err instanceof
|
|
3995
|
+
if (err instanceof SecretStoreUnavailableError) {
|
|
2343
3996
|
return config;
|
|
2344
3997
|
}
|
|
2345
3998
|
throw err;
|
|
@@ -2373,7 +4026,30 @@ async function writeClientConfigStore(filePath, body, secretStore) {
|
|
|
2373
4026
|
SECRET_FIELD_IDP_CLIENT_SECRET
|
|
2374
4027
|
);
|
|
2375
4028
|
}
|
|
2376
|
-
await
|
|
4029
|
+
const durable = await secretStoreIsDurable(secretStore);
|
|
4030
|
+
await writeStoreFile(
|
|
4031
|
+
filePath,
|
|
4032
|
+
serializeStore(
|
|
4033
|
+
durable ? stripped : await preserveLegacyPlaintext(
|
|
4034
|
+
filePath,
|
|
4035
|
+
validated,
|
|
4036
|
+
stripped,
|
|
4037
|
+
idpSecret
|
|
4038
|
+
)
|
|
4039
|
+
)
|
|
4040
|
+
);
|
|
4041
|
+
}
|
|
4042
|
+
async function preserveLegacyPlaintext(filePath, validated, stripped, idpSecret) {
|
|
4043
|
+
if (!idpSecret) return stripped;
|
|
4044
|
+
try {
|
|
4045
|
+
const raw = await readStoreFile(filePath);
|
|
4046
|
+
if (raw === null) return stripped;
|
|
4047
|
+
const prior = parseClientConfig(parseStore(raw));
|
|
4048
|
+
const priorSecret = extractSecretsFromClientConfig(prior).secrets[SECRET_FIELD_IDP_CLIENT_SECRET];
|
|
4049
|
+
return priorSecret === idpSecret ? validated : stripped;
|
|
4050
|
+
} catch {
|
|
4051
|
+
return stripped;
|
|
4052
|
+
}
|
|
2377
4053
|
}
|
|
2378
4054
|
async function deleteClientConfigStore(filePath, secretStore) {
|
|
2379
4055
|
await deleteStoreFile(filePath);
|
|
@@ -2389,18 +4065,24 @@ function closeAbortedStream(stream) {
|
|
|
2389
4065
|
}
|
|
2390
4066
|
async function primeAndHoldSseStream(stream, cleanup) {
|
|
2391
4067
|
let done = false;
|
|
2392
|
-
const aborted = new Promise((
|
|
4068
|
+
const aborted = new Promise((resolve9) => {
|
|
2393
4069
|
stream.onAbort(() => {
|
|
2394
4070
|
if (done) return;
|
|
2395
4071
|
done = true;
|
|
2396
4072
|
cleanup();
|
|
2397
4073
|
closeAbortedStream(stream);
|
|
2398
|
-
|
|
4074
|
+
resolve9();
|
|
2399
4075
|
});
|
|
2400
4076
|
});
|
|
2401
4077
|
await stream.write(SSE_PRIMING_COMMENT);
|
|
2402
4078
|
await aborted;
|
|
2403
4079
|
}
|
|
4080
|
+
var MAX_APP_DOCUMENT_CHARS = 8 * 1024 * 1024;
|
|
4081
|
+
var MAX_APP_DOCUMENT_BODY_BYTES = 24 * 1024 * 1024;
|
|
4082
|
+
var MAX_APP_CSP_CHARS = 8 * 1024;
|
|
4083
|
+
function isHeaderSafeValue(value) {
|
|
4084
|
+
return !/[^\t\x20-\x7e]/.test(value);
|
|
4085
|
+
}
|
|
2404
4086
|
function createOriginMiddleware(allowedOrigins) {
|
|
2405
4087
|
return async (c, next) => {
|
|
2406
4088
|
if (!allowedOrigins || allowedOrigins.length === 0) {
|
|
@@ -2535,13 +4217,13 @@ function mcpParamHeadersOnly(headers) {
|
|
|
2535
4217
|
}
|
|
2536
4218
|
function createRemoteApp(options) {
|
|
2537
4219
|
const dangerouslyOmitAuth = !!options.dangerouslyOmitAuth;
|
|
2538
|
-
const authToken = dangerouslyOmitAuth ? "" : options.authToken || process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] ||
|
|
4220
|
+
const authToken = dangerouslyOmitAuth ? "" : options.authToken || process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] || randomBytes3(32).toString("hex");
|
|
2539
4221
|
const app = new Hono();
|
|
2540
4222
|
const sessions = /* @__PURE__ */ new Map();
|
|
2541
4223
|
const { logger: fileLogger, allowedOrigins } = options;
|
|
2542
4224
|
const storageDir = options.storageDir ?? getDefaultStorageDir();
|
|
2543
4225
|
const mcpConfigPath = options.mcpConfigPath ?? getDefaultMcpConfigPath();
|
|
2544
|
-
const secretStore = options.secretStore ??
|
|
4226
|
+
const secretStore = options.secretStore ?? defaultSecretStore();
|
|
2545
4227
|
const writable = options.writable ?? true;
|
|
2546
4228
|
const inMemoryServers = options.initialServers;
|
|
2547
4229
|
const watchable = !inMemoryServers;
|
|
@@ -2625,14 +4307,66 @@ function createRemoteApp(options) {
|
|
|
2625
4307
|
if (!dangerouslyOmitAuth) {
|
|
2626
4308
|
app.use("*", createAuthMiddleware(authToken));
|
|
2627
4309
|
}
|
|
2628
|
-
app.get("/api/config", (c) => {
|
|
4310
|
+
app.get("/api/config", async (c) => {
|
|
4311
|
+
const secretStorage = options.secretStorageResolver ? await options.secretStorageResolver() : options.initialConfig?.secretStorage;
|
|
2629
4312
|
const payload = {
|
|
2630
4313
|
...options.initialConfig,
|
|
2631
4314
|
writable,
|
|
4315
|
+
// Assigned unconditionally, not spread-when-truthy. The resolver's
|
|
4316
|
+
// contract is that `undefined` means "not known right now", and a
|
|
4317
|
+
// conditional spread leaves the *startup* descriptor from
|
|
4318
|
+
// `initialConfig` standing in its place — so a store that became
|
|
4319
|
+
// undescribable would keep serving a stale, confident answer. Setting
|
|
4320
|
+
// it to `undefined` is what clears it; `c.json` omits the key.
|
|
4321
|
+
secretStorage,
|
|
2632
4322
|
...options.sandboxUrl ? { sandboxUrl: options.sandboxUrl } : {}
|
|
2633
4323
|
};
|
|
2634
4324
|
return c.json(payload);
|
|
2635
4325
|
});
|
|
4326
|
+
app.post(
|
|
4327
|
+
"/api/app-document",
|
|
4328
|
+
// Before the parse, not after: see MAX_APP_DOCUMENT_BODY_BYTES.
|
|
4329
|
+
bodyLimit({
|
|
4330
|
+
maxSize: MAX_APP_DOCUMENT_BODY_BYTES,
|
|
4331
|
+
onError: (c) => c.json({ error: "Document too large" }, 413)
|
|
4332
|
+
}),
|
|
4333
|
+
async (c) => {
|
|
4334
|
+
const publish = options.publishAppDocument;
|
|
4335
|
+
if (!publish) {
|
|
4336
|
+
return c.json({ error: "App-origin hosting is not available" }, 503);
|
|
4337
|
+
}
|
|
4338
|
+
let parsed;
|
|
4339
|
+
try {
|
|
4340
|
+
parsed = await c.req.json();
|
|
4341
|
+
} catch {
|
|
4342
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
4343
|
+
}
|
|
4344
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4345
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
4346
|
+
}
|
|
4347
|
+
const { html, csp } = parsed;
|
|
4348
|
+
if (typeof html !== "string" || html === "") {
|
|
4349
|
+
return c.json({ error: "Missing html" }, 400);
|
|
4350
|
+
}
|
|
4351
|
+
if (csp !== void 0 && typeof csp !== "string") {
|
|
4352
|
+
return c.json({ error: "csp must be a string" }, 400);
|
|
4353
|
+
}
|
|
4354
|
+
if (csp !== void 0 && !isHeaderSafeValue(csp)) {
|
|
4355
|
+
return c.json({ error: "csp is not a valid header value" }, 400);
|
|
4356
|
+
}
|
|
4357
|
+
if (html.length > MAX_APP_DOCUMENT_CHARS) {
|
|
4358
|
+
return c.json({ error: "Document too large" }, 413);
|
|
4359
|
+
}
|
|
4360
|
+
if (csp !== void 0 && csp.length > MAX_APP_CSP_CHARS) {
|
|
4361
|
+
return c.json({ error: "csp too large" }, 413);
|
|
4362
|
+
}
|
|
4363
|
+
const published = publish({ html, csp });
|
|
4364
|
+
if (!published) {
|
|
4365
|
+
return c.json({ error: "App-origin hosting is not available" }, 503);
|
|
4366
|
+
}
|
|
4367
|
+
return c.json(published);
|
|
4368
|
+
}
|
|
4369
|
+
);
|
|
2636
4370
|
app.post("/api/mcp/connect", async (c) => {
|
|
2637
4371
|
let body;
|
|
2638
4372
|
try {
|
|
@@ -2761,11 +4495,27 @@ function createRemoteApp(options) {
|
|
|
2761
4495
|
void responseWait.catch(() => {
|
|
2762
4496
|
});
|
|
2763
4497
|
}
|
|
4498
|
+
const upstreamAbort = new AbortController();
|
|
4499
|
+
const clientSignal = c.req.raw.signal;
|
|
4500
|
+
const onClientDisconnect = () => {
|
|
4501
|
+
upstreamAbort.abort(clientSignal.reason);
|
|
4502
|
+
if (requestId !== void 0) {
|
|
4503
|
+
session.cancelRequestWait(requestId);
|
|
4504
|
+
}
|
|
4505
|
+
};
|
|
4506
|
+
if (clientSignal.aborted) {
|
|
4507
|
+
onClientDisconnect();
|
|
4508
|
+
} else {
|
|
4509
|
+
clientSignal.addEventListener("abort", onClientDisconnect, {
|
|
4510
|
+
once: true
|
|
4511
|
+
});
|
|
4512
|
+
}
|
|
2764
4513
|
try {
|
|
2765
4514
|
const paramHeaders = mcpParamHeadersOnly(headers);
|
|
2766
4515
|
await session.transport.send(message, {
|
|
2767
4516
|
relatedRequestId,
|
|
2768
|
-
...paramHeaders && { headers: paramHeaders }
|
|
4517
|
+
...paramHeaders && { headers: paramHeaders },
|
|
4518
|
+
requestSignal: upstreamAbort.signal
|
|
2769
4519
|
});
|
|
2770
4520
|
if (responseWait) {
|
|
2771
4521
|
await responseWait;
|
|
@@ -2786,6 +4536,7 @@ function createRemoteApp(options) {
|
|
|
2786
4536
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2787
4537
|
return c.json({ ok: false, kind: "transport_error", error: msg });
|
|
2788
4538
|
} finally {
|
|
4539
|
+
clientSignal.removeEventListener("abort", onClientDisconnect);
|
|
2789
4540
|
session.endSend();
|
|
2790
4541
|
}
|
|
2791
4542
|
});
|
|
@@ -2807,7 +4558,7 @@ function createRemoteApp(options) {
|
|
|
2807
4558
|
});
|
|
2808
4559
|
});
|
|
2809
4560
|
if (session.isTransportDead()) {
|
|
2810
|
-
await new Promise((
|
|
4561
|
+
await new Promise((resolve9) => setTimeout(resolve9, 0));
|
|
2811
4562
|
session.clearEventConsumer();
|
|
2812
4563
|
sessions.delete(sessionId);
|
|
2813
4564
|
return;
|
|
@@ -2878,7 +4629,7 @@ function createRemoteApp(options) {
|
|
|
2878
4629
|
return c.json({ error: "Missing url" }, 400);
|
|
2879
4630
|
}
|
|
2880
4631
|
try {
|
|
2881
|
-
const res = await fetch(url, {
|
|
4632
|
+
const res = await (createProxyFetch() ?? fetch)(url, {
|
|
2882
4633
|
method,
|
|
2883
4634
|
headers: new Headers(headers),
|
|
2884
4635
|
body: reqBody
|
|
@@ -2989,6 +4740,7 @@ function createRemoteApp(options) {
|
|
|
2989
4740
|
console.warn("[mcp.json]", msg, bindings);
|
|
2990
4741
|
}
|
|
2991
4742
|
};
|
|
4743
|
+
const isJsonObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2992
4744
|
const isStringRecord = (v) => {
|
|
2993
4745
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
2994
4746
|
for (const val of Object.values(v)) {
|
|
@@ -3023,6 +4775,12 @@ function createRemoteApp(options) {
|
|
|
3023
4775
|
if (o.enterpriseManaged !== void 0 && typeof o.enterpriseManaged !== "boolean") {
|
|
3024
4776
|
return false;
|
|
3025
4777
|
}
|
|
4778
|
+
if (o.requestRefreshToken !== void 0 && typeof o.requestRefreshToken !== "boolean") {
|
|
4779
|
+
return false;
|
|
4780
|
+
}
|
|
4781
|
+
if (o.revokeOnClear !== void 0 && typeof o.revokeOnClear !== "boolean") {
|
|
4782
|
+
return false;
|
|
4783
|
+
}
|
|
3026
4784
|
return true;
|
|
3027
4785
|
};
|
|
3028
4786
|
const isRootArray = (v) => {
|
|
@@ -3056,10 +4814,10 @@ function createRemoteApp(options) {
|
|
|
3056
4814
|
);
|
|
3057
4815
|
delete valObj.headers;
|
|
3058
4816
|
}
|
|
3059
|
-
if ("metadata" in valObj && !isKvArray(valObj.metadata)) {
|
|
4817
|
+
if ("metadata" in valObj && !isJsonObject(valObj.metadata) && !isKvArray(valObj.metadata)) {
|
|
3060
4818
|
logWarn(
|
|
3061
4819
|
{ route: "/api/servers", id, droppedKey: "metadata" },
|
|
3062
|
-
"Dropping malformed `metadata` field \u2014 expected
|
|
4820
|
+
"Dropping malformed `metadata` field \u2014 expected a JSON object."
|
|
3063
4821
|
);
|
|
3064
4822
|
delete valObj.metadata;
|
|
3065
4823
|
}
|
|
@@ -3094,7 +4852,7 @@ function createRemoteApp(options) {
|
|
|
3094
4852
|
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
|
3095
4853
|
logWarn(
|
|
3096
4854
|
{ route: "/api/servers", id, droppedKey: "oauth" },
|
|
3097
|
-
"Dropping malformed `oauth` field \u2014 expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope? }`."
|
|
4855
|
+
"Dropping malformed `oauth` field \u2014 expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken?, revokeOnClear? }`."
|
|
3098
4856
|
);
|
|
3099
4857
|
delete valObj.oauth;
|
|
3100
4858
|
}
|
|
@@ -3157,16 +4915,15 @@ function createRemoteApp(options) {
|
|
|
3157
4915
|
const applyStdioSettingsToConfig = (entry, settings, provided) => {
|
|
3158
4916
|
if (!(entry.type === "stdio" || entry.type === void 0)) return;
|
|
3159
4917
|
const stdio = entry;
|
|
4918
|
+
const { env, cwd } = stdioConfigFieldsFromSettings(settings);
|
|
3160
4919
|
if (provided.env) {
|
|
3161
|
-
|
|
3162
|
-
if (Object.keys(env).length > 0) {
|
|
4920
|
+
if (env) {
|
|
3163
4921
|
stdio.env = env;
|
|
3164
4922
|
} else {
|
|
3165
4923
|
delete stdio.env;
|
|
3166
4924
|
}
|
|
3167
4925
|
}
|
|
3168
4926
|
if (provided.cwd) {
|
|
3169
|
-
const cwd = settings.cwd?.trim();
|
|
3170
4927
|
if (cwd) {
|
|
3171
4928
|
stdio.cwd = cwd;
|
|
3172
4929
|
} else {
|
|
@@ -3197,10 +4954,10 @@ function createRemoteApp(options) {
|
|
|
3197
4954
|
error: "settings.headers must be an array of { key, value }"
|
|
3198
4955
|
};
|
|
3199
4956
|
}
|
|
3200
|
-
if (!
|
|
4957
|
+
if (!isJsonObject(obj.metadata)) {
|
|
3201
4958
|
return {
|
|
3202
4959
|
ok: false,
|
|
3203
|
-
error: "settings.metadata must be
|
|
4960
|
+
error: "settings.metadata must be a JSON object"
|
|
3204
4961
|
};
|
|
3205
4962
|
}
|
|
3206
4963
|
if (obj.env !== void 0 && !isKvArray2(obj.env)) {
|
|
@@ -3273,6 +5030,18 @@ function createRemoteApp(options) {
|
|
|
3273
5030
|
error: "settings.enterpriseManaged must be a boolean"
|
|
3274
5031
|
};
|
|
3275
5032
|
}
|
|
5033
|
+
if (obj.oauthRequestRefreshToken !== void 0 && typeof obj.oauthRequestRefreshToken !== "boolean") {
|
|
5034
|
+
return {
|
|
5035
|
+
ok: false,
|
|
5036
|
+
error: "settings.oauthRequestRefreshToken must be a boolean"
|
|
5037
|
+
};
|
|
5038
|
+
}
|
|
5039
|
+
if (obj.oauthRevokeOnClear !== void 0 && typeof obj.oauthRevokeOnClear !== "boolean") {
|
|
5040
|
+
return {
|
|
5041
|
+
ok: false,
|
|
5042
|
+
error: "settings.oauthRevokeOnClear must be a boolean"
|
|
5043
|
+
};
|
|
5044
|
+
}
|
|
3276
5045
|
if (obj.oauthOnInsufficientScope !== void 0 && obj.oauthOnInsufficientScope !== "reauthorize" && obj.oauthOnInsufficientScope !== "throw") {
|
|
3277
5046
|
return {
|
|
3278
5047
|
ok: false,
|
|
@@ -3352,6 +5121,12 @@ function createRemoteApp(options) {
|
|
|
3352
5121
|
if (obj.enterpriseManaged === true) {
|
|
3353
5122
|
value.enterpriseManaged = true;
|
|
3354
5123
|
}
|
|
5124
|
+
if (obj.oauthRequestRefreshToken === false) {
|
|
5125
|
+
value.oauthRequestRefreshToken = false;
|
|
5126
|
+
}
|
|
5127
|
+
if (obj.oauthRevokeOnClear === false) {
|
|
5128
|
+
value.oauthRevokeOnClear = false;
|
|
5129
|
+
}
|
|
3355
5130
|
if (obj.oauthOnInsufficientScope === "reauthorize" || obj.oauthOnInsufficientScope === "throw") {
|
|
3356
5131
|
value.oauthOnInsufficientScope = obj.oauthOnInsufficientScope;
|
|
3357
5132
|
}
|
|
@@ -3396,23 +5171,13 @@ function createRemoteApp(options) {
|
|
|
3396
5171
|
return { mcpServers: normalizeMcpServers(parsed?.mcpServers) };
|
|
3397
5172
|
};
|
|
3398
5173
|
const writeKeychainEntriesFor = async (id, secrets) => {
|
|
3399
|
-
await
|
|
3400
|
-
Object.entries(secrets).map(
|
|
3401
|
-
([field, value]) => secretStore.set(id, field, value)
|
|
3402
|
-
)
|
|
3403
|
-
);
|
|
5174
|
+
await secretStoreSetMany(secretStore, id, secrets);
|
|
3404
5175
|
};
|
|
3405
5176
|
const readKeychainEntriesFor = async (id, fields) => {
|
|
3406
|
-
const
|
|
3407
|
-
fields
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
);
|
|
3411
|
-
const out = {};
|
|
3412
|
-
for (const [field, v] of values) {
|
|
3413
|
-
if (v !== null) out[field] = v;
|
|
3414
|
-
}
|
|
3415
|
-
return out;
|
|
5177
|
+
const many = await secretStoreGetMany(secretStore, [
|
|
5178
|
+
{ serverId: id, fields }
|
|
5179
|
+
]);
|
|
5180
|
+
return many[id] ?? {};
|
|
3416
5181
|
};
|
|
3417
5182
|
const hasPlaintextSecrets = (config) => {
|
|
3418
5183
|
for (const stored of Object.values(config.mcpServers)) {
|
|
@@ -3421,9 +5186,18 @@ function createRemoteApp(options) {
|
|
|
3421
5186
|
}
|
|
3422
5187
|
return false;
|
|
3423
5188
|
};
|
|
5189
|
+
let warnedSessionPreserved = false;
|
|
3424
5190
|
const migratePlaintextSecrets = async (config) => {
|
|
3425
5191
|
let changed = false;
|
|
3426
5192
|
const next = { mcpServers: {} };
|
|
5193
|
+
const durable = await secretStoreIsDurable(secretStore);
|
|
5194
|
+
const warnSessionPreserved = () => {
|
|
5195
|
+
if (warnedSessionPreserved || durable || !fileLogger) return;
|
|
5196
|
+
warnedSessionPreserved = true;
|
|
5197
|
+
fileLogger.warn(
|
|
5198
|
+
"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."
|
|
5199
|
+
);
|
|
5200
|
+
};
|
|
3427
5201
|
try {
|
|
3428
5202
|
for (const [id, stored] of Object.entries(config.mcpServers)) {
|
|
3429
5203
|
const { stripped, secrets } = extractSecretsFromStored(stored);
|
|
@@ -3432,20 +5206,25 @@ function createRemoteApp(options) {
|
|
|
3432
5206
|
continue;
|
|
3433
5207
|
}
|
|
3434
5208
|
for (const [field, value] of Object.entries(secrets)) {
|
|
3435
|
-
const existing = await secretStore
|
|
5209
|
+
const existing = await secretStoreGetStrict(secretStore, id, field);
|
|
3436
5210
|
if (existing === null) {
|
|
3437
5211
|
await secretStore.set(id, field, value);
|
|
3438
5212
|
}
|
|
3439
5213
|
}
|
|
5214
|
+
if (!durable) {
|
|
5215
|
+
warnSessionPreserved();
|
|
5216
|
+
next.mcpServers[id] = stored;
|
|
5217
|
+
continue;
|
|
5218
|
+
}
|
|
3440
5219
|
next.mcpServers[id] = stripped;
|
|
3441
5220
|
changed = true;
|
|
3442
5221
|
}
|
|
3443
5222
|
} catch (err) {
|
|
3444
|
-
if (err instanceof
|
|
5223
|
+
if (err instanceof SecretStoreUnavailableError) {
|
|
3445
5224
|
if (fileLogger) {
|
|
3446
5225
|
fileLogger.warn(
|
|
3447
5226
|
{ err: err.message },
|
|
3448
|
-
"
|
|
5227
|
+
"Secret store unavailable; skipping plaintext-secret migration on this read. Existing mcp.json plaintext values are preserved."
|
|
3449
5228
|
);
|
|
3450
5229
|
}
|
|
3451
5230
|
return { migrated: config, changed: false };
|
|
@@ -3455,22 +5234,20 @@ function createRemoteApp(options) {
|
|
|
3455
5234
|
return { migrated: next, changed };
|
|
3456
5235
|
};
|
|
3457
5236
|
const rehydrateConfig = async (config) => {
|
|
5237
|
+
const entries = Object.entries(config.mcpServers);
|
|
5238
|
+
const secrets = await secretStoreGetMany(
|
|
5239
|
+
secretStore,
|
|
5240
|
+
entries.map(([serverId, stored]) => ({
|
|
5241
|
+
serverId,
|
|
5242
|
+
fields: expectedSecretFields(stored)
|
|
5243
|
+
}))
|
|
5244
|
+
);
|
|
3458
5245
|
const out = { mcpServers: {} };
|
|
3459
|
-
for (const [id, stored] of
|
|
3460
|
-
|
|
3461
|
-
const secrets = await readKeychainEntriesFor(id, fields);
|
|
3462
|
-
out.mcpServers[id] = mergeSecretsIntoStored(stored, secrets);
|
|
5246
|
+
for (const [id, stored] of entries) {
|
|
5247
|
+
out.mcpServers[id] = mergeSecretsIntoStored(stored, secrets[id] ?? {});
|
|
3463
5248
|
}
|
|
3464
5249
|
return out;
|
|
3465
5250
|
};
|
|
3466
|
-
const computeObsoleteFields = (previousFields, nextSecrets) => {
|
|
3467
|
-
const nextFieldSet = new Set(Object.keys(nextSecrets));
|
|
3468
|
-
const obsolete = [];
|
|
3469
|
-
for (const field of previousFields) {
|
|
3470
|
-
if (!nextFieldSet.has(field)) obsolete.push(field);
|
|
3471
|
-
}
|
|
3472
|
-
return obsolete;
|
|
3473
|
-
};
|
|
3474
5251
|
const mergeRenameKeychainSecrets = (stripped, keychainSecrets, nextSecrets) => {
|
|
3475
5252
|
const allowedFields = new Set(expectedSecretFields(stripped));
|
|
3476
5253
|
const merged = { ...keychainSecrets, ...nextSecrets };
|
|
@@ -3483,8 +5260,18 @@ function createRemoteApp(options) {
|
|
|
3483
5260
|
const deleteKeychainFields = async (id, fields) => {
|
|
3484
5261
|
await Promise.all(fields.map((field) => secretStore.delete(id, field)));
|
|
3485
5262
|
};
|
|
5263
|
+
const entryForDisk = async (built, stripped, previous) => {
|
|
5264
|
+
if (await secretStoreIsDurable(secretStore)) return stripped;
|
|
5265
|
+
const prior = previous ? extractSecretsFromStored(previous).secrets : {};
|
|
5266
|
+
const submitted = extractSecretsFromStored(built).secrets;
|
|
5267
|
+
const carry = {};
|
|
5268
|
+
for (const [field, value] of Object.entries(submitted)) {
|
|
5269
|
+
if (prior[field] === value) carry[field] = value;
|
|
5270
|
+
}
|
|
5271
|
+
return Object.keys(carry).length > 0 ? mergeSecretsIntoStored(stripped, carry) : stripped;
|
|
5272
|
+
};
|
|
3486
5273
|
const keychainErrorResponse = (c, err) => {
|
|
3487
|
-
if (err instanceof
|
|
5274
|
+
if (err instanceof SecretStoreUnavailableError) {
|
|
3488
5275
|
return c.json({ error: err.message }, 503);
|
|
3489
5276
|
}
|
|
3490
5277
|
return void 0;
|
|
@@ -3547,7 +5334,7 @@ function createRemoteApp(options) {
|
|
|
3547
5334
|
type,
|
|
3548
5335
|
process.platform,
|
|
3549
5336
|
homedir(),
|
|
3550
|
-
(
|
|
5337
|
+
(path6) => existsSync3(path6) ? readFileSync5(path6, "utf-8") : null
|
|
3551
5338
|
);
|
|
3552
5339
|
if (!result) {
|
|
3553
5340
|
return c.json(
|
|
@@ -3594,7 +5381,7 @@ function createRemoteApp(options) {
|
|
|
3594
5381
|
const { stripped, secrets } = extractSecretsFromStored(built);
|
|
3595
5382
|
await secretStore.deleteAllForServer(id);
|
|
3596
5383
|
await writeKeychainEntriesFor(id, secrets);
|
|
3597
|
-
current.mcpServers[id] = stripped;
|
|
5384
|
+
current.mcpServers[id] = await entryForDisk(built, stripped);
|
|
3598
5385
|
await writeMcpAndTrackMtime(serializeStore(current));
|
|
3599
5386
|
return c.json({ ok: true });
|
|
3600
5387
|
});
|
|
@@ -3734,10 +5521,11 @@ function createRemoteApp(options) {
|
|
|
3734
5521
|
}
|
|
3735
5522
|
}
|
|
3736
5523
|
const { stripped, secrets } = extractSecretsFromStored(built);
|
|
5524
|
+
const onDisk = await entryForDisk(built, stripped, existing);
|
|
3737
5525
|
const next = { mcpServers: {} };
|
|
3738
5526
|
for (const [key, val] of Object.entries(current.mcpServers)) {
|
|
3739
5527
|
if (key === originalId) {
|
|
3740
|
-
next.mcpServers[newId] =
|
|
5528
|
+
next.mcpServers[newId] = onDisk;
|
|
3741
5529
|
} else {
|
|
3742
5530
|
next.mcpServers[key] = val;
|
|
3743
5531
|
}
|
|
@@ -3758,7 +5546,10 @@ function createRemoteApp(options) {
|
|
|
3758
5546
|
await secretStore.deleteAllForServer(originalId);
|
|
3759
5547
|
} else {
|
|
3760
5548
|
const previousFields = new Set(expectedSecretFields(existing));
|
|
3761
|
-
const
|
|
5549
|
+
const stillExpected = new Set(expectedSecretFields(built));
|
|
5550
|
+
const obsolete = [...previousFields].filter(
|
|
5551
|
+
(field) => field === SECRET_FIELD_OAUTH_CLIENT_SECRET ? settingsIntent.kind !== "preserve" && !(field in secrets) : !stillExpected.has(field)
|
|
5552
|
+
);
|
|
3762
5553
|
await writeKeychainEntriesFor(newId, secrets);
|
|
3763
5554
|
await writeMcpAndTrackMtime(serializeStore(next));
|
|
3764
5555
|
await deleteKeychainFields(newId, obsolete);
|
|
@@ -3874,6 +5665,16 @@ function honoMiddlewarePlugin(config) {
|
|
|
3874
5665
|
allowedOrigins: config.allowedOrigins
|
|
3875
5666
|
});
|
|
3876
5667
|
await sandboxController.start();
|
|
5668
|
+
const appOriginController = createAppOriginController({
|
|
5669
|
+
port: config.appOriginPort,
|
|
5670
|
+
host: config.sandboxHost,
|
|
5671
|
+
embedderOrigins: appDocumentEmbedders(
|
|
5672
|
+
sandboxController.getUrl(),
|
|
5673
|
+
config.allowedOrigins
|
|
5674
|
+
)
|
|
5675
|
+
});
|
|
5676
|
+
await appOriginController.start();
|
|
5677
|
+
const secretStorage = await getSecretStorageInfo();
|
|
3877
5678
|
const {
|
|
3878
5679
|
app: honoApp,
|
|
3879
5680
|
authToken: resolvedToken,
|
|
@@ -3887,14 +5688,21 @@ function honoMiddlewarePlugin(config) {
|
|
|
3887
5688
|
initialServers: config.initialServers ?? void 0,
|
|
3888
5689
|
allowedOrigins: config.allowedOrigins,
|
|
3889
5690
|
sandboxUrl: sandboxController.getUrl() ?? void 0,
|
|
5691
|
+
publishAppDocument: (doc) => appOriginController.publish(doc),
|
|
3890
5692
|
logger: config.logger,
|
|
3891
|
-
initialConfig: webServerConfigToInitialPayload(config)
|
|
5693
|
+
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
|
5694
|
+
// The startup value above is what the banner printed; the route
|
|
5695
|
+
// re-resolves per request, because the first write under a newly-set
|
|
5696
|
+
// passphrase encrypts a pre-existing plaintext file and a captured
|
|
5697
|
+
// descriptor would keep reporting the old state until a restart.
|
|
5698
|
+
secretStorageResolver: getSecretStorageInfo
|
|
3892
5699
|
});
|
|
3893
5700
|
resolvedAuthToken = config.dangerouslyOmitAuth ? "" : resolvedToken;
|
|
3894
5701
|
const originalClose = server.close.bind(server);
|
|
3895
5702
|
server.close = async () => {
|
|
3896
5703
|
await closeApi();
|
|
3897
5704
|
await sandboxController.close();
|
|
5705
|
+
await appOriginController.close();
|
|
3898
5706
|
return originalClose();
|
|
3899
5707
|
};
|
|
3900
5708
|
const sandboxUrl = sandboxController.getUrl();
|
|
@@ -3905,7 +5713,8 @@ function honoMiddlewarePlugin(config) {
|
|
|
3905
5713
|
config,
|
|
3906
5714
|
actualPort,
|
|
3907
5715
|
resolvedToken,
|
|
3908
|
-
sandboxUrl ?? void 0
|
|
5716
|
+
sandboxUrl ?? void 0,
|
|
5717
|
+
secretStorage
|
|
3909
5718
|
);
|
|
3910
5719
|
if (config.autoOpen) {
|
|
3911
5720
|
openBrowser(url);
|
|
@@ -3943,10 +5752,10 @@ function honoMiddlewarePlugin(config) {
|
|
|
3943
5752
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
3944
5753
|
const chunks = [];
|
|
3945
5754
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
3946
|
-
await new Promise((
|
|
3947
|
-
req.once("end", () =>
|
|
3948
|
-
req.once("error", () =>
|
|
3949
|
-
req.once("close", () =>
|
|
5755
|
+
await new Promise((resolve9) => {
|
|
5756
|
+
req.once("end", () => resolve9());
|
|
5757
|
+
req.once("error", () => resolve9());
|
|
5758
|
+
req.once("close", () => resolve9());
|
|
3950
5759
|
});
|
|
3951
5760
|
if (chunks.length > 0) {
|
|
3952
5761
|
init.body = Buffer.concat(chunks);
|
|
@@ -3988,10 +5797,10 @@ function honoMiddlewarePlugin(config) {
|
|
|
3988
5797
|
while (true) {
|
|
3989
5798
|
const { done, value } = await reader.read();
|
|
3990
5799
|
if (done || clientGone || res.destroyed) break;
|
|
3991
|
-
await new Promise((
|
|
5800
|
+
await new Promise((resolve9, reject) => {
|
|
3992
5801
|
res.write(
|
|
3993
5802
|
Buffer.from(value),
|
|
3994
|
-
(err) => err ? reject(err) :
|
|
5803
|
+
(err) => err ? reject(err) : resolve9()
|
|
3995
5804
|
);
|
|
3996
5805
|
});
|
|
3997
5806
|
}
|
|
@@ -4025,7 +5834,7 @@ function honoMiddlewarePlugin(config) {
|
|
|
4025
5834
|
|
|
4026
5835
|
// server/vite-base-config.ts
|
|
4027
5836
|
import { rmSync } from "fs";
|
|
4028
|
-
import { join as
|
|
5837
|
+
import { join as join6 } from "path";
|
|
4029
5838
|
var NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [
|
|
4030
5839
|
"@modelcontextprotocol/client/stdio",
|
|
4031
5840
|
// `atomically` is reached only through `core/storage/store-io.ts`,
|
|
@@ -4048,7 +5857,15 @@ var NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [
|
|
|
4048
5857
|
// (#1905). It's a native-binding package (no browser code path) so
|
|
4049
5858
|
// excluding it keeps Vite's dep scanner from chasing into the
|
|
4050
5859
|
// platform-specific binaries during dev startup.
|
|
4051
|
-
"@napi-rs/keyring"
|
|
5860
|
+
"@napi-rs/keyring",
|
|
5861
|
+
// `proper-lockfile` is reached only through `core/auth/node/file-lock.ts`
|
|
5862
|
+
// — the secrets file's cross-process lock (#2082) — which the Hono
|
|
5863
|
+
// `/api/servers` handlers pull in via `core/auth/node/file-secret-store.ts`.
|
|
5864
|
+
// Same node-only import chain as `atomically` above, and the same reason:
|
|
5865
|
+
// it is CJS with a `graceful-fs`/`signal-exit`/`retry` graph that Vite's
|
|
5866
|
+
// dev scanner has no business walking. Note the tsup `external` lists do
|
|
5867
|
+
// **not** cover this — they configure the production bundles, not `vite dev`.
|
|
5868
|
+
"proper-lockfile"
|
|
4052
5869
|
];
|
|
4053
5870
|
function getViteDevOptimizeDeps() {
|
|
4054
5871
|
return {
|
|
@@ -4059,21 +5876,21 @@ function getViteDevOptimizeDeps() {
|
|
|
4059
5876
|
};
|
|
4060
5877
|
}
|
|
4061
5878
|
function clearViteDepsCache(clientWebRoot) {
|
|
4062
|
-
rmSync(
|
|
5879
|
+
rmSync(join6(clientWebRoot, "node_modules", ".vite"), {
|
|
4063
5880
|
recursive: true,
|
|
4064
5881
|
force: true
|
|
4065
5882
|
});
|
|
4066
5883
|
}
|
|
4067
5884
|
|
|
4068
5885
|
// ../../vitest.shared.mts
|
|
4069
|
-
import
|
|
5886
|
+
import path5 from "path";
|
|
4070
5887
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4071
5888
|
function vitestSharedPaths(clientDir) {
|
|
4072
|
-
const
|
|
4073
|
-
const repoRoot =
|
|
5889
|
+
const dirname10 = path5.resolve(clientDir);
|
|
5890
|
+
const repoRoot = path5.resolve(dirname10, "../..");
|
|
4074
5891
|
const sharedAliases = {
|
|
4075
|
-
"@inspector/core":
|
|
4076
|
-
"@modelcontextprotocol/inspector-test-server":
|
|
5892
|
+
"@inspector/core": path5.resolve(repoRoot, "core"),
|
|
5893
|
+
"@modelcontextprotocol/inspector-test-server": path5.resolve(
|
|
4077
5894
|
repoRoot,
|
|
4078
5895
|
"test-servers/build/index.js"
|
|
4079
5896
|
)
|
|
@@ -4082,82 +5899,147 @@ function vitestSharedPaths(clientDir) {
|
|
|
4082
5899
|
"react",
|
|
4083
5900
|
"react-dom",
|
|
4084
5901
|
"@modelcontextprotocol/client",
|
|
4085
|
-
"@modelcontextprotocol/core"
|
|
5902
|
+
"@modelcontextprotocol/core",
|
|
5903
|
+
// Every SDK schema is a zod type, so a second copy breaks `instanceof`
|
|
5904
|
+
// across the whole surface. The alias below picks the install; this keeps
|
|
5905
|
+
// it to one copy within it.
|
|
5906
|
+
"zod"
|
|
4086
5907
|
];
|
|
4087
5908
|
const nodeModulesAliases = [
|
|
4088
5909
|
{
|
|
4089
5910
|
find: /^react$/,
|
|
4090
|
-
replacement:
|
|
5911
|
+
replacement: path5.resolve(dirname10, "node_modules/react")
|
|
4091
5912
|
},
|
|
4092
5913
|
{
|
|
4093
5914
|
find: /^react\/jsx-runtime$/,
|
|
4094
|
-
replacement:
|
|
5915
|
+
replacement: path5.resolve(dirname10, "node_modules/react/jsx-runtime.js")
|
|
4095
5916
|
},
|
|
4096
5917
|
{
|
|
4097
5918
|
find: /^react\/jsx-dev-runtime$/,
|
|
4098
|
-
replacement:
|
|
4099
|
-
|
|
5919
|
+
replacement: path5.resolve(
|
|
5920
|
+
dirname10,
|
|
4100
5921
|
"node_modules/react/jsx-dev-runtime.js"
|
|
4101
5922
|
)
|
|
4102
5923
|
},
|
|
4103
5924
|
{
|
|
4104
5925
|
find: /^react-dom$/,
|
|
4105
|
-
replacement:
|
|
5926
|
+
replacement: path5.resolve(dirname10, "node_modules/react-dom")
|
|
4106
5927
|
},
|
|
4107
5928
|
{
|
|
4108
5929
|
find: /^react-dom\/client$/,
|
|
4109
|
-
replacement:
|
|
5930
|
+
replacement: path5.resolve(dirname10, "node_modules/react-dom/client.js")
|
|
5931
|
+
},
|
|
5932
|
+
// Everything below is **root-owned** and resolves from the repo root,
|
|
5933
|
+
// unlike the `react` / `react-dom` pins above. What they have in common is
|
|
5934
|
+
// that no client declares them, so the root install is the only place a
|
|
5935
|
+
// client's resolution chain is guaranteed to find one — not that they are
|
|
5936
|
+
// all root `dependencies`: `express` is test-only and sits in the root
|
|
5937
|
+
// `devDependencies`. Nor is this the complete root runtime set; a package
|
|
5938
|
+
// no client install carries a copy of (`ajv`, `commander`, `undici`)
|
|
5939
|
+
// resolves to the root on its own and needs no pin. Absence from this list
|
|
5940
|
+
// is a statement about the installed tree, not about the manifests — check
|
|
5941
|
+
// the tree before adding or removing an entry.
|
|
5942
|
+
//
|
|
5943
|
+
// `express` and `yaml` are reached only through `test-servers/src` —
|
|
5944
|
+
// express by the http/oauth servers, yaml by `load-config.ts` — which is
|
|
5945
|
+
// root-owned code with no manifest of its own.
|
|
5946
|
+
//
|
|
5947
|
+
// Pointing these at `<client>/node_modules` is what broke when the MCP
|
|
5948
|
+
// packages moved to the root (#1970): express was never declared by a client
|
|
5949
|
+
// at all, it arrived in `clients/cli` as a peer of `express-rate-limit`
|
|
5950
|
+
// under `@modelcontextprotocol/server-legacy`, so removing that manifest
|
|
5951
|
+
// entry took express with it and every cli test that spawns a test server
|
|
5952
|
+
// failed to resolve it.
|
|
5953
|
+
{
|
|
5954
|
+
find: /^express$/,
|
|
5955
|
+
replacement: path5.resolve(repoRoot, "node_modules/express")
|
|
5956
|
+
},
|
|
5957
|
+
{
|
|
5958
|
+
find: /^yaml$/,
|
|
5959
|
+
replacement: path5.resolve(repoRoot, "node_modules/yaml")
|
|
5960
|
+
},
|
|
5961
|
+
// Same reasoning, one layer in: `proper-lockfile` is reached only through
|
|
5962
|
+
// `core/` (the secrets file's cross-process lock, #2082), which is the
|
|
5963
|
+
// other root-owned tree with no manifest of its own. Resolution finds the
|
|
5964
|
+
// root copy on its own today — nothing declares it in a client — and this
|
|
5965
|
+
// pin is what keeps that from depending on nothing ever arriving as some
|
|
5966
|
+
// client's transitive dependency, which would otherwise give a test two
|
|
5967
|
+
// copies of a module whose whole job is a single registry of held locks.
|
|
5968
|
+
{
|
|
5969
|
+
find: /^proper-lockfile$/,
|
|
5970
|
+
replacement: path5.resolve(repoRoot, "node_modules/proper-lockfile")
|
|
5971
|
+
},
|
|
5972
|
+
// The rest of the root-owned aliases, consolidated into the root manifest
|
|
5973
|
+
// by #2195. Mostly `core/`'s runtime dependencies, but not only —
|
|
5974
|
+
// `@hono/node-server` is reached from web client code alone and is here for
|
|
5975
|
+
// the same resolution reason rather than because `core/` imports it. Each
|
|
5976
|
+
// used to be declared by the clients that reached it and was pinned to
|
|
5977
|
+
// `<client>/node_modules` accordingly; once the declarations went away
|
|
5978
|
+
// those paths stopped existing, so the pin has to follow the package to
|
|
5979
|
+
// the root. Left un-repointed they would resolve
|
|
5980
|
+
// to a directory that is not there — or, worse, to a transitive copy some
|
|
5981
|
+
// unrelated dependency happened to drag in, which is the duplicate this
|
|
5982
|
+
// whole pin list exists to prevent.
|
|
5983
|
+
{
|
|
5984
|
+
find: /^pino$/,
|
|
5985
|
+
replacement: path5.resolve(repoRoot, "node_modules/pino")
|
|
4110
5986
|
},
|
|
4111
|
-
{ find: /^pino$/, replacement: path2.resolve(dirname8, "node_modules/pino") },
|
|
4112
5987
|
{
|
|
4113
5988
|
find: /^pino\/browser\.js$/,
|
|
4114
|
-
replacement:
|
|
5989
|
+
replacement: path5.resolve(repoRoot, "node_modules/pino/browser.js")
|
|
4115
5990
|
},
|
|
4116
5991
|
{
|
|
4117
5992
|
find: /^hono$/,
|
|
4118
|
-
replacement:
|
|
5993
|
+
replacement: path5.resolve(repoRoot, "node_modules/hono/dist/index.js")
|
|
4119
5994
|
},
|
|
4120
5995
|
{
|
|
4121
5996
|
find: /^hono\/streaming$/,
|
|
4122
|
-
replacement:
|
|
4123
|
-
|
|
5997
|
+
replacement: path5.resolve(
|
|
5998
|
+
repoRoot,
|
|
4124
5999
|
"node_modules/hono/dist/helper/streaming/index.js"
|
|
4125
6000
|
)
|
|
4126
6001
|
},
|
|
4127
6002
|
{
|
|
4128
6003
|
find: /^@hono\/node-server$/,
|
|
4129
|
-
replacement:
|
|
6004
|
+
replacement: path5.resolve(repoRoot, "node_modules/@hono/node-server")
|
|
4130
6005
|
},
|
|
4131
6006
|
{
|
|
4132
6007
|
find: /^atomically$/,
|
|
4133
|
-
replacement:
|
|
6008
|
+
replacement: path5.resolve(repoRoot, "node_modules/atomically")
|
|
4134
6009
|
},
|
|
4135
6010
|
{
|
|
4136
6011
|
find: /^chokidar$/,
|
|
4137
|
-
replacement:
|
|
6012
|
+
replacement: path5.resolve(repoRoot, "node_modules/chokidar")
|
|
4138
6013
|
},
|
|
4139
6014
|
{
|
|
4140
6015
|
find: /^@napi-rs\/keyring$/,
|
|
4141
|
-
replacement:
|
|
6016
|
+
replacement: path5.resolve(repoRoot, "node_modules/@napi-rs/keyring")
|
|
4142
6017
|
},
|
|
4143
|
-
// `
|
|
4144
|
-
//
|
|
4145
|
-
//
|
|
4146
|
-
//
|
|
4147
|
-
//
|
|
4148
|
-
//
|
|
4149
|
-
//
|
|
4150
|
-
//
|
|
4151
|
-
//
|
|
4152
|
-
//
|
|
4153
|
-
//
|
|
6018
|
+
// `zod` and `open` are pinned for a different reason from everything above:
|
|
6019
|
+
// they resolve *somewhere* without help, and the somewhere is wrong. Both
|
|
6020
|
+
// still sit at the top level of `clients/web/node_modules` as transitive
|
|
6021
|
+
// copies — zod under `eslint-plugin-react-hooks`, open under Storybook —
|
|
6022
|
+
// so an unpinned bare import from a web test resolves the client copy while
|
|
6023
|
+
// `core/` and the SDK packages resolve the root's (Copilot).
|
|
6024
|
+
//
|
|
6025
|
+
// For `open` that is merely wasteful. For `zod` it is the hazard this file
|
|
6026
|
+
// exists for: two copies in one process means a schema built by one and an
|
|
6027
|
+
// `instanceof` check made by the other, across the entire
|
|
6028
|
+
// `@modelcontextprotocol/*` surface. The versions are identical today and
|
|
6029
|
+
// `verify:dep-lockstep` is what keeps them that way, but identical is not
|
|
6030
|
+
// the same as single, and only a pin makes it single.
|
|
6031
|
+
//
|
|
6032
|
+
// `zod/v4` needs its own entry — first-party code imports both specifiers,
|
|
6033
|
+
// and pinning only the bare one would split the package across two installs
|
|
6034
|
+
// rather than collapse it.
|
|
6035
|
+
{ find: /^zod$/, replacement: path5.resolve(repoRoot, "node_modules/zod") },
|
|
4154
6036
|
{
|
|
4155
|
-
find: /^
|
|
4156
|
-
replacement:
|
|
6037
|
+
find: /^zod\/v4$/,
|
|
6038
|
+
replacement: path5.resolve(repoRoot, "node_modules/zod/v4/index.js")
|
|
4157
6039
|
},
|
|
4158
6040
|
{
|
|
4159
|
-
find: /^
|
|
4160
|
-
replacement:
|
|
6041
|
+
find: /^open$/,
|
|
6042
|
+
replacement: path5.resolve(repoRoot, "node_modules/open")
|
|
4161
6043
|
}
|
|
4162
6044
|
];
|
|
4163
6045
|
const projectResolve = {
|
|
@@ -4180,9 +6062,9 @@ function vitestSharedPaths(clientDir) {
|
|
|
4180
6062
|
}
|
|
4181
6063
|
|
|
4182
6064
|
// server/start-vite-dev-server.ts
|
|
4183
|
-
var __dirname2 =
|
|
6065
|
+
var __dirname2 = dirname7(fileURLToPath4(import.meta.url));
|
|
4184
6066
|
async function startViteDevServer(config) {
|
|
4185
|
-
const root =
|
|
6067
|
+
const root = resolve6(join7(__dirname2, ".."));
|
|
4186
6068
|
clearViteDepsCache(root);
|
|
4187
6069
|
const { repoRoot, sharedAliases, sharedDedupe, nodeModulesAliases } = vitestSharedPaths(root);
|
|
4188
6070
|
const inlineConfig = {
|
|
@@ -4216,7 +6098,7 @@ async function startViteDevServer(config) {
|
|
|
4216
6098
|
},
|
|
4217
6099
|
plugins: [react(), honoMiddlewarePlugin(config)]
|
|
4218
6100
|
};
|
|
4219
|
-
const server = await
|
|
6101
|
+
const server = await createServer3(inlineConfig);
|
|
4220
6102
|
await server.listen();
|
|
4221
6103
|
return {
|
|
4222
6104
|
async close() {
|
|
@@ -4226,15 +6108,15 @@ async function startViteDevServer(config) {
|
|
|
4226
6108
|
}
|
|
4227
6109
|
|
|
4228
6110
|
// server/server.ts
|
|
4229
|
-
import { readFileSync as
|
|
4230
|
-
import { join as
|
|
6111
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
6112
|
+
import { join as join8, dirname as dirname8 } from "path";
|
|
4231
6113
|
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
4232
|
-
import { randomBytes as
|
|
6114
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
4233
6115
|
import { serve } from "@hono/node-server";
|
|
4234
6116
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
4235
6117
|
import { Hono as Hono2 } from "hono";
|
|
4236
6118
|
var __filename = fileURLToPath5(import.meta.url);
|
|
4237
|
-
var __dirname3 =
|
|
6119
|
+
var __dirname3 = dirname8(__filename);
|
|
4238
6120
|
async function startHonoServer(config) {
|
|
4239
6121
|
const sandboxController = createSandboxController({
|
|
4240
6122
|
port: config.sandboxPort,
|
|
@@ -4242,8 +6124,18 @@ async function startHonoServer(config) {
|
|
|
4242
6124
|
allowedOrigins: config.allowedOrigins
|
|
4243
6125
|
});
|
|
4244
6126
|
await sandboxController.start();
|
|
4245
|
-
const
|
|
6127
|
+
const appOriginController = createAppOriginController({
|
|
6128
|
+
port: config.appOriginPort,
|
|
6129
|
+
host: config.sandboxHost,
|
|
6130
|
+
embedderOrigins: appDocumentEmbedders(
|
|
6131
|
+
sandboxController.getUrl(),
|
|
6132
|
+
config.allowedOrigins
|
|
6133
|
+
)
|
|
6134
|
+
});
|
|
6135
|
+
await appOriginController.start();
|
|
6136
|
+
const resolvedAuthToken = config.authToken || (config.dangerouslyOmitAuth ? "" : randomBytes4(32).toString("hex"));
|
|
4246
6137
|
const rootPath = config.staticRoot ?? __dirname3;
|
|
6138
|
+
const secretStorage = await getSecretStorageInfo();
|
|
4247
6139
|
const { app: apiApp, close: closeApi } = createRemoteApp({
|
|
4248
6140
|
authToken: config.dangerouslyOmitAuth ? void 0 : resolvedAuthToken,
|
|
4249
6141
|
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
|
|
@@ -4253,16 +6145,22 @@ async function startHonoServer(config) {
|
|
|
4253
6145
|
initialServers: config.initialServers ?? void 0,
|
|
4254
6146
|
allowedOrigins: config.allowedOrigins,
|
|
4255
6147
|
sandboxUrl: sandboxController.getUrl() ?? void 0,
|
|
6148
|
+
publishAppDocument: (doc) => appOriginController.publish(doc),
|
|
4256
6149
|
logger: config.logger,
|
|
4257
|
-
initialConfig: webServerConfigToInitialPayload(config)
|
|
6150
|
+
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
|
6151
|
+
// The startup value above is what the banner printed; the route
|
|
6152
|
+
// re-resolves per request, because the first write under a newly-set
|
|
6153
|
+
// passphrase encrypts a pre-existing plaintext file and a captured
|
|
6154
|
+
// descriptor would keep reporting the old state until a restart.
|
|
6155
|
+
secretStorageResolver: getSecretStorageInfo
|
|
4258
6156
|
});
|
|
4259
6157
|
const app = new Hono2();
|
|
4260
6158
|
app.use("/api/*", async (c) => {
|
|
4261
6159
|
return apiApp.fetch(c.req.raw);
|
|
4262
6160
|
});
|
|
4263
6161
|
const serveIndexHtml = (c) => {
|
|
4264
|
-
const indexPath =
|
|
4265
|
-
const html =
|
|
6162
|
+
const indexPath = join8(rootPath, "index.html");
|
|
6163
|
+
const html = readFileSync6(indexPath, "utf-8");
|
|
4266
6164
|
c.header("Cache-Control", "no-store");
|
|
4267
6165
|
return c.html(injectAuthToken(html, resolvedAuthToken));
|
|
4268
6166
|
};
|
|
@@ -4298,7 +6196,8 @@ async function startHonoServer(config) {
|
|
|
4298
6196
|
config,
|
|
4299
6197
|
info.port,
|
|
4300
6198
|
resolvedAuthToken,
|
|
4301
|
-
sandboxUrl ?? void 0
|
|
6199
|
+
sandboxUrl ?? void 0,
|
|
6200
|
+
secretStorage
|
|
4302
6201
|
);
|
|
4303
6202
|
if (config.autoOpen) {
|
|
4304
6203
|
openBrowser(url);
|
|
@@ -4319,19 +6218,20 @@ async function startHonoServer(config) {
|
|
|
4319
6218
|
async close() {
|
|
4320
6219
|
await closeApi();
|
|
4321
6220
|
await sandboxController.close();
|
|
6221
|
+
await appOriginController.close();
|
|
4322
6222
|
if ("closeAllConnections" in httpServer) {
|
|
4323
6223
|
httpServer.closeAllConnections();
|
|
4324
6224
|
}
|
|
4325
|
-
await new Promise((
|
|
4326
|
-
httpServer.close((err) => err ? reject(err) :
|
|
6225
|
+
await new Promise((resolve9, reject) => {
|
|
6226
|
+
httpServer.close((err) => err ? reject(err) : resolve9());
|
|
4327
6227
|
});
|
|
4328
6228
|
}
|
|
4329
6229
|
};
|
|
4330
6230
|
}
|
|
4331
6231
|
|
|
4332
6232
|
// server/ensure-web-build.ts
|
|
4333
|
-
import { existsSync as
|
|
4334
|
-
import { join as
|
|
6233
|
+
import { existsSync as existsSync4 } from "fs";
|
|
6234
|
+
import { join as join9 } from "path";
|
|
4335
6235
|
import { spawnSync } from "child_process";
|
|
4336
6236
|
function runViteBuild(webRoot, log) {
|
|
4337
6237
|
const result = spawnSync("npm", ["run", "build:client"], {
|
|
@@ -4346,10 +6246,10 @@ function runViteBuild(webRoot, log) {
|
|
|
4346
6246
|
return result.status;
|
|
4347
6247
|
}
|
|
4348
6248
|
function ensureWebBuild(webRoot, distRoot, deps = {}) {
|
|
4349
|
-
const exists = deps.exists ??
|
|
6249
|
+
const exists = deps.exists ?? existsSync4;
|
|
4350
6250
|
const build = deps.build ?? runViteBuild;
|
|
4351
6251
|
const log = deps.log ?? ((message) => console.log(message));
|
|
4352
|
-
const indexHtml =
|
|
6252
|
+
const indexHtml = join9(distRoot, "index.html");
|
|
4353
6253
|
if (exists(indexHtml)) return;
|
|
4354
6254
|
log(
|
|
4355
6255
|
"No production web build found at clients/web/dist. Building the web UI now (first run only; this can take a minute)..."
|
|
@@ -4363,10 +6263,10 @@ function ensureWebBuild(webRoot, distRoot, deps = {}) {
|
|
|
4363
6263
|
}
|
|
4364
6264
|
|
|
4365
6265
|
// server/run-web.ts
|
|
4366
|
-
var __dirname4 =
|
|
6266
|
+
var __dirname4 = dirname9(fileURLToPath6(import.meta.url));
|
|
4367
6267
|
function ensureStdioCwd(config) {
|
|
4368
6268
|
if ((config.type === "stdio" || config.type === void 0) && config.command && !config.cwd) {
|
|
4369
|
-
return { ...config, cwd:
|
|
6269
|
+
return { ...config, cwd: resolve7(process.cwd()) };
|
|
4370
6270
|
}
|
|
4371
6271
|
return config;
|
|
4372
6272
|
}
|
|
@@ -4424,7 +6324,7 @@ async function runWeb(argv) {
|
|
|
4424
6324
|
"Note: --server has no effect on the web UI yet; it lists every server in the catalog."
|
|
4425
6325
|
);
|
|
4426
6326
|
}
|
|
4427
|
-
mcpConfigPath =
|
|
6327
|
+
mcpConfigPath = resolve7(process.cwd(), catalogPath);
|
|
4428
6328
|
writable = true;
|
|
4429
6329
|
} else if (hasConfig) {
|
|
4430
6330
|
if (opts.server) {
|
|
@@ -4432,7 +6332,7 @@ async function runWeb(argv) {
|
|
|
4432
6332
|
"Note: --server has no effect on the web UI yet; it lists every server in the file."
|
|
4433
6333
|
);
|
|
4434
6334
|
}
|
|
4435
|
-
const resolvedConfigPath =
|
|
6335
|
+
const resolvedConfigPath = resolve7(process.cwd(), opts.config);
|
|
4436
6336
|
try {
|
|
4437
6337
|
resolveServerConfigs({ configPath: resolvedConfigPath }, "multi");
|
|
4438
6338
|
} catch (err) {
|
|
@@ -4453,14 +6353,14 @@ async function runWeb(argv) {
|
|
|
4453
6353
|
cwd: opts.cwd,
|
|
4454
6354
|
env: opts.e
|
|
4455
6355
|
};
|
|
4456
|
-
const
|
|
4457
|
-
if (!
|
|
6356
|
+
const resolved2 = resolveServerConfigs(serverOptions, "single")[0];
|
|
6357
|
+
if (!resolved2) {
|
|
4458
6358
|
console.error(
|
|
4459
6359
|
"Error: Could not resolve server config. Pass a command/URL, or use --catalog / --config to serve a file."
|
|
4460
6360
|
);
|
|
4461
6361
|
process.exit(1);
|
|
4462
6362
|
}
|
|
4463
|
-
config = ensureStdioCwd(
|
|
6363
|
+
config = ensureStdioCwd(resolved2);
|
|
4464
6364
|
} catch (err) {
|
|
4465
6365
|
const message = err instanceof Error ? err.message : "Could not resolve server config.";
|
|
4466
6366
|
console.error("Error:", message);
|
|
@@ -4500,8 +6400,8 @@ async function runWeb(argv) {
|
|
|
4500
6400
|
console.error("Error:", message);
|
|
4501
6401
|
process.exit(1);
|
|
4502
6402
|
}
|
|
4503
|
-
const webRoot =
|
|
4504
|
-
const distRoot =
|
|
6403
|
+
const webRoot = join10(__dirname4, "..");
|
|
6404
|
+
const distRoot = join10(webRoot, "dist");
|
|
4505
6405
|
if (!isDev) {
|
|
4506
6406
|
webConfig.staticRoot = distRoot;
|
|
4507
6407
|
}
|
|
@@ -4532,7 +6432,7 @@ async function runWeb(argv) {
|
|
|
4532
6432
|
|
|
4533
6433
|
// src/index.ts
|
|
4534
6434
|
var __filename2 = fileURLToPath7(import.meta.url);
|
|
4535
|
-
var isMain = process.argv[1] !== void 0 &&
|
|
6435
|
+
var isMain = process.argv[1] !== void 0 && resolve8(process.argv[1]) === resolve8(__filename2);
|
|
4536
6436
|
if (isMain) {
|
|
4537
6437
|
runWeb(process.argv).then((code) => process.exit(code ?? 0)).catch((e) => {
|
|
4538
6438
|
console.error(e);
|
|
@@ -4542,3 +6442,16 @@ if (isMain) {
|
|
|
4542
6442
|
export {
|
|
4543
6443
|
runWeb
|
|
4544
6444
|
};
|
|
6445
|
+
/* v8 ignore next 2 -- @preserve: the non-Error arm is unreachable via
|
|
6446
|
+
proper-lockfile, which rejects only with Errors. */
|
|
6447
|
+
/* v8 ignore next -- @preserve: node validates the cost parameters
|
|
6448
|
+
synchronously (an out-of-grammar `N` throws from the call itself,
|
|
6449
|
+
which the corrupt-KDF test exercises), so this asynchronous channel
|
|
6450
|
+
is left for allocation failure — not provokable deterministically. */
|
|
6451
|
+
/* v8 ignore next -- @preserve: unreachable, `encryptedEnvelopeProblem`
|
|
6452
|
+
above returns a reason for anything `decodeParts` would reject. Kept as
|
|
6453
|
+
the type narrowing `parts` needs. */
|
|
6454
|
+
/* v8 ignore next -- @preserve: the walk can only run out of ancestors if
|
|
6455
|
+
the filesystem root itself does not exist, which would mean the process
|
|
6456
|
+
has no cwd to have resolved against. Kept as the loop's termination
|
|
6457
|
+
guard rather than trusting that invariant. */
|