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/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
- headers: Object.fromEntries(spec.headers.map((header) => [header.name, header.value])),
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
- await this.applyEnv(id, previous, request.env);
606
- const row = toServerRow(id, request.server, request.env);
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 probe = await probeConnection(this.toClientConfig(request.server, env), {
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 oauthTokenRef(serverId) {
43
+ function oauthRef(prefix, serverId) {
43
44
  const safe = serverId.replace(/[^A-Za-z0-9_]/g, "_");
44
- return credentialRef(`${OAUTH_REF_PREFIX}${safe}_${shortHash(serverId)}`);
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.0",
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",