dsh-mcp 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +17 -0
- package/CHANGELOG.md +17 -0
- package/README.en.md +23 -5
- package/README.md +19 -4
- package/lib/client.js +970 -364
- package/lib/index.js +321 -12
- package/lib/oauth.js +46 -5
- package/package.json +1 -1
- package/src/client/GlobalEnvEditor.module.css +194 -0
- package/src/client/GlobalEnvEditor.tsx +245 -0
- package/src/client/McpSettingsSection.module.css +67 -0
- package/src/client/McpSettingsSection.tsx +82 -18
- package/src/client/ServerForm.module.css +0 -47
- package/src/client/ServerForm.tsx +1 -55
- package/src/client/ServersJsonEditor.module.css +90 -0
- package/src/client/ServersJsonEditor.tsx +226 -0
- package/src/client/index.ts +21 -3
- package/src/client/locales.ts +54 -4
- package/src/client/mcp-store.ts +4 -43
- package/src/client/remote-contribution.js +163 -2
- package/src/client/types.ts +2 -0
package/lib/index.js
CHANGED
|
@@ -81,8 +81,16 @@ const mcpServersDomainSpec = defineDomain({
|
|
|
81
81
|
});
|
|
82
82
|
envNames.add(entry.name);
|
|
83
83
|
});
|
|
84
|
-
}))
|
|
85
|
-
|
|
84
|
+
})) ,
|
|
85
|
+
// Process-level environment variables: shared by every server's header
|
|
86
|
+
// substitution. Plain values live here; secret values are stored in the
|
|
87
|
+
// credentials document under DSH_MCP_ENV_<name>.
|
|
88
|
+
global_env: domainTable(z$1.object({
|
|
89
|
+
name: envVarNameSchema,
|
|
90
|
+
secret: z$1.boolean(),
|
|
91
|
+
value: z$1.string().default("")
|
|
92
|
+
}))
|
|
93
|
+
} });
|
|
86
94
|
/** Thrown by {@link validateServerInput}; the service maps it to `MCP_INVALID_SPEC`. */
|
|
87
95
|
var McpServerValidationError = class extends Error {
|
|
88
96
|
/** Discriminates validation failures from internal errors at the Remote boundary. */
|
|
@@ -159,6 +167,49 @@ function toServerRow(id, server, env) {
|
|
|
159
167
|
failOnStartupError: server.failOnStartupError
|
|
160
168
|
};
|
|
161
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Normalize a JSON-editor header value into the wire header-array shape.
|
|
172
|
+
* Accepts `{ "Name": "value" }` or `[{name, value}]`; anything else yields
|
|
173
|
+
* an empty header set.
|
|
174
|
+
* @param headers - The submitted headers.
|
|
175
|
+
* @returns The wire header array.
|
|
176
|
+
*/
|
|
177
|
+
function normalizeHeaders(headers) {
|
|
178
|
+
if (Array.isArray(headers)) return headers.map((header) => ({
|
|
179
|
+
name: String(header?.name ?? ""),
|
|
180
|
+
value: String(header?.value ?? "")
|
|
181
|
+
}));
|
|
182
|
+
if (headers !== null && typeof headers === "object") return Object.entries(headers).map(([name, value]) => ({
|
|
183
|
+
name,
|
|
184
|
+
value: String(value ?? "")
|
|
185
|
+
}));
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Substitute `${NAME}` placeholders and bare variable names in one header
|
|
190
|
+
* value. Resolution order per variable: the server's resolved env (including
|
|
191
|
+
* secrets), then the process-level env table, then the process environment.
|
|
192
|
+
* A bare value that exactly matches a configured variable name is treated as
|
|
193
|
+
* a reference to that variable. Unresolved placeholders stay literal so a
|
|
194
|
+
* missing variable never silently empties a header.
|
|
195
|
+
* @param value - The stored header value (may contain `${NAME}` tokens or be a bare variable name).
|
|
196
|
+
* @param env - The server's resolved environment variables.
|
|
197
|
+
* @param globalEnv - The process-level environment variables.
|
|
198
|
+
* @returns The header value with known variables substituted.
|
|
199
|
+
*/
|
|
200
|
+
function interpolateEnv(value, env, globalEnv) {
|
|
201
|
+
const text = String(value);
|
|
202
|
+
const substituted = text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name) => {
|
|
203
|
+
if (env !== null && typeof env === "object" && env[name] !== void 0 && env[name] !== "") return env[name];
|
|
204
|
+
if (globalEnv !== null && typeof globalEnv === "object" && globalEnv[name] !== void 0 && globalEnv[name] !== "") return globalEnv[name];
|
|
205
|
+
if (process.env[name] !== void 0 && process.env[name] !== "") return process.env[name];
|
|
206
|
+
return match;
|
|
207
|
+
});
|
|
208
|
+
const trimmed = substituted.trim();
|
|
209
|
+
if (env !== null && typeof env === "object" && env[trimmed] !== void 0 && env[trimmed] !== "") return env[trimmed];
|
|
210
|
+
if (globalEnv !== null && typeof globalEnv === "object" && globalEnv[trimmed] !== void 0 && globalEnv[trimmed] !== "") return globalEnv[trimmed];
|
|
211
|
+
return substituted;
|
|
212
|
+
}
|
|
162
213
|
//#endregion
|
|
163
214
|
//#region lib/types/index.js
|
|
164
215
|
/**
|
|
@@ -213,6 +264,8 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
|
|
|
213
264
|
};
|
|
214
265
|
/** Credential reference namespace prefix for secret env values. */
|
|
215
266
|
const SECRET_REF_PREFIX = "DSH_MCP_";
|
|
267
|
+
/** Credential reference namespace prefix for process-level secret env values. */
|
|
268
|
+
const GLOBAL_ENV_REF_PREFIX = "DSH_MCP_ENV_";
|
|
216
269
|
const Config = z.object({ probeTimeoutMs: z.number().min(1).default(15e3) });
|
|
217
270
|
/** The mcp-client plugin as a Cordis object plugin, mounted per server. */
|
|
218
271
|
const MCP_CLIENT_PLUGIN = {
|
|
@@ -242,6 +295,10 @@ function managedServerId(ref) {
|
|
|
242
295
|
function secretRef(id, name) {
|
|
243
296
|
return credentialRef(`${SECRET_REF_PREFIX}${id}_${name}`);
|
|
244
297
|
}
|
|
298
|
+
/** Credential reference holding one process-level secret env value. */
|
|
299
|
+
function globalEnvRef(name) {
|
|
300
|
+
return credentialRef(`${GLOBAL_ENV_REF_PREFIX}${name}`);
|
|
301
|
+
}
|
|
245
302
|
/** Brand a fresh random server id at the minting boundary. */
|
|
246
303
|
function mintServerId(existing) {
|
|
247
304
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
@@ -259,21 +316,27 @@ let McpManagerService = (() => {
|
|
|
259
316
|
let _instanceExtraInitializers = [];
|
|
260
317
|
let _list_decorators;
|
|
261
318
|
let _upsert_decorators;
|
|
319
|
+
let _upsertJson_decorators;
|
|
262
320
|
let _delete_decorators;
|
|
263
321
|
let _test_decorators;
|
|
264
322
|
let _toolsList_decorators;
|
|
265
323
|
let _toolsSet_decorators;
|
|
266
324
|
let _toolsMode_decorators;
|
|
325
|
+
let _envList_decorators;
|
|
326
|
+
let _envSet_decorators;
|
|
267
327
|
return class McpManagerService extends _classSuper {
|
|
268
328
|
static {
|
|
269
329
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
270
330
|
_list_decorators = [Remote("list")];
|
|
271
331
|
_upsert_decorators = [Remote("upsert")];
|
|
332
|
+
_upsertJson_decorators = [Remote("upsertJson")];
|
|
272
333
|
_delete_decorators = [Remote("delete")];
|
|
273
334
|
_test_decorators = [Remote("test")];
|
|
274
335
|
_toolsList_decorators = [Remote("toolsList")];
|
|
275
336
|
_toolsSet_decorators = [Remote("toolsSet")];
|
|
276
337
|
_toolsMode_decorators = [Remote("toolsMode")];
|
|
338
|
+
_envList_decorators = [Remote("envList")];
|
|
339
|
+
_envSet_decorators = [Remote("envSet")];
|
|
277
340
|
__esDecorate(this, null, _list_decorators, {
|
|
278
341
|
kind: "method",
|
|
279
342
|
name: "list",
|
|
@@ -307,6 +370,17 @@ let McpManagerService = (() => {
|
|
|
307
370
|
},
|
|
308
371
|
metadata: _metadata
|
|
309
372
|
}, null, _instanceExtraInitializers);
|
|
373
|
+
__esDecorate(this, null, _upsertJson_decorators, {
|
|
374
|
+
kind: "method",
|
|
375
|
+
name: "upsertJson",
|
|
376
|
+
static: false,
|
|
377
|
+
private: false,
|
|
378
|
+
access: {
|
|
379
|
+
has: (obj) => "upsertJson" in obj,
|
|
380
|
+
get: (obj) => obj.upsertJson
|
|
381
|
+
},
|
|
382
|
+
metadata: _metadata
|
|
383
|
+
}, null, _instanceExtraInitializers);
|
|
310
384
|
__esDecorate(this, null, _test_decorators, {
|
|
311
385
|
kind: "method",
|
|
312
386
|
name: "test",
|
|
@@ -351,6 +425,28 @@ let McpManagerService = (() => {
|
|
|
351
425
|
},
|
|
352
426
|
metadata: _metadata
|
|
353
427
|
}, null, _instanceExtraInitializers);
|
|
428
|
+
__esDecorate(this, null, _envList_decorators, {
|
|
429
|
+
kind: "method",
|
|
430
|
+
name: "envList",
|
|
431
|
+
static: false,
|
|
432
|
+
private: false,
|
|
433
|
+
access: {
|
|
434
|
+
has: (obj) => "envList" in obj,
|
|
435
|
+
get: (obj) => obj.envList
|
|
436
|
+
},
|
|
437
|
+
metadata: _metadata
|
|
438
|
+
}, null, _instanceExtraInitializers);
|
|
439
|
+
__esDecorate(this, null, _envSet_decorators, {
|
|
440
|
+
kind: "method",
|
|
441
|
+
name: "envSet",
|
|
442
|
+
static: false,
|
|
443
|
+
private: false,
|
|
444
|
+
access: {
|
|
445
|
+
has: (obj) => "envSet" in obj,
|
|
446
|
+
get: (obj) => obj.envSet
|
|
447
|
+
},
|
|
448
|
+
metadata: _metadata
|
|
449
|
+
}, null, _instanceExtraInitializers);
|
|
354
450
|
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
355
451
|
enumerable: true,
|
|
356
452
|
configurable: true,
|
|
@@ -396,6 +492,7 @@ let McpManagerService = (() => {
|
|
|
396
492
|
await domain.close();
|
|
397
493
|
}, "mcp-manager.domainClose");
|
|
398
494
|
this.table = domain.table("servers");
|
|
495
|
+
this.envTable = domain.table("global_env");
|
|
399
496
|
for (const [id, row] of this.table.entries()) if (row.enabled) this.mount(id, row);
|
|
400
497
|
this.installToolControl();
|
|
401
498
|
this.ctx.on("credentials/updated", (ref) => {
|
|
@@ -417,6 +514,11 @@ let McpManagerService = (() => {
|
|
|
417
514
|
if (this.table === void 0) throw new Error("mcp-manager: storage domain is not open");
|
|
418
515
|
return this.table;
|
|
419
516
|
}
|
|
517
|
+
/** Resolve the process-level env table after the domain is open. */
|
|
518
|
+
requireEnvTable() {
|
|
519
|
+
if (this.envTable === void 0) throw new Error("mcp-manager: storage domain is not open");
|
|
520
|
+
return this.envTable;
|
|
521
|
+
}
|
|
420
522
|
/** Resolve the full env map for one row: plain values plus credentials. */
|
|
421
523
|
async resolveEnv(row) {
|
|
422
524
|
const env = {};
|
|
@@ -426,8 +528,17 @@ let McpManagerService = (() => {
|
|
|
426
528
|
} else if (entry.value !== void 0) env[entry.name] = entry.value;
|
|
427
529
|
return env;
|
|
428
530
|
}
|
|
531
|
+
/** Resolve the process-level env map: plain table values plus credentials. */
|
|
532
|
+
async resolveGlobalEnv() {
|
|
533
|
+
const env = {};
|
|
534
|
+
for (const [name, row] of this.requireEnvTable().entries()) if (row.secret) {
|
|
535
|
+
const hit = await this.ctx.credentials.resolve(globalEnvRef(name));
|
|
536
|
+
if (hit !== void 0) env[name] = hit.value;
|
|
537
|
+
} else if (row.value !== void 0) env[name] = row.value;
|
|
538
|
+
return env;
|
|
539
|
+
}
|
|
429
540
|
/** Map a server spec + resolved env to the mcp-client plugin config. */
|
|
430
|
-
toClientConfig(spec, env) {
|
|
541
|
+
toClientConfig(spec, env, globalEnv) {
|
|
431
542
|
if (spec.transport === "stdio") return {
|
|
432
543
|
transport: "stdio",
|
|
433
544
|
serverName: spec.serverName,
|
|
@@ -442,7 +553,10 @@ let McpManagerService = (() => {
|
|
|
442
553
|
transport: "streamable-http",
|
|
443
554
|
serverName: spec.serverName,
|
|
444
555
|
url: spec.url,
|
|
445
|
-
|
|
556
|
+
// Header values support `${ENV}` placeholders and bare variable
|
|
557
|
+
// names resolved from the server's env (including secrets), the
|
|
558
|
+
// process-level env table, or the process environment.
|
|
559
|
+
headers: Object.fromEntries(spec.headers.map((header) => [header.name, interpolateEnv(header.value, env, globalEnv)])),
|
|
446
560
|
toolCallTimeoutMs: spec.toolCallTimeoutMs,
|
|
447
561
|
failOnStartupError: spec.failOnStartupError
|
|
448
562
|
};
|
|
@@ -465,13 +579,14 @@ let McpManagerService = (() => {
|
|
|
465
579
|
let handle;
|
|
466
580
|
try {
|
|
467
581
|
const env = await this.resolveEnv(row);
|
|
582
|
+
const globalEnv = await this.resolveGlobalEnv();
|
|
468
583
|
// Every server gets an OAuth provider: servers that require
|
|
469
584
|
// OAuth (authorization-code flow) trigger browser auth on
|
|
470
585
|
// connect; servers that do not are unaffected. Tokens are
|
|
471
586
|
// keyed by serverName so a test-connection authorization is
|
|
472
587
|
// reused by the mount.
|
|
473
588
|
const authProvider = createOAuthProvider(row.serverName, this.ctx);
|
|
474
|
-
handle = this.ctx.plugin(MCP_CLIENT_PLUGIN, { ...this.toClientConfig(row, env), authProvider });
|
|
589
|
+
handle = this.ctx.plugin(MCP_CLIENT_PLUGIN, { ...this.toClientConfig(row, env, globalEnv), authProvider });
|
|
475
590
|
} catch (error) {
|
|
476
591
|
entry.phase = "failed";
|
|
477
592
|
entry.error = errorText(error);
|
|
@@ -522,7 +637,10 @@ let McpManagerService = (() => {
|
|
|
522
637
|
env.push({
|
|
523
638
|
name: entry.name,
|
|
524
639
|
secret: entry.secret,
|
|
525
|
-
configured
|
|
640
|
+
configured,
|
|
641
|
+
// Non-secret values round-trip through the JSON editor; secret
|
|
642
|
+
// values stay in the credentials document (blank keeps them).
|
|
643
|
+
...entry.secret ? {} : { value: entry.value ?? "" }
|
|
526
644
|
});
|
|
527
645
|
}
|
|
528
646
|
return {
|
|
@@ -578,7 +696,7 @@ let McpManagerService = (() => {
|
|
|
578
696
|
async upsert(request) {
|
|
579
697
|
const table = this.requireTable();
|
|
580
698
|
try {
|
|
581
|
-
validateServerInput(request.server, request.env);
|
|
699
|
+
validateServerInput(request.server, request.env ?? []);
|
|
582
700
|
} catch (error) {
|
|
583
701
|
if (error instanceof McpServerValidationError) return {
|
|
584
702
|
ok: false,
|
|
@@ -602,8 +720,14 @@ let McpManagerService = (() => {
|
|
|
602
720
|
}
|
|
603
721
|
};
|
|
604
722
|
const previous = table.get(id);
|
|
605
|
-
|
|
606
|
-
|
|
723
|
+
// The settings form no longer edits env (process-level env vars are
|
|
724
|
+
// managed separately); when env is not submitted, keep the stored
|
|
725
|
+
// env untouched. The JSON editor still submits env to replace it.
|
|
726
|
+
const envInputs = request.env === void 0
|
|
727
|
+
? (previous?.env ?? []).map((entry) => ({ ...entry }))
|
|
728
|
+
: request.env;
|
|
729
|
+
await this.applyEnv(id, previous, envInputs);
|
|
730
|
+
const row = toServerRow(id, request.server, envInputs);
|
|
607
731
|
await table.put(id, row);
|
|
608
732
|
await this.serialize(id, () => this.reconcileMount(id));
|
|
609
733
|
return {
|
|
@@ -655,6 +779,190 @@ let McpManagerService = (() => {
|
|
|
655
779
|
return { ok: true };
|
|
656
780
|
}
|
|
657
781
|
/**
|
|
782
|
+
* Replace the whole server list from one JSON document (JSON editor
|
|
783
|
+
* path). Entries are matched by serverName: listed ones are created or
|
|
784
|
+
* updated, existing servers absent from the document are removed.
|
|
785
|
+
* Secret env values are never submitted back — a blank value keeps the
|
|
786
|
+
* stored one (same semantics as a single upsert).
|
|
787
|
+
* @param request - The full desired server list.
|
|
788
|
+
* @returns change counts plus the refreshed server list, or a failure.
|
|
789
|
+
*/
|
|
790
|
+
async upsertJson(request) {
|
|
791
|
+
const items = request?.servers;
|
|
792
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
793
|
+
return {
|
|
794
|
+
ok: false,
|
|
795
|
+
error: {
|
|
796
|
+
code: "MCP_INVALID_SPEC",
|
|
797
|
+
message: "servers must be a non-empty array of { server, env } entries"
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
const table = this.requireTable();
|
|
802
|
+
// Normalize and validate every entry up front so a bad document
|
|
803
|
+
// never partially applies.
|
|
804
|
+
const normalized = [];
|
|
805
|
+
const seen = /* @__PURE__ */ new Set();
|
|
806
|
+
for (const item of items) {
|
|
807
|
+
if (item === null || typeof item !== "object" || item.server === null || typeof item.server !== "object") {
|
|
808
|
+
return {
|
|
809
|
+
ok: false,
|
|
810
|
+
error: {
|
|
811
|
+
code: "MCP_INVALID_SPEC",
|
|
812
|
+
message: "each entry must be an object with a server definition"
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
const env = Array.isArray(item.env) ? item.env : [];
|
|
817
|
+
const spec = {
|
|
818
|
+
serverName: item.server.serverName,
|
|
819
|
+
transport: item.server.transport,
|
|
820
|
+
enabled: item.server.enabled !== false,
|
|
821
|
+
command: typeof item.server.command === "string" ? item.server.command : "",
|
|
822
|
+
args: Array.isArray(item.server.args) ? item.server.args : [],
|
|
823
|
+
cwd: typeof item.server.cwd === "string" ? item.server.cwd : "",
|
|
824
|
+
url: typeof item.server.url === "string" ? item.server.url : "",
|
|
825
|
+
headers: normalizeHeaders(item.server.headers),
|
|
826
|
+
toolCallTimeoutMs: Number.isInteger(item.server.toolCallTimeoutMs) ? item.server.toolCallTimeoutMs : 60e3,
|
|
827
|
+
failOnStartupError: item.server.failOnStartupError !== false
|
|
828
|
+
};
|
|
829
|
+
try {
|
|
830
|
+
validateServerInput(spec, env);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
if (error instanceof McpServerValidationError) return {
|
|
833
|
+
ok: false,
|
|
834
|
+
error: {
|
|
835
|
+
code: "MCP_INVALID_SPEC",
|
|
836
|
+
message: `${spec.serverName}: ${error.message}`
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
throw error;
|
|
840
|
+
}
|
|
841
|
+
if (seen.has(spec.serverName)) return {
|
|
842
|
+
ok: false,
|
|
843
|
+
error: {
|
|
844
|
+
code: "MCP_SERVER_NAME_CONFLICT",
|
|
845
|
+
message: `duplicate serverName "${spec.serverName}" in the JSON document`
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
seen.add(spec.serverName);
|
|
849
|
+
normalized.push({ server: spec, env });
|
|
850
|
+
}
|
|
851
|
+
const byName = /* @__PURE__ */ new Map();
|
|
852
|
+
for (const [id, row] of table.entries()) byName.set(row.serverName, { id, row });
|
|
853
|
+
let added = 0;
|
|
854
|
+
let updated = 0;
|
|
855
|
+
for (const { server, env } of normalized) {
|
|
856
|
+
const existing = byName.get(server.serverName);
|
|
857
|
+
const id = existing?.id ?? mintServerId(new Set(table.keys()));
|
|
858
|
+
await this.applyEnv(id, existing?.row, env);
|
|
859
|
+
const row = toServerRow(id, server, env);
|
|
860
|
+
await table.put(id, row);
|
|
861
|
+
await this.serialize(id, () => this.reconcileMount(id));
|
|
862
|
+
if (existing === void 0) added += 1;
|
|
863
|
+
else updated += 1;
|
|
864
|
+
}
|
|
865
|
+
let removed = 0;
|
|
866
|
+
for (const [id, row] of table.entries()) {
|
|
867
|
+
if (seen.has(row.serverName)) continue;
|
|
868
|
+
await this.serialize(id, async () => {
|
|
869
|
+
await this.disposeMount(id);
|
|
870
|
+
await table.delete(id);
|
|
871
|
+
});
|
|
872
|
+
for (const entry of row.env) if (entry.secret) await this.ctx.credentials.unset(secretRef(id, entry.name));
|
|
873
|
+
removed += 1;
|
|
874
|
+
}
|
|
875
|
+
const listed = await this.list();
|
|
876
|
+
return {
|
|
877
|
+
ok: true,
|
|
878
|
+
added,
|
|
879
|
+
updated,
|
|
880
|
+
removed,
|
|
881
|
+
servers: listed.servers
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Read the process-level environment variables (plain values from the env
|
|
886
|
+
* table, secret flags and configured state from the credentials document).
|
|
887
|
+
* Used as the header-substitution source for every server.
|
|
888
|
+
* @returns the current process-level env rows.
|
|
889
|
+
*/
|
|
890
|
+
async envList() {
|
|
891
|
+
const table = this.requireEnvTable();
|
|
892
|
+
const vars = [];
|
|
893
|
+
for (const [name, row] of table.entries()) {
|
|
894
|
+
const configured = row.secret ? (await this.ctx.credentials.describe(globalEnvRef(name))).configured : (row.value ?? "").length > 0;
|
|
895
|
+
vars.push({
|
|
896
|
+
name,
|
|
897
|
+
secret: row.secret,
|
|
898
|
+
configured,
|
|
899
|
+
...row.secret ? {} : { value: row.value ?? "" }
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
return { ok: true, vars };
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Replace the whole process-level env table from one submission. Secret
|
|
906
|
+
* values go to the credentials document; a blank secret value keeps the
|
|
907
|
+
* stored one.
|
|
908
|
+
* @param request - The full desired process-level env rows.
|
|
909
|
+
* @returns the refreshed env list, or a failure.
|
|
910
|
+
*/
|
|
911
|
+
async envSet(request) {
|
|
912
|
+
const inputs = request?.vars;
|
|
913
|
+
if (!Array.isArray(inputs)) {
|
|
914
|
+
return {
|
|
915
|
+
ok: false,
|
|
916
|
+
error: {
|
|
917
|
+
code: "MCP_INVALID_SPEC",
|
|
918
|
+
message: "vars must be an array of { name, secret, value? }"
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
const table = this.requireEnvTable();
|
|
923
|
+
const names = /* @__PURE__ */ new Set();
|
|
924
|
+
for (const entry of inputs) {
|
|
925
|
+
if (!envVarNameSchema.safeParse(entry?.name).success) {
|
|
926
|
+
return {
|
|
927
|
+
ok: false,
|
|
928
|
+
error: {
|
|
929
|
+
code: "MCP_INVALID_SPEC",
|
|
930
|
+
message: `environment variable name '${String(entry?.name ?? "")}' must be a POSIX identifier`
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
if (names.has(entry.name)) {
|
|
935
|
+
return {
|
|
936
|
+
ok: false,
|
|
937
|
+
error: {
|
|
938
|
+
code: "MCP_INVALID_SPEC",
|
|
939
|
+
message: `duplicate environment variable '${entry.name}'`
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
names.add(entry.name);
|
|
944
|
+
}
|
|
945
|
+
// Unset secrets no longer present.
|
|
946
|
+
for (const [name, row] of table.entries()) if (row.secret && !names.has(name)) await this.ctx.credentials.unset(globalEnvRef(name));
|
|
947
|
+
// Rewrite the table and secrets.
|
|
948
|
+
for (const name of [...table.entries()].map(([key]) => key)) await table.delete(name);
|
|
949
|
+
for (const entry of inputs) {
|
|
950
|
+
if (entry.secret === true && entry.value !== void 0 && entry.value.length > 0) {
|
|
951
|
+
await this.ctx.credentials.set(globalEnvRef(entry.name), entry.value);
|
|
952
|
+
}
|
|
953
|
+
await table.put(entry.name, {
|
|
954
|
+
name: entry.name,
|
|
955
|
+
secret: entry.secret === true,
|
|
956
|
+
value: entry.secret === true ? "" : String(entry.value ?? "")
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
const listed = await this.envList();
|
|
960
|
+
return {
|
|
961
|
+
ok: true,
|
|
962
|
+
vars: listed.vars
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
658
966
|
* Probe one server configuration without persisting or mounting anything.
|
|
659
967
|
* Secret env values resolve from the submitted values, or from the stored
|
|
660
968
|
* credentials when the request carries an existing server id.
|
|
@@ -664,7 +972,7 @@ let McpManagerService = (() => {
|
|
|
664
972
|
*/
|
|
665
973
|
async test(request) {
|
|
666
974
|
try {
|
|
667
|
-
validateServerInput(request.server, request.env);
|
|
975
|
+
validateServerInput(request.server, request.env ?? []);
|
|
668
976
|
} catch (error) {
|
|
669
977
|
if (error instanceof McpServerValidationError) return {
|
|
670
978
|
ok: true,
|
|
@@ -677,7 +985,7 @@ let McpManagerService = (() => {
|
|
|
677
985
|
throw error;
|
|
678
986
|
}
|
|
679
987
|
const env = {};
|
|
680
|
-
for (const entry of request.env) if (entry.secret) {
|
|
988
|
+
for (const entry of request.env ?? []) if (entry.secret) {
|
|
681
989
|
if (entry.value !== void 0 && entry.value.length > 0) env[entry.name] = entry.value;
|
|
682
990
|
else if (request.id !== void 0) {
|
|
683
991
|
const hit = await this.ctx.credentials.resolve(secretRef(request.id, entry.name));
|
|
@@ -688,7 +996,8 @@ let McpManagerService = (() => {
|
|
|
688
996
|
// Test connection also supports OAuth: an OAuth-protected server
|
|
689
997
|
// opens the browser for authorization and the probe continues after
|
|
690
998
|
// tokens are stored (keyed by serverName, matching the mount path).
|
|
691
|
-
const
|
|
999
|
+
const globalEnv = await this.resolveGlobalEnv();
|
|
1000
|
+
const probe = await probeConnection(this.toClientConfig(request.server, env, globalEnv), {
|
|
692
1001
|
timeoutMs: this.probeTimeoutMs,
|
|
693
1002
|
authProvider: createOAuthProvider(request.server.serverName, this.ctx)
|
|
694
1003
|
});
|
package/lib/oauth.js
CHANGED
|
@@ -22,6 +22,9 @@ import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
|
22
22
|
/** Credential reference namespace prefix for per-server OAuth tokens. */
|
|
23
23
|
const OAUTH_REF_PREFIX = "DSH_MCP_OAUTH_";
|
|
24
24
|
|
|
25
|
+
/** Credential reference namespace prefix for per-server OAuth client info. */
|
|
26
|
+
const OAUTH_CLIENT_REF_PREFIX = "DSH_MCP_OAUTH_CLIENT_";
|
|
27
|
+
|
|
25
28
|
/** Short stable hash so sanitized server ids cannot collide. */
|
|
26
29
|
function shortHash(value) {
|
|
27
30
|
let hash = 0;
|
|
@@ -32,16 +35,24 @@ function shortHash(value) {
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
|
-
* One server's OAuth token credential reference.
|
|
36
|
-
*
|
|
37
38
|
* Credential ref names must match /^[A-Za-z_][A-Za-z0-9_]*$/, but managed
|
|
38
39
|
* server ids may contain hyphens, slashes, spaces, etc.; sanitize the id and
|
|
39
40
|
* append a short stable hash of the original so distinct ids cannot collide
|
|
40
41
|
* (e.g. "a-b" and "a_b").
|
|
41
42
|
*/
|
|
42
|
-
function
|
|
43
|
+
function oauthRef(prefix, serverId) {
|
|
43
44
|
const safe = serverId.replace(/[^A-Za-z0-9_]/g, "_");
|
|
44
|
-
return credentialRef(`${
|
|
45
|
+
return credentialRef(`${prefix}${safe}_${shortHash(serverId)}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One server's OAuth token credential reference. */
|
|
49
|
+
function oauthTokenRef(serverId) {
|
|
50
|
+
return oauthRef(OAUTH_REF_PREFIX, serverId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One server's registered OAuth client credential reference. */
|
|
54
|
+
function oauthClientRef(serverId) {
|
|
55
|
+
return oauthRef(OAUTH_CLIENT_REF_PREFIX, serverId);
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
/** Open the system browser to one URL, best effort. */
|
|
@@ -78,10 +89,40 @@ export function createOAuthProvider(serverId, ctx) {
|
|
|
78
89
|
redirect_uris: [redirectUrl],
|
|
79
90
|
},
|
|
80
91
|
async clientInformation() {
|
|
81
|
-
return savedClientInfo;
|
|
92
|
+
if (savedClientInfo !== null) return savedClientInfo;
|
|
93
|
+
const hit = await ctx.credentials.resolve(oauthClientRef(serverId));
|
|
94
|
+
if (hit === undefined) return undefined;
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(hit.value);
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
82
100
|
},
|
|
83
101
|
async saveClientInformation(info) {
|
|
84
102
|
savedClientInfo = info;
|
|
103
|
+
// Persist the registered client (client_id) with the tokens so a later
|
|
104
|
+
// token refresh uses the same client. Without this, every process
|
|
105
|
+
// re-registers a fresh client and the server rejects refresh with
|
|
106
|
+
// "client_id mismatch".
|
|
107
|
+
if (info === undefined || info === null) {
|
|
108
|
+
await ctx.credentials.unset(oauthClientRef(serverId));
|
|
109
|
+
} else {
|
|
110
|
+
await ctx.credentials.set(oauthClientRef(serverId), JSON.stringify(info));
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
async invalidateCredentials(credentialType) {
|
|
114
|
+
// The SDK calls this when a refresh/registration fails with an
|
|
115
|
+
// unrecoverable error (InvalidClient / UnauthorizedClient / InvalidGrant)
|
|
116
|
+
// so the next attempt starts a fresh authorization flow. Clearing the
|
|
117
|
+
// stored tokens is what lets that flow actually open the browser again.
|
|
118
|
+
if (credentialType === undefined || credentialType === "all" || credentialType === "tokens") {
|
|
119
|
+
await ctx.credentials.unset(oauthTokenRef(serverId));
|
|
120
|
+
}
|
|
121
|
+
if (credentialType === undefined || credentialType === "all") {
|
|
122
|
+
savedClientInfo = null;
|
|
123
|
+
await ctx.credentials.unset(oauthClientRef(serverId));
|
|
124
|
+
}
|
|
125
|
+
log.info(`${tag}: credentials invalidated (${String(credentialType)})`);
|
|
85
126
|
},
|
|
86
127
|
async tokens() {
|
|
87
128
|
const hit = await ctx.credentials.resolve(oauthTokenRef(serverId));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "MCP 管理界面 + tool search:可视化配置/启停/刷新 MCP 服务器与工具勾选,按需检索(tool search)热注入;工具列表稳定命中缓存、不撑爆上下文。DeepSeek Harness plugin: MCP management UI + tool search — stable tool list, cache-friendly, no context bloat.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|