dsh-agent-plugins-market 0.5.2 → 0.6.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.
Files changed (86) hide show
  1. package/README.md +29 -3
  2. package/README.zh.md +29 -3
  3. package/client/client.js +1388 -204
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +537 -71
  6. package/lib/application/details.js +39 -12
  7. package/lib/catalog/archive.js +341 -0
  8. package/lib/catalog/git.js +85 -12
  9. package/lib/catalog/legacy-root-migration.js +51 -0
  10. package/lib/catalog/lsp-spec.js +87 -0
  11. package/lib/catalog/manifests.js +84 -32
  12. package/lib/catalog/paths.js +65 -7
  13. package/lib/catalog/scan-pipeline.js +50 -0
  14. package/lib/catalog/scan-resolvers.js +402 -0
  15. package/lib/catalog/source-catalog.js +22 -8
  16. package/lib/catalog/suite-scanner.js +5 -176
  17. package/lib/catalog/surfaces.js +80 -30
  18. package/lib/contracts/lsp-status.js +2 -0
  19. package/lib/contracts/market.js +8 -1
  20. package/lib/index.js +118 -13
  21. package/lib/model/state.js +14 -1
  22. package/lib/model/types.js +21 -2
  23. package/lib/routes.js +88 -6
  24. package/lib/runtime/commands-mounts.js +6 -2
  25. package/lib/runtime/hooks-mounts.js +9 -6
  26. package/lib/runtime/lsp-direct-config.js +44 -0
  27. package/lib/runtime/lsp-mounts.js +236 -0
  28. package/lib/runtime/lsp-status.js +96 -0
  29. package/lib/runtime/mcp-backend.js +60 -0
  30. package/lib/runtime/mcp-client/bridge.js +126 -0
  31. package/lib/runtime/mcp-client/config.js +42 -0
  32. package/lib/runtime/mcp-client/connection.js +380 -0
  33. package/lib/runtime/mcp-client/host-seams.js +88 -0
  34. package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
  35. package/lib/runtime/mcp-client/oauth.js +379 -0
  36. package/lib/runtime/mcp-client/tools.js +432 -0
  37. package/lib/runtime/mcp-client/transport.js +103 -0
  38. package/lib/runtime/mcp-config.js +196 -54
  39. package/lib/runtime/mcp-credentials.js +20 -0
  40. package/lib/runtime/mcp-mounts.js +237 -27
  41. package/lib/runtime/mcp-overrides.js +64 -6
  42. package/lib/runtime/mcp-redaction.js +92 -0
  43. package/lib/runtime/mcp-status.js +76 -32
  44. package/lib/runtime/reconciler.js +35 -1
  45. package/lib/runtime/regions.js +56 -0
  46. package/lib/types/application/catalog.d.ts +191 -14
  47. package/lib/types/application/details.d.ts +1 -6
  48. package/lib/types/application/queries.d.ts +28 -1
  49. package/lib/types/catalog/archive.d.ts +48 -0
  50. package/lib/types/catalog/git.d.ts +27 -3
  51. package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
  52. package/lib/types/catalog/lsp-spec.d.ts +14 -0
  53. package/lib/types/catalog/manifests.d.ts +16 -1
  54. package/lib/types/catalog/paths.d.ts +23 -2
  55. package/lib/types/catalog/scan-pipeline.d.ts +76 -0
  56. package/lib/types/catalog/scan-resolvers.d.ts +81 -0
  57. package/lib/types/catalog/source-catalog.d.ts +5 -0
  58. package/lib/types/catalog/suite-scanner.d.ts +14 -7
  59. package/lib/types/catalog/surfaces.d.ts +3 -3
  60. package/lib/types/contracts/lsp-status.d.ts +53 -0
  61. package/lib/types/contracts/market.d.ts +55 -1
  62. package/lib/types/contracts/mcp-status.d.ts +14 -2
  63. package/lib/types/index.d.ts +24 -8
  64. package/lib/types/model/types.d.ts +52 -3
  65. package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
  66. package/lib/types/runtime/lsp-mounts.d.ts +89 -0
  67. package/lib/types/runtime/lsp-status.d.ts +30 -0
  68. package/lib/types/runtime/mcp-backend.d.ts +49 -0
  69. package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
  70. package/lib/types/runtime/mcp-client/config.d.ts +123 -0
  71. package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
  72. package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
  73. package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
  74. package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
  75. package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
  76. package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
  77. package/lib/types/runtime/mcp-config.d.ts +29 -12
  78. package/lib/types/runtime/mcp-credentials.d.ts +9 -0
  79. package/lib/types/runtime/mcp-mounts.d.ts +56 -6
  80. package/lib/types/runtime/mcp-overrides.d.ts +23 -4
  81. package/lib/types/runtime/mcp-redaction.d.ts +7 -0
  82. package/lib/types/runtime/mcp-status.d.ts +4 -2
  83. package/lib/types/runtime/reconciler.d.ts +22 -4
  84. package/lib/types/runtime/regions.d.ts +37 -0
  85. package/package.json +15 -11
  86. package/schemas/1.0.0/mcp.schema.json +22 -0
@@ -1,29 +1,37 @@
1
1
  /**
2
- * Maps one validated suite `mcp.json` onto `dsh-mcp-client` config rows.
2
+ * Maps one validated suite `mcp.json` onto self-built bridge config rows.
3
3
  *
4
4
  * The portable format is translated, not executed directly: stdio commands
5
5
  * resolve against the suite root (spec §7.2.1), `${PLUGIN_ROOT}` /
6
6
  * `${PLUGIN_DATA}` expand against the suite root and its data directory, and
7
- * `${NAME}` expands from the process environment (documented extension over
8
- * the portable format, matching how hook commands already consume ambient
9
- * env). Legacy HTTP+SSE servers have no transport in the dsh MCP client and
10
- * are skipped with a per-server reason.
7
+ * every `${NAME}` — including ones inside a streamable-http `url` — expands
8
+ * through the optional DSH credentials seam at mount time. Legacy HTTP+SSE
9
+ * servers are supported by the market's own bridge (the host client had no
10
+ * such transport); unrecognized shapes are still skipped with a per-server
11
+ * reason.
11
12
  */
12
13
  import { createHash } from 'node:crypto';
13
- import { expandPlaceholders, resolveCwd } from '../catalog/validate.js';
14
+ import { DEFAULT_TOOL_CALL_TIMEOUT_MS } from './mcp-client/config.js';
15
+ import { resolveCwd } from '../catalog/validate.js';
16
+ import { qualifiedSuiteId } from '../catalog/paths.js';
14
17
  import { applyOverride } from './mcp-overrides.js';
15
- /** The max length `dsh-mcp-client` accepts for a serverName. */
18
+ /** The max length the bridge accepts for a serverName. */
16
19
  const SERVER_NAME_MAX = 32;
20
+ const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
21
+ const BUILTIN_PLACEHOLDERS = new Set(['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA']);
17
22
  /**
18
- * Build one mount request per supported mcp.json server.
23
+ * Build one mount request per supported mcp.json server, resolving every
24
+ * `${NAME}` through the credential resolver before a child process or HTTP
25
+ * request is created. Missing references fail closed per server instead of
26
+ * becoming empty strings.
27
+ *
19
28
  * @param overrides user-owned per-server overrides (url/headers/env/args
20
- * replacement plus enable/disable); applied after source expansion, before
21
- * mount. Disabled servers are omitted from the result entirely.
22
- * @returns mount requests plus per-server failures (unsupported transport,
23
- * invalid server key, or derived serverName collision candidates are
24
- * checked by the mount registry, not here).
29
+ * replacement plus enable/disable); applied before mount. Disabled servers
30
+ * are omitted entirely.
31
+ * @returns mount requests plus per-server failures. serverName collisions are
32
+ * reported by the mount registry, not here.
25
33
  */
26
- export function toMcpMounts(suite, pluginDataRoot, overrides = {}) {
34
+ export async function toMcpMounts(suite, pluginDataRoot, overrides = {}, resolver = { resolve: async () => undefined }) {
27
35
  if (suite.mcp === undefined)
28
36
  return { mounts: [], failures: [] };
29
37
  const mounts = [];
@@ -32,57 +40,191 @@ export function toMcpMounts(suite, pluginDataRoot, overrides = {}) {
32
40
  const override = overrides[serverKey];
33
41
  if (override?.enabled === false)
34
42
  continue;
35
- const request = toMount(suite, serverKey, applyOverride(source, override), pluginDataRoot);
36
- if (request === undefined) {
37
- failures.push({ serverKey, reason: transportReason(source) });
43
+ const server = applyOverride(source, override);
44
+ try {
45
+ const result = await toResolvedMount(suite, serverKey, server, pluginDataRoot, resolver);
46
+ if (result.failure !== undefined)
47
+ failures.push(result.failure);
48
+ if (result.request !== undefined)
49
+ mounts.push(result.request);
38
50
  }
39
- else {
40
- mounts.push(request);
51
+ catch {
52
+ failures.push({ serverKey, code: 'credential-error', credentialRefs: credentialRefsInServer(server), reason: 'credential lookup failed' });
41
53
  }
42
54
  }
43
55
  return { mounts, failures };
44
56
  }
45
- function toMount(suite, serverKey, server, pluginDataRoot) {
46
- const serverName = deriveServerName(suite.id, serverKey);
47
- if (server.type === 'stdio')
48
- return { suiteId: suite.id, serverKey, config: toStdio(serverName, suite, server, pluginDataRoot) };
49
- if (server.type === 'streamable-http')
50
- return { suiteId: suite.id, serverKey, config: toHttp(serverName, suite, server, pluginDataRoot) };
51
- return undefined;
57
+ /** Find external credential references used by one MCP server definition. */
58
+ export function credentialRefsInServer(server) {
59
+ const values = [];
60
+ if (server.type === 'stdio') {
61
+ values.push(...(server.args ?? []), ...(server.cwd === undefined ? [] : [server.cwd]), ...Object.values(server.env ?? {}));
62
+ }
63
+ else {
64
+ // A remote endpoint may carry the token in the URL query as well as in a
65
+ // header, so both are scanned and both are resolved at mount time.
66
+ values.push(server.url ?? '', ...Object.values(server.headers ?? {}));
67
+ }
68
+ const refs = new Set();
69
+ for (const value of values) {
70
+ for (const match of value.matchAll(PLACEHOLDER)) {
71
+ const name = match[1];
72
+ if (name !== undefined && !BUILTIN_PLACEHOLDERS.has(name))
73
+ refs.add(name);
74
+ }
75
+ }
76
+ return [...refs].sort();
77
+ }
78
+ async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolver) {
79
+ if (server.type === 'sse') {
80
+ // The market bridge supports the legacy HTTP+SSE transport natively.
81
+ const expand = expander(suite, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id)), resolver);
82
+ const url = await expand.one(server.url);
83
+ const headers = await expand.map(server.headers ?? {});
84
+ const missing = unique([...url.missing, ...headers.missing]);
85
+ if (missing.length > 0)
86
+ return { failure: missingFailure(serverKey, missing) };
87
+ const sseConfig = {
88
+ transport: 'sse',
89
+ serverName: deriveServerName(qualifiedSuiteId(suite.sourceId, suite.id), serverKey),
90
+ url: url.value,
91
+ headers: headers.values,
92
+ ...(server.auth === undefined ? {} : { auth: mapAuth(server.auth) }),
93
+ toolCallTimeoutMs: DEFAULT_TOOL_CALL_TIMEOUT_MS,
94
+ failOnStartupError: true
95
+ };
96
+ return { request: { suiteId: qualifiedSuiteId(suite.sourceId, suite.id), serverKey, config: sseConfig } };
97
+ }
98
+ const expand = expander(suite, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id)), resolver);
99
+ const serverName = deriveServerName(qualifiedSuiteId(suite.sourceId, suite.id), serverKey);
100
+ if (server.type === 'stdio') {
101
+ const args = await expand.all(server.args ?? []);
102
+ const env = await expand.map(server.env ?? {});
103
+ const cwd = server.cwd === undefined ? { value: suite.root, missing: [] } : await expand.one(server.cwd);
104
+ const missing = unique([...args.missing, ...env.missing, ...cwd.missing]);
105
+ if (missing.length > 0)
106
+ return { failure: missingFailure(serverKey, missing) };
107
+ return {
108
+ request: {
109
+ suiteId: qualifiedSuiteId(suite.sourceId, suite.id),
110
+ serverKey,
111
+ config: {
112
+ transport: 'stdio',
113
+ serverName,
114
+ command: server.command.startsWith('./') ? joinInside(suite.root, server.command.slice(2)) : server.command,
115
+ args: args.values,
116
+ env: env.values,
117
+ cwd: resolveCwd(cwd.value, suite.root, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id))),
118
+ toolCallTimeoutMs: DEFAULT_TOOL_CALL_TIMEOUT_MS,
119
+ failOnStartupError: true
120
+ }
121
+ }
122
+ };
123
+ }
124
+ const url = await expand.one(server.url);
125
+ const headers = await expand.map(server.headers ?? {});
126
+ const missing = unique([...url.missing, ...headers.missing]);
127
+ if (missing.length > 0)
128
+ return { failure: missingFailure(serverKey, missing) };
129
+ return {
130
+ request: {
131
+ suiteId: qualifiedSuiteId(suite.sourceId, suite.id),
132
+ serverKey,
133
+ config: {
134
+ transport: 'streamable-http',
135
+ serverName,
136
+ url: url.value,
137
+ headers: headers.values,
138
+ ...(server.auth === undefined ? {} : { auth: mapAuth(server.auth) }),
139
+ toolCallTimeoutMs: DEFAULT_TOOL_CALL_TIMEOUT_MS,
140
+ failOnStartupError: true
141
+ }
142
+ }
143
+ };
144
+ }
145
+ /**
146
+ * Map the suite format's auth declaration onto the bridge's OAuth config.
147
+ * `enabled: false` must survive the mapping — it is the explicit opt-out —
148
+ * while `enabled: true` (or a bare declaration) requests the default-on
149
+ * flow, optionally with a scope.
150
+ */
151
+ function mapAuth(auth) {
152
+ return {
153
+ enabled: auth.enabled !== false,
154
+ ...(auth.scope === undefined ? {} : { scope: auth.scope })
155
+ };
52
156
  }
53
- function toStdio(serverName, suite, server, pluginDataRoot) {
54
- const command = server.command.startsWith('./') ? joinInside(suite.root, server.command.slice(2)) : server.command;
55
- const pluginData = joinInside(pluginDataRoot, suite.id);
56
- const args = (server.args ?? []).map(arg => expandPlaceholders(arg, suite.root, pluginData));
57
- const env = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expandPlaceholders(value, suite.root, pluginData)]));
58
- const cwd = server.cwd === undefined ? suite.root : resolveCwd(expandPlaceholders(server.cwd, suite.root, pluginData), suite.root, pluginData);
157
+ /** Per-mount expansion context; credential lookups are memoized per call. */
158
+ function expander(suite, pluginData, resolver) {
159
+ // Promise-valued cache: concurrent expansions of the same reference share
160
+ // one in-flight lookup, so a config using a token twice resolves it once.
161
+ const inflight = new Map();
162
+ const lookup = (name) => {
163
+ const pending = inflight.get(name);
164
+ if (pending !== undefined)
165
+ return pending;
166
+ const created = Promise.resolve(resolver.resolve(name));
167
+ inflight.set(name, created);
168
+ return created;
169
+ };
170
+ const one = async (value) => {
171
+ let cursor = 0;
172
+ let output = '';
173
+ const missing = [];
174
+ for (const match of value.matchAll(PLACEHOLDER)) {
175
+ const index = match.index ?? 0;
176
+ const name = match[1];
177
+ if (name === undefined)
178
+ continue;
179
+ output += value.slice(cursor, index);
180
+ const fallback = match[2];
181
+ let replacement;
182
+ if (name === 'PLUGIN_ROOT' || name === 'CLAUDE_PLUGIN_ROOT')
183
+ replacement = suite.root;
184
+ else if (name === 'PLUGIN_DATA' || name === 'CLAUDE_PLUGIN_DATA')
185
+ replacement = pluginData;
186
+ else
187
+ replacement = (await lookup(name))?.value;
188
+ if (replacement === undefined || replacement === '') {
189
+ if (fallback !== undefined && fallback !== '')
190
+ replacement = fallback;
191
+ else {
192
+ missing.push(name);
193
+ replacement = '';
194
+ }
195
+ }
196
+ output += replacement;
197
+ cursor = index + match[0].length;
198
+ }
199
+ output += value.slice(cursor);
200
+ return { value: output, missing };
201
+ };
59
202
  return {
60
- transport: 'stdio',
61
- serverName,
62
- command,
63
- args,
64
- env,
65
- cwd,
66
- toolCallTimeoutMs: 60_000,
67
- failOnStartupError: false
203
+ one,
204
+ async all(values) {
205
+ const expanded = await Promise.all(values.map(one));
206
+ return { values: expanded.map(item => item.value), missing: expanded.flatMap(item => item.missing) };
207
+ },
208
+ async map(values) {
209
+ const entries = await Promise.all(Object.entries(values).map(async ([key, value]) => [key, await one(value)]));
210
+ return {
211
+ values: Object.fromEntries(entries.map(([key, result]) => [key, result.value])),
212
+ missing: entries.flatMap(([, result]) => result.missing)
213
+ };
214
+ }
68
215
  };
69
216
  }
70
- function toHttp(serverName, suite, server, pluginDataRoot) {
71
- const pluginData = joinInside(pluginDataRoot, suite.id);
72
- const headers = Object.fromEntries(Object.entries(server.headers ?? {}).map(([key, value]) => [key, expandPlaceholders(value, suite.root, pluginData)]));
217
+ function missingFailure(serverKey, refs) {
218
+ const uniqueRefs = unique(refs);
73
219
  return {
74
- transport: 'streamable-http',
75
- serverName,
76
- url: server.url,
77
- headers,
78
- toolCallTimeoutMs: 60_000,
79
- failOnStartupError: false
220
+ serverKey,
221
+ code: 'missing-credential',
222
+ credentialRefs: uniqueRefs,
223
+ reason: uniqueRefs.map(ref => `missing credential reference ${ref}`).join('; ')
80
224
  };
81
225
  }
82
- function transportReason(server) {
83
- if (server.type === 'sse')
84
- return 'legacy HTTP+SSE transport is not supported by the dsh MCP client';
85
- return 'unsupported mcp.json server shape';
226
+ function unique(values) {
227
+ return [...new Set(values)].sort();
86
228
  }
87
229
  function joinInside(root, segment) {
88
230
  return `${root.replace(/[\\/]$/, '')}/${segment}`;
@@ -96,7 +238,7 @@ function sanitizeToken(raw) {
96
238
  return cleaned === '' ? 'server' : cleaned;
97
239
  }
98
240
  /**
99
- * Derive a stable, unique-ish `dsh-mcp-client` serverName from the suite and
241
+ * Derive a stable, unique-ish bridge serverName from the suite and
100
242
  * server ids: `${suiteId}__${serverKey}` sanitized, truncated to 32 chars
101
243
  * with a deterministic 12-hex suffix when the join exceeds the budget (the
102
244
  * same deterministic-hash policy the MCP client uses for long tool names).
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Build the resolver used at mount time. Standard DSH profiles provide
3
+ * `ctx.credentials`; minimal profiles retain the historical process-env
4
+ * fallback so MCP mounting remains backwards compatible.
5
+ */
6
+ export function mcpCredentialResolver(ctx) {
7
+ const host = ctx;
8
+ const service = host.get?.('credentials');
9
+ if (typeof service?.resolve === 'function') {
10
+ return {
11
+ resolve: ref => service.resolve(ref)
12
+ };
13
+ }
14
+ return {
15
+ async resolve(ref) {
16
+ const value = process.env[ref];
17
+ return value === undefined || value === '' ? undefined : { value, source: 'env' };
18
+ }
19
+ };
20
+ }
@@ -1,12 +1,41 @@
1
- import { toMcpMounts } from './mcp-config.js';
1
+ import { createHash } from 'node:crypto';
2
+ import * as mcpBridge from './mcp-client/bridge.js';
3
+ import { applyOverride } from './mcp-overrides.js';
4
+ import { credentialRefsInServer, toMcpMounts } from './mcp-config.js';
5
+ import { mcpCredentialResolver } from './mcp-credentials.js';
6
+ import { qualifiedSuiteId } from '../catalog/paths.js';
7
+ /**
8
+ * Bounded retry schedule for a failed mount/unmount. A crash-looping or
9
+ * permanently broken server must eventually stop consuming attempt budget, so
10
+ * the schedule is capped and resets with every reconcile pass.
11
+ */
12
+ const RETRY_SCHEDULE_MS = [1_500, 5_000, 15_000, 45_000, 120_000];
13
+ const MAX_RETRY_ATTEMPTS = RETRY_SCHEDULE_MS.length;
2
14
  export class McpMountRegistry {
3
15
  ctx;
4
16
  pluginDataRoot;
5
17
  live = new Map();
6
18
  names = new Map();
19
+ credentialRefs = new Set();
7
20
  overridesProvider = async () => new Map();
21
+ /** Live model-facing tool names, for foreign-namespace detection before a mount. */
22
+ toolNamesProvider = () => [];
23
+ /** The active MCP mount backend ('builtin' bridge or host client compat mode). */
24
+ backendProvider = async () => 'builtin';
8
25
  /** Serialize mount and unmount passes so a disable cannot race an in-flight spawn. */
9
26
  reconcileQueue = Promise.resolve();
27
+ /** Pending retry timers keyed by mount key, so a teardown can cancel them. */
28
+ retries = new Map();
29
+ /** Attempt count per mount key; reset whenever a retry succeeds. */
30
+ attempts = new Map();
31
+ /**
32
+ * Mount keys flagged for an explicit rebuild (re-authorize): the next
33
+ * reconcile must tear down and remount them even when the resolved config
34
+ * fingerprint is unchanged — dropping a grant changes no config field.
35
+ */
36
+ forcedRemounts = new Set();
37
+ /** Snapshot of the last reconciled suite set, replayed by retry passes. */
38
+ lastEnabled = [];
10
39
  constructor(ctx, pluginDataRoot) {
11
40
  this.ctx = ctx;
12
41
  this.pluginDataRoot = pluginDataRoot;
@@ -15,6 +44,41 @@ export class McpMountRegistry {
15
44
  setOverridesProvider(provider) {
16
45
  this.overridesProvider = provider;
17
46
  }
47
+ /**
48
+ * Install the live model-facing tool-name provider (the host registry
49
+ * snapshot) used to detect foreign `mcp__<serverName>__` namespaces before
50
+ * a mount is attempted.
51
+ */
52
+ setToolNamesProvider(provider) {
53
+ this.toolNamesProvider = provider;
54
+ }
55
+ /** Install the backend provider deciding which client mounts each server. */
56
+ setBackendProvider(provider) {
57
+ this.backendProvider = provider;
58
+ }
59
+ /** Whether the last catalog snapshot uses one credential reference. */
60
+ usesCredential(ref) {
61
+ return this.credentialRefs.has(ref);
62
+ }
63
+ /**
64
+ * Flag one mount for an explicit rebuild on the next reconcile: used by
65
+ * re-authorize, which deletes a grant without changing any resolved config
66
+ * field, so a fingerprint-only reconcile would keep the live bridge (and
67
+ * its in-memory tokens) untouched.
68
+ */
69
+ forceRemount(suiteId, serverKey) {
70
+ this.forcedRemounts.add(mountKey(suiteId, serverKey));
71
+ }
72
+ /** The mount key owning one derived serverName; undefined when not mounted here. */
73
+ serverOwner(serverName) {
74
+ const owner = this.names.get(serverName);
75
+ if (owner === undefined)
76
+ return undefined;
77
+ // Values are `${suiteId}\u0000${serverKey}` — suite ids may contain a
78
+ // slash (qualified ids), so the separator is the NUL byte, not '/'.
79
+ const separator = owner.indexOf('\u0000');
80
+ return { suiteId: owner.slice(0, separator), serverKey: owner.slice(separator + 1) };
81
+ }
18
82
  /** Queue one reconciliation behind any in-flight mount/unmount pass. */
19
83
  async reconcile(enabledSuites) {
20
84
  const run = this.reconcileQueue.then(() => this.reconcileNow(enabledSuites));
@@ -23,14 +87,32 @@ export class McpMountRegistry {
23
87
  }
24
88
  /** Mount/unmount MCP servers to match the enabled suites exactly. */
25
89
  async reconcileNow(enabledSuites) {
90
+ this.lastEnabled = [...enabledSuites];
26
91
  const active = enabledSuites.filter(suite => suite.activeSurfaces?.mcp !== false);
27
92
  const overrides = await this.overridesProvider();
93
+ const resolver = mcpCredentialResolver(this.ctx);
28
94
  const wanted = new Map();
29
95
  const diagnostics = [];
96
+ this.credentialRefs.clear();
30
97
  for (const suite of active) {
31
- const { mounts, failures } = toMcpMounts(suite, this.pluginDataRoot, overrides.get(suite.id));
98
+ const suiteOverrides = overrides.get(qualifiedSuiteId(suite.sourceId, suite.id));
99
+ for (const [serverKey, source] of Object.entries(suite.mcp?.servers ?? {})) {
100
+ const override = suiteOverrides?.[serverKey];
101
+ if (override?.enabled === false)
102
+ continue;
103
+ const effective = applyOverride(source, override);
104
+ for (const ref of credentialRefsInServer(effective))
105
+ this.credentialRefs.add(ref);
106
+ }
107
+ const { mounts, failures } = await toMcpMounts(suite, this.pluginDataRoot, suiteOverrides, resolver);
32
108
  for (const failure of failures) {
33
- diagnostics.push({ suiteId: suite.id, serverKey: failure.serverKey, reason: failure.reason });
109
+ diagnostics.push({
110
+ suiteId: suite.id,
111
+ serverKey: failure.serverKey,
112
+ reason: failure.reason,
113
+ ...(failure.code === undefined ? {} : { code: failure.code }),
114
+ ...(failure.credentialRefs === undefined ? {} : { credentialRefs: failure.credentialRefs })
115
+ });
34
116
  }
35
117
  for (const mount of mounts) {
36
118
  wanted.set(mountKey(mount.suiteId, mount.serverKey), { suite, serverKey: mount.serverKey, request: mount });
@@ -38,19 +120,80 @@ export class McpMountRegistry {
38
120
  }
39
121
  for (const [key, live] of [...this.live]) {
40
122
  if (!wanted.has(key)) {
41
- await this.unmount(key, live);
42
- diagnostics.push({ suiteId: live.suiteId, serverKey: live.serverKey, reason: 'unmounted' });
123
+ const reason = await this.unmount(key, live);
124
+ diagnostics.push({
125
+ suiteId: live.suiteId,
126
+ serverKey: live.serverKey,
127
+ reason: reason ?? 'unmounted',
128
+ ...(reason === undefined ? {} : { code: 'unmount-failed' })
129
+ });
43
130
  }
44
131
  }
45
132
  for (const [key, entry] of wanted) {
46
- if (this.live.has(key))
133
+ const live = this.live.get(key);
134
+ // A forced remount (re-authorize) skips the fingerprint match on
135
+ // purpose: dropping a grant does not change the resolved config, so
136
+ // only the explicit instruction tears the live bridge down and lets
137
+ // the server's 401 restart the browser authorization.
138
+ if (live !== undefined && !this.forcedRemounts.has(key) && live.configFingerprint === fingerprintOf(entry.request))
47
139
  continue;
48
- const reason = await this.mountWith(entry.request);
49
- if (reason !== undefined)
50
- diagnostics.push({ suiteId: entry.suite.id, serverKey: entry.serverKey, reason });
140
+ this.forcedRemounts.delete(key);
141
+ // Either a fresh mount, or a remount because the resolved config moved
142
+ // (a credential rotation rewrites the env/header/url values while the
143
+ // suite stays enabled) — keeping the old mount would keep serving the
144
+ // old token until disable or restart.
145
+ if (live !== undefined) {
146
+ const reason = await this.unmount(key, live);
147
+ if (reason !== undefined) {
148
+ diagnostics.push({ suiteId: live.suiteId, serverKey: live.serverKey, reason, code: 'unmount-failed' });
149
+ continue;
150
+ }
151
+ }
152
+ // A retry attempt runs the same mount path; only the last failure is
153
+ // reported so a transient error does not shadow the final state.
154
+ const failure = await this.mountWith(entry.request);
155
+ if (failure !== undefined) {
156
+ diagnostics.push({ suiteId: entry.suite.id, serverKey: entry.serverKey, reason: failure.reason, code: failure.code });
157
+ // A foreign-namespace skip is deterministic, not transient: retrying
158
+ // it just burns the attempt budget and log lines. The next full
159
+ // reconcile re-checks it anyway, so the self-heal path is intact.
160
+ this.scheduleRetry(key, entry.suite.id, entry.serverKey, failure.code === 'foreign-mount' ? undefined : failure.reason);
161
+ }
51
162
  }
52
163
  return diagnostics.filter(diagnostic => diagnostic.reason !== 'unmounted');
53
164
  }
165
+ /**
166
+ * Schedule a delayed re-attempt for a mount that still is not live.
167
+ *
168
+ * Mounts are retried because a remote endpoint or a credential may become
169
+ * available slightly after the suite is enabled. The schedule is bounded, and
170
+ * a server that keeps failing simply stops retrying until the next reconcile.
171
+ */
172
+ scheduleRetry(key, suiteId, serverKey, reason) {
173
+ const pending = this.retries.get(key);
174
+ if (pending !== undefined)
175
+ clearTimeout(pending);
176
+ this.retries.delete(key);
177
+ if (reason === undefined) {
178
+ this.attempts.delete(key);
179
+ return;
180
+ }
181
+ const attempt = (this.attempts.get(key) ?? 0) + 1;
182
+ this.attempts.set(key, attempt);
183
+ if (attempt > MAX_RETRY_ATTEMPTS) {
184
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${suiteId}/${serverKey}: giving up after ${MAX_RETRY_ATTEMPTS} attempts — ${reason}`);
185
+ return;
186
+ }
187
+ const delay = RETRY_SCHEDULE_MS[attempt - 1] ?? RETRY_SCHEDULE_MS[RETRY_SCHEDULE_MS.length - 1];
188
+ const timer = setTimeout(() => {
189
+ this.retries.delete(key);
190
+ // Re-run against the last known suite set: a retry must not resurrect a
191
+ // server that has since been disabled or uninstalled.
192
+ void this.reconcile(this.lastEnabled).catch(() => { });
193
+ }, delay);
194
+ timer.unref?.();
195
+ this.retries.set(key, timer);
196
+ }
54
197
  /** Dispose every live mount after queued reconciliation passes settle. */
55
198
  async disposeAll() {
56
199
  const run = this.reconcileQueue.then(async () => {
@@ -60,45 +203,112 @@ export class McpMountRegistry {
60
203
  });
61
204
  this.reconcileQueue = run.then(() => undefined, () => undefined);
62
205
  await run;
206
+ for (const timer of this.retries.values())
207
+ clearTimeout(timer);
208
+ this.retries.clear();
209
+ this.attempts.clear();
210
+ this.credentialRefs.clear();
63
211
  }
64
212
  /** Mount one precomputed request (source config merged with overrides). */
65
213
  async mountWith(request) {
66
214
  const owner = this.names.get(request.config.serverName);
67
- if (owner !== undefined)
68
- return `derived serverName "${request.config.serverName}" already mounted by ${owner}`;
69
- let mcpClient;
70
- try {
71
- mcpClient = await import('@deepseek-ai/dsh-mcp-client');
215
+ if (owner !== undefined) {
216
+ return { reason: `derived serverName "${request.config.serverName}" already mounted by ${owner}`, code: 'mount-failed' };
72
217
  }
73
- catch {
74
- return 'the @deepseek-ai/dsh-mcp-client package is not installed in this profile';
218
+ // Foreign-namespace guard: a native host MCP client (or another plugin's
219
+ // mount) may already own this `mcp__<serverName>__` namespace. Registering
220
+ // into it would fail loudly mid-mount; skipping here reports the conflict
221
+ // as a clear per-server diagnostic instead, and a later reconcile mounts
222
+ // this server if the foreign owner goes away. The matching names ride the
223
+ // diagnostic — a leftover of this plugin's own earlier mount (registry
224
+ // record lost without a teardown) reads as an orphan here, and a Host
225
+ // restart clears it; a genuinely foreign owner keeps the skip sticky.
226
+ const prefix = `mcp__${request.config.serverName}__`;
227
+ const foreign = this.toolNamesProvider().filter(name => name.startsWith(prefix));
228
+ if (foreign.length > 0) {
229
+ return {
230
+ reason: `serverName "${request.config.serverName}" is already mounted by another MCP client (native config or another plugin; matching tools: ${foreign.slice(0, 3).join(', ')}${foreign.length > 3 ? `, +${foreign.length - 3} more` : ''}) — skipped to avoid a duplicate mount; restart the Host if these tools are a leftover`,
231
+ code: 'foreign-mount'
232
+ };
75
233
  }
234
+ // Backend dispatch: the built-in bridge connects stdio, Streamable HTTP
235
+ // (with OAuth), and legacy SSE servers in-process; the host backend
236
+ // mounts the host's own `dsh-mcp-client` for compatibility, at the cost
237
+ // of OAuth and SSE support.
76
238
  const mountCtx = this.ctx;
77
- if (typeof mountCtx.plugin !== 'function')
78
- return 'the host context does not support dynamic plugin mounting';
239
+ if (typeof mountCtx.plugin !== 'function') {
240
+ return { reason: 'the host context does not support dynamic plugin mounting', code: 'mount-failed' };
241
+ }
242
+ let pluginModule = mcpBridge;
243
+ if ((await this.backendProvider()) === 'host') {
244
+ if (request.config.transport === 'sse') {
245
+ return {
246
+ reason: 'the host dsh-mcp-client does not support the legacy SSE transport — switch the MCP backend back to the built-in client for this server',
247
+ code: 'mount-failed'
248
+ };
249
+ }
250
+ try {
251
+ pluginModule = await import('@deepseek-ai/dsh-mcp-client');
252
+ }
253
+ catch {
254
+ return {
255
+ reason: 'the @deepseek-ai/dsh-mcp-client package is not installed in this profile — switch the MCP backend back to the built-in client',
256
+ code: 'mount-failed'
257
+ };
258
+ }
259
+ }
260
+ let handle;
79
261
  try {
80
- const handle = mountCtx.plugin(mcpClient, request.config);
262
+ handle = mountCtx.plugin(pluginModule, request.config);
81
263
  await handle.await();
82
- const key = mountKey(request.suiteId, request.serverKey);
83
- this.live.set(key, { suiteId: request.suiteId, serverKey: request.serverKey, serverName: request.config.serverName, disposer: () => handle.dispose() });
84
- this.names.set(request.config.serverName, `${request.suiteId}/${request.serverKey}`);
85
- return undefined;
86
264
  }
87
265
  catch (error) {
88
- return `mount failed: ${error instanceof Error ? error.message : String(error)}`;
266
+ // The bridge mounts with failOnStartupError for suite servers, so a
267
+ // connection or initialization failure rejects here. Drop the
268
+ // half-mounted handle before reporting so no orphan child survives a
269
+ // failed startup.
270
+ if (handle !== undefined) {
271
+ try {
272
+ await handle.dispose();
273
+ }
274
+ catch {
275
+ // Ignore teardown errors: the startup failure is the real signal.
276
+ }
277
+ }
278
+ return { reason: `mount failed: ${error instanceof Error ? error.message : String(error)}`, code: 'mount-failed' };
89
279
  }
280
+ this.live.set(mountKey(request.suiteId, request.serverKey), {
281
+ suiteId: request.suiteId,
282
+ serverKey: request.serverKey,
283
+ serverName: request.config.serverName,
284
+ configFingerprint: fingerprintOf(request),
285
+ disposer: () => handle.dispose()
286
+ });
287
+ this.names.set(request.config.serverName, `${request.suiteId}\u0000${request.serverKey}`);
288
+ return undefined;
90
289
  }
91
290
  async unmount(key, live) {
92
- this.live.delete(key);
93
- this.names.delete(live.serverName);
94
291
  try {
95
292
  await live.disposer();
293
+ this.live.delete(key);
294
+ this.names.delete(live.serverName);
295
+ return undefined;
96
296
  }
97
297
  catch (error) {
98
- this.ctx.logger?.warn(`[dsh-agent-plugins-market] unmount ${live.suiteId}/${live.serverKey} failed: ${error instanceof Error ? error.message : String(error)}`);
298
+ const reason = `unmount failed: ${error instanceof Error ? error.message : String(error)}`;
299
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${reason} (${live.suiteId}/${live.serverKey})`);
300
+ return reason;
99
301
  }
100
302
  }
101
303
  }
102
304
  function mountKey(suiteId, serverKey) {
103
305
  return `${suiteId}\u0000${serverKey}`;
104
306
  }
307
+ /**
308
+ * Stable fingerprint of one resolved mount request: any change to the
309
+ * transport, url, headers, env, args, or auth shape produces a different
310
+ * fingerprint and forces a remount on the next reconcile pass.
311
+ */
312
+ function fingerprintOf(request) {
313
+ return createHash('sha256').update(JSON.stringify(request.config)).digest('hex');
314
+ }