dsh-agent-plugins-market 0.6.2 → 0.7.1

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 (155) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +28533 -1511
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +188 -0
  6. package/lib/application/catalog.js +229 -1109
  7. package/lib/application/details.js +19 -8
  8. package/lib/application/install-store.js +95 -0
  9. package/lib/application/lsp-service.js +97 -0
  10. package/lib/application/mcp-service.js +263 -0
  11. package/lib/application/panel-resources.js +43 -14
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +8 -2
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +35 -30
  17. package/lib/catalog/component-files.js +14 -6
  18. package/lib/catalog/fs-probes.js +43 -0
  19. package/lib/catalog/manifests.js +53 -92
  20. package/lib/catalog/native-project.js +2 -2
  21. package/lib/catalog/paths.js +74 -55
  22. package/lib/catalog/plugin-variables.js +38 -0
  23. package/lib/catalog/project-config.js +2 -1
  24. package/lib/catalog/project-hooks.js +23 -2
  25. package/lib/catalog/scan-resolvers.js +29 -45
  26. package/lib/catalog/skills-parse.js +61 -10
  27. package/lib/catalog/source-catalog.js +10 -4
  28. package/lib/catalog/suite-components.js +7 -7
  29. package/lib/catalog/suite-scanner.js +3 -1
  30. package/lib/catalog/surfaces.js +5 -26
  31. package/lib/catalog/validate.js +7 -11
  32. package/lib/contracts/market.js +2 -0
  33. package/lib/contracts/settings.js +63 -0
  34. package/lib/index.js +115 -217
  35. package/lib/model/layouts.js +10 -1
  36. package/lib/model/types.js +26 -3
  37. package/lib/routes.js +57 -15
  38. package/lib/runtime/agent-role-names.js +70 -0
  39. package/lib/runtime/agent-role-router.js +142 -106
  40. package/lib/runtime/commands-mounts.js +55 -11
  41. package/lib/runtime/deadline.js +54 -0
  42. package/lib/runtime/dynamic-context.js +140 -0
  43. package/lib/runtime/feedback-tool.js +101 -46
  44. package/lib/runtime/hooks-mounts.js +9 -11
  45. package/lib/runtime/host-locale.js +49 -24
  46. package/lib/runtime/lsp-direct-config.js +19 -19
  47. package/lib/runtime/lsp-mounts.js +182 -70
  48. package/lib/runtime/lsp-server-state.js +4 -4
  49. package/lib/runtime/lsp-status.js +1 -1
  50. package/lib/runtime/mcp-auth-record.js +42 -0
  51. package/lib/runtime/mcp-backend.js +14 -14
  52. package/lib/runtime/mcp-client/config.js +8 -1
  53. package/lib/runtime/mcp-client/connection.js +38 -43
  54. package/lib/runtime/mcp-client/host-contract.js +10 -0
  55. package/lib/runtime/mcp-client/host-seams.js +8 -88
  56. package/lib/runtime/mcp-client/oauth.js +5 -7
  57. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  58. package/lib/runtime/mcp-client/projection.js +202 -0
  59. package/lib/runtime/mcp-client/tools.js +4 -189
  60. package/lib/runtime/mcp-config.js +36 -14
  61. package/lib/runtime/mcp-direct-config.js +33 -16
  62. package/lib/runtime/mcp-mounts.js +35 -75
  63. package/lib/runtime/mcp-overrides.js +5 -5
  64. package/lib/runtime/mcp-redaction.js +3 -2
  65. package/lib/runtime/mcp-status.js +7 -8
  66. package/lib/runtime/mount-lifecycle.js +73 -0
  67. package/lib/runtime/profile-seam.js +321 -0
  68. package/lib/runtime/project-runtime.js +34 -17
  69. package/lib/runtime/reconcile-scheduler.js +97 -0
  70. package/lib/runtime/reconciler.js +6 -1
  71. package/lib/runtime/regions.js +0 -19
  72. package/lib/runtime/server-config.js +6 -4
  73. package/lib/runtime/settings-namespace.js +144 -0
  74. package/lib/runtime/skills-provider.js +38 -14
  75. package/lib/runtime/source-auto-update.js +66 -0
  76. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  77. package/lib/runtime/storage-migration.js +35 -18
  78. package/lib/runtime/subagent-catalog.js +5 -4
  79. package/lib/runtime/timer-seat.js +80 -0
  80. package/lib/runtime/tool-registry-observer.js +20 -20
  81. package/lib/runtime/user-commands.js +7 -3
  82. package/lib/runtime/user-panels.js +125 -46
  83. package/lib/runtime/user-store.js +124 -69
  84. package/lib/types/application/catalog-context.d.ts +124 -0
  85. package/lib/types/application/catalog.d.ts +108 -304
  86. package/lib/types/application/install-store.d.ts +21 -0
  87. package/lib/types/application/lsp-service.d.ts +36 -0
  88. package/lib/types/application/mcp-service.d.ts +77 -0
  89. package/lib/types/application/panel-resources.d.ts +8 -0
  90. package/lib/types/application/ports.d.ts +72 -0
  91. package/lib/types/application/queries.d.ts +15 -26
  92. package/lib/types/application/snapshot-cache.d.ts +51 -0
  93. package/lib/types/application/source-store.d.ts +100 -0
  94. package/lib/types/catalog/archive.d.ts +3 -11
  95. package/lib/types/catalog/component-files.d.ts +4 -0
  96. package/lib/types/catalog/fs-probes.d.ts +10 -0
  97. package/lib/types/catalog/manifests.d.ts +17 -16
  98. package/lib/types/catalog/native-project.d.ts +2 -2
  99. package/lib/types/catalog/paths.d.ts +53 -15
  100. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  101. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  102. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  103. package/lib/types/catalog/skills-parse.d.ts +13 -7
  104. package/lib/types/catalog/source-catalog.d.ts +10 -5
  105. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  106. package/lib/types/catalog/validate.d.ts +0 -2
  107. package/lib/types/contracts/lsp-status.d.ts +35 -0
  108. package/lib/types/contracts/market.d.ts +13 -2
  109. package/lib/types/contracts/mcp-status.d.ts +4 -0
  110. package/lib/types/contracts/settings.d.ts +59 -0
  111. package/lib/types/model/layouts.d.ts +10 -0
  112. package/lib/types/model/types.d.ts +29 -5
  113. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  114. package/lib/types/runtime/agent-role-router.d.ts +59 -52
  115. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  116. package/lib/types/runtime/deadline.d.ts +17 -0
  117. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  118. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  119. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  120. package/lib/types/runtime/host-locale.d.ts +20 -2
  121. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  122. package/lib/types/runtime/lsp-mounts.d.ts +69 -19
  123. package/lib/types/runtime/lsp-status.d.ts +2 -1
  124. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  125. package/lib/types/runtime/mcp-backend.d.ts +10 -14
  126. package/lib/types/runtime/mcp-client/config.d.ts +8 -2
  127. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  128. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  129. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  130. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  131. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  132. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  133. package/lib/types/runtime/mcp-config.d.ts +25 -4
  134. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  135. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  136. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  137. package/lib/types/runtime/mcp-status.d.ts +1 -1
  138. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  139. package/lib/types/runtime/profile-seam.d.ts +64 -0
  140. package/lib/types/runtime/project-runtime.d.ts +10 -3
  141. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  142. package/lib/types/runtime/reconciler.d.ts +2 -0
  143. package/lib/types/runtime/regions.d.ts +1 -4
  144. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  145. package/lib/types/runtime/skills-provider.d.ts +11 -2
  146. package/lib/types/runtime/source-auto-update.d.ts +32 -0
  147. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  148. package/lib/types/runtime/storage-migration.d.ts +5 -3
  149. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  150. package/lib/types/runtime/timer-seat.d.ts +51 -0
  151. package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
  152. package/lib/types/runtime/user-panels.d.ts +69 -19
  153. package/lib/types/runtime/user-store.d.ts +67 -20
  154. package/package.json +68 -24
  155. package/schemas/README.md +1 -1
@@ -9,10 +9,9 @@
9
9
  * to the DeepSeek function-name constraints. The raw name is only ever sent on
10
10
  * the wire (`tools/call`); the public name is never parsed to recover it.
11
11
  *
12
- * The host contract below (`ToolDefinition`, `ToolExecution`,
13
- * `ToolExecutionResult`) is a structural mirror of `@deepseek-ai/dsh-tools`
14
- * rc.2's registration surface — the runtime accepts the same object shape the
15
- * upstream bridge registers, so the model-facing behavior is identical.
12
+ * The registration surface this module targets is mirrored by
13
+ * `./host-contract.js`; MCP results are mapped into host content blocks by
14
+ * `./projection.js`.
16
15
  *
17
16
  * @module runtime/mcp-client/tools
18
17
  */
@@ -21,7 +20,7 @@ import { isDeepStrictEqual } from 'node:util';
21
20
  import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
22
21
  import { z } from 'zod';
23
22
  import { assertSupportedJsonSchema } from './json-schema-subset.js';
24
- import { isImageAdmissionError } from './host-seams.js';
23
+ import { containsImage, extractText, prepareImageProjection } from './projection.js';
25
24
  /**
26
25
  * DeepSeek function-name contract: at most 64 characters. Wire-protocol
27
26
  * constant, not configuration.
@@ -31,10 +30,6 @@ const MAX_PUBLIC_NAME_LENGTH = 64;
31
30
  const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
32
31
  /** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
33
32
  const HASH_LENGTH = 12;
34
- /** Raster formats supported by the durable attachment vocabulary. */
35
- const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
36
- /** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
37
- const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
38
33
  /** Raw result record: the bridge owns JSON-value validation after transport. */
39
34
  const RawCallToolResultSchema = z.record(z.string(), z.unknown());
40
35
  /** List without mutating the SDK's per-page output-validator cache. */
@@ -253,183 +248,3 @@ function createExecutor(client, host, rawName, taskRequired, opts, projections)
253
248
  return value;
254
249
  };
255
250
  }
256
- /** Whether an untrusted MCP content array contains a declared image block. */
257
- function containsImage(content) {
258
- return content.some(value => isRecord(value) && value.type === 'image');
259
- }
260
- /** Narrow one JSON value to a string-keyed object. */
261
- function isRecord(value) {
262
- return typeof value === 'object' && value !== null && !Array.isArray(value);
263
- }
264
- /** Narrow a declared MIME string to the durable image vocabulary. */
265
- function isImageMediaType(value) {
266
- return IMAGE_MEDIA_TYPES.includes(value);
267
- }
268
- /** Decode one untrusted MCP image block without accepting base64 aliases. */
269
- function decodeImage(block) {
270
- if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) {
271
- throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF');
272
- }
273
- if (block.data === undefined || !CANONICAL_BASE64.test(block.data)) {
274
- throw new Error('the image data is not canonical base64');
275
- }
276
- const data = Buffer.from(block.data, 'base64');
277
- if (data.toString('base64') !== block.data) {
278
- throw new Error('the image data is not canonical base64');
279
- }
280
- return { data, mediaType: block.mimeType };
281
- }
282
- /**
283
- * Resolve the active model route and durable store for an image-bearing result.
284
- * @param host - bridge host with optional services.
285
- * @param exec - exact tool execution whose agent supplies the latest route.
286
- * @returns the attachment store after exact positive image-capability proof.
287
- */
288
- async function resolveImageAdmission(host, exec) {
289
- const attachments = host.getService?.('attachments');
290
- if (attachments === undefined || typeof attachments.saveImages !== 'function') {
291
- throw new Error('no attachment store is mounted');
292
- }
293
- const routed = exec.agent?.session?.requestHeader?.()?.config;
294
- const provider = routed?.provider ?? exec.agent?.options?.provider;
295
- const model = routed?.model ?? exec.agent?.options?.model;
296
- const llm = host.getService?.('llm');
297
- if (provider === undefined || model === undefined || llm === undefined) {
298
- throw new Error('the current model route could not be resolved');
299
- }
300
- let info;
301
- try {
302
- info = await llm.resolveModelInfo(provider, model, exec.signal);
303
- }
304
- catch {
305
- throw new Error('the current model route could not be verified');
306
- }
307
- if (info?.inputModalities === undefined || !info.inputModalities.includes('image')) {
308
- throw new Error(`model "${model}" does not declare image input`);
309
- }
310
- if (exec.signal.aborted)
311
- throw new Error('the tool call was canceled before image storage');
312
- return attachments;
313
- }
314
- /** Stable diagnostic text for an image block that was not admitted. */
315
- function imageDiagnostic(block, reason) {
316
- return `[image unavailable: ${block.mimeType ?? 'unknown media type'}; ${reason}; raw image data remains available to programmatic callers]`;
317
- }
318
- /**
319
- * Decode, preflight, and durably save one MCP result's ordered image batch.
320
- * Any refusal projects every image as text while retaining the canonical raw
321
- * value for programmatic callers.
322
- */
323
- async function prepareImageProjection(host, exec, content, toolName) {
324
- const decoded = [];
325
- const validationErrors = new Map();
326
- const imageIndexes = [];
327
- for (const [index, value] of content.entries()) {
328
- if (!isRecord(value) || value.type !== 'image')
329
- continue;
330
- imageIndexes.push(index);
331
- try {
332
- decoded.push(decodeImage(value));
333
- }
334
- catch (error) {
335
- // decodeImage owns every throw above and always produces Error.
336
- validationErrors.set(index, error.message);
337
- }
338
- }
339
- if (validationErrors.size > 0) {
340
- return projectContent(content, toolName, (block, index) => ({
341
- type: 'text',
342
- text: imageDiagnostic(block, validationErrors.get(index) ?? 'another image in the same result was invalid')
343
- }));
344
- }
345
- let attachments;
346
- try {
347
- attachments = await resolveImageAdmission(host, exec);
348
- }
349
- catch (error) {
350
- // resolveImageAdmission contains provider failures and throws Error only.
351
- const reason = error.message;
352
- return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }));
353
- }
354
- try {
355
- const refs = await attachments.saveImages(decoded);
356
- const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset]]));
357
- return projectContent(content, toolName, (_block, index) => ({
358
- type: 'image',
359
- attachment: byIndex.get(index)
360
- }));
361
- }
362
- catch (error) {
363
- const reason = isImageAdmissionError(error) ? `image admission rejected the result: ${error.message}` : 'durable image storage rejected the result';
364
- return projectContent(content, toolName, block => ({
365
- type: 'text',
366
- text: imageDiagnostic(block, reason)
367
- }));
368
- }
369
- }
370
- /**
371
- * Extract text from an MCP content array into a single string.
372
- * - text blocks: join with '\n'
373
- * - image/audio/resource blocks: replaced with a placeholder
374
- *
375
- * Defensive: fields that the MCP spec declares required (mimeType, text) are
376
- * guarded with fallbacks because this is a network trust boundary.
377
- */
378
- function extractText(mcpContent, toolName) {
379
- const content = projectContent(mcpContent, toolName);
380
- // The default image projector below also returns text, so this local call
381
- // cannot produce a core image block.
382
- return content.map(block => block.text).join('\n');
383
- }
384
- /**
385
- * Project ordered MCP blocks into the core content vocabulary.
386
- * Text-like runs are newline-coalesced; admitted images split those runs at
387
- * their original position.
388
- */
389
- function projectContent(mcpContent, toolName, image = block => ({
390
- type: 'text',
391
- text: imageDiagnostic(block, 'this result was not admitted to durable model context')
392
- })) {
393
- const projected = [];
394
- const text = [];
395
- const flushText = () => {
396
- if (text.length === 0)
397
- return;
398
- projected.push({ type: 'text', text: text.splice(0).join('\n') });
399
- };
400
- for (const [index, value] of mcpContent.entries()) {
401
- if (!isRecord(value)) {
402
- text.push('[unsupported MCP content block: expected an object]');
403
- continue;
404
- }
405
- const block = value;
406
- switch (block.type) {
407
- case 'text':
408
- if (block.text !== undefined)
409
- text.push(block.text);
410
- break;
411
- case 'image':
412
- flushText();
413
- projected.push(image(block, index));
414
- break;
415
- case 'resource_link':
416
- if (block.name === undefined || block.uri === undefined) {
417
- text.push('[resource link unavailable: the MCP block is missing its name or URI]');
418
- }
419
- else {
420
- text.push(`Resource link: ${block.name} (${block.uri})`);
421
- }
422
- break;
423
- case 'audio':
424
- text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`);
425
- break;
426
- case 'resource':
427
- text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]');
428
- break;
429
- default:
430
- text.push(`[unsupported MCP content type: ${block.type}]`);
431
- }
432
- }
433
- flushText();
434
- return projected.length > 0 ? projected : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }];
435
- }
@@ -13,13 +13,29 @@
13
13
  import { createHash } from 'node:crypto';
14
14
  import { DEFAULT_TOOL_CALL_TIMEOUT_MS } from './mcp-client/config.js';
15
15
  import { resolveCwd } from '../catalog/validate.js';
16
- import { qualifiedSuiteId } from '../catalog/paths.js';
16
+ import { qualifiedSuiteId, suiteDataDir } from '../catalog/paths.js';
17
17
  import { applyOverride } from './mcp-overrides.js';
18
18
  import { PLUGIN_ROOT_VARIABLES, PLUGIN_DATA_VARIABLES } from '../model/layouts.js';
19
19
  /** The max length the bridge accepts for a serverName. */
20
20
  const SERVER_NAME_MAX = 32;
21
21
  const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
22
22
  const BUILTIN_PLACEHOLDERS = new Set([...PLUGIN_ROOT_VARIABLES, ...PLUGIN_DATA_VARIABLES]);
23
+ /**
24
+ * The effective view of one suite's `mcp.json` servers: the user's per-server
25
+ * overrides applied, every row flagged with whether it is used at all, and each
26
+ * row's credential references read off the effective shape. Mounting, status
27
+ * and detail rendering read this one projection, so a server cannot be listed
28
+ * as enabled while mounting something else.
29
+ */
30
+ export function effectiveMcpServers(suite, overrides = {}) {
31
+ const rows = [];
32
+ for (const [serverKey, source] of Object.entries(suite.mcp?.servers ?? {})) {
33
+ const override = overrides[serverKey];
34
+ const server = applyOverride(source, override);
35
+ rows.push({ serverKey, server, override, enabled: override?.enabled !== false, credentialRefs: credentialRefsInServer(server) });
36
+ }
37
+ return rows;
38
+ }
23
39
  /**
24
40
  * Build one mount request per supported mcp.json server, resolving every
25
41
  * `${NAME}` through the credential resolver before a child process or HTTP
@@ -29,31 +45,34 @@ const BUILTIN_PLACEHOLDERS = new Set([...PLUGIN_ROOT_VARIABLES, ...PLUGIN_DATA_V
29
45
  * @param overrides user-owned per-server overrides (url/headers/env/args
30
46
  * replacement plus enable/disable); applied before mount. Disabled servers
31
47
  * are omitted entirely.
32
- * @returns mount requests plus per-server failures. serverName collisions are
33
- * reported by the mount registry, not here.
48
+ * @returns mount requests, per-server failures, and the credential references
49
+ * the effective servers need. serverName collisions are reported by the
50
+ * mount registry, not here.
34
51
  */
35
52
  export async function toMcpMounts(suite, pluginDataRoot, overrides = {}, resolver = { resolve: async () => undefined }) {
36
53
  if (suite.mcp === undefined)
37
- return { mounts: [], failures: [] };
54
+ return { mounts: [], failures: [], credentialRefs: [] };
38
55
  const mounts = [];
39
56
  const failures = [];
40
- for (const [serverKey, source] of Object.entries(suite.mcp.servers)) {
41
- const override = overrides[serverKey];
42
- if (override?.enabled === false)
57
+ const credentialRefs = new Set();
58
+ for (const row of effectiveMcpServers(suite, overrides)) {
59
+ if (!row.enabled)
43
60
  continue;
44
- const server = applyOverride(source, override);
61
+ for (const ref of row.credentialRefs)
62
+ credentialRefs.add(ref);
45
63
  try {
46
- const result = await toResolvedMount(suite.mcp.root === undefined ? suite : { ...suite, root: suite.mcp.root }, serverKey, server, pluginDataRoot, resolver);
64
+ const target = suite.mcp.root === undefined ? suite : { ...suite, root: suite.mcp.root };
65
+ const result = await toResolvedMount(target, row.serverKey, row.server, pluginDataRoot, resolver);
47
66
  if (result.failure !== undefined)
48
67
  failures.push(result.failure);
49
68
  if (result.request !== undefined)
50
69
  mounts.push(result.request);
51
70
  }
52
71
  catch {
53
- failures.push({ serverKey, code: 'credential-error', credentialRefs: credentialRefsInServer(server), reason: 'credential lookup failed' });
72
+ failures.push({ serverKey: row.serverKey, code: 'credential-error', credentialRefs: row.credentialRefs, reason: 'credential lookup failed' });
54
73
  }
55
74
  }
56
- return { mounts, failures };
75
+ return { mounts, failures, credentialRefs: [...credentialRefs] };
57
76
  }
58
77
  /** Find external credential references used by one MCP server definition. */
59
78
  export function credentialRefsInServer(server) {
@@ -79,7 +98,7 @@ export function credentialRefsInServer(server) {
79
98
  async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolver) {
80
99
  if (server.type === 'sse') {
81
100
  // The market bridge supports the legacy HTTP+SSE transport natively.
82
- const expand = expander(suite, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id)), resolver);
101
+ const expand = expander(suite, suiteDataDir(pluginDataRoot, suite.sourceId, suite.id), resolver);
83
102
  const url = await expand.one(server.url);
84
103
  const headers = await expand.map(server.headers ?? {});
85
104
  const missing = unique([...url.missing, ...headers.missing]);
@@ -96,7 +115,7 @@ async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolve
96
115
  };
97
116
  return { request: { suiteId: qualifiedSuiteId(suite.sourceId, suite.id), serverKey, config: sseConfig } };
98
117
  }
99
- const expand = expander(suite, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id)), resolver);
118
+ const expand = expander(suite, suiteDataDir(pluginDataRoot, suite.sourceId, suite.id), resolver);
100
119
  const serverName = deriveServerName(suite.id, serverKey);
101
120
  if (server.type === 'stdio') {
102
121
  const args = await expand.all(server.args ?? []);
@@ -115,7 +134,7 @@ async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolve
115
134
  command: server.command.startsWith('./') ? joinInside(suite.root, server.command.slice(2)) : server.command,
116
135
  args: args.values,
117
136
  env: env.values,
118
- cwd: resolveCwd(cwd.value, suite.root, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id))),
137
+ cwd: resolveCwd(cwd.value, suite.root, suiteDataDir(pluginDataRoot, suite.sourceId, suite.id)),
119
138
  ...bridgePolicy(server),
120
139
  failOnStartupError: true
121
140
  }
@@ -158,6 +177,9 @@ function mapAuth(auth) {
158
177
  function bridgePolicy(server) {
159
178
  return {
160
179
  toolCallTimeoutMs: server.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS,
180
+ // Only a DECLARED startup timeout is a policy: host compatibility mode
181
+ // cannot enforce one, so an undeclared server stays enforceable there and
182
+ // the built-in bridge applies its own default at connect time.
161
183
  ...(server.startupTimeoutMs === undefined ? {} : { startupTimeoutMs: server.startupTimeoutMs }),
162
184
  ...(server.enabledTools === undefined ? {} : { enabledTools: server.enabledTools }),
163
185
  ...(server.disabledTools === undefined ? {} : { disabledTools: server.disabledTools })
@@ -1,56 +1,73 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
3
4
  import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalog/validate.js';
5
+ import { effectiveSurfaces } from '../model/types.js';
4
6
  export const USER_MCP_SOURCE = '@user-mcp';
5
7
  export const USER_MCP_SUITE = 'user-mcp';
6
- /** User-created services reuse the owned bridge lifecycle without changing host configuration. */
7
- export async function loadUserMcpSuite(dataRoot) {
8
- const path = join(dataRoot, 'mcp-servers.json');
8
+ /**
9
+ * User-created services reuse the owned bridge lifecycle without changing host configuration.
10
+ *
11
+ * The declarations live in the shared Agent layout root
12
+ * (`~/.agents/mcp.json`), so a service the user adds by hand is ordinary
13
+ * `mcpServers` JSON in the same place other Agent tools look.
14
+ *
15
+ * The suite always carries an `mcp` document — an absent or malformed
16
+ * `mcp.json` leaves an empty one — so callers read `.mcp.servers` directly
17
+ * instead of re-checking the optional field.
18
+ */
19
+ export async function loadUserMcpSuite(agentsRoot) {
20
+ const path = userMcpPath(agentsRoot);
9
21
  let mcp = { schema: MCP_SCHEMA_ID, servers: {} };
10
22
  const errors = [];
11
23
  try {
12
- mcp = await validateUserMcp(dataRoot, JSON.parse(await readFile(path, 'utf8')));
24
+ mcp = await validateUserMcp(agentsRoot, JSON.parse(await readFile(path, 'utf8')));
13
25
  }
14
26
  catch (error) {
15
27
  if (error.code !== 'ENOENT')
16
- errors.push(`mcp-servers.json: ${error instanceof Error ? error.message : String(error)}`);
28
+ errors.push(`mcp.json: ${error instanceof Error ? error.message : String(error)}`);
17
29
  }
18
30
  return {
19
31
  sourceId: USER_MCP_SOURCE,
20
32
  id: USER_MCP_SUITE,
21
- root: dataRoot,
33
+ root: agentsRoot,
22
34
  manifest: { layout: 'agent-plugin-v1', path, id: USER_MCP_SUITE, name: USER_MCP_SUITE },
23
35
  skills: [],
24
36
  mcp,
25
37
  surfaces: { skills: 0, mcp: Object.keys(mcp.servers).length, commands: 0, agents: 0, hooks: 0, lsp: 0 },
26
38
  dimension: 'user',
27
39
  enabled: true,
40
+ // A user-created suite has no install entry and no overrides, so every
41
+ // surface keeps its enabled default.
42
+ activeSurfaces: effectiveSurfaces(undefined),
28
43
  installedAt: 'user',
29
44
  errors
30
45
  };
31
46
  }
32
- async function validateUserMcp(dataRoot, raw) {
47
+ async function validateUserMcp(agentsRoot, raw) {
33
48
  const errors = await validateAgainstSchema(MCP_SCHEMA_ID, raw);
34
49
  if (errors.length > 0)
35
50
  throw new Error(`invalid MCP configuration: ${errors.join('; ')}`);
36
- const result = await validateMcpJson(dataRoot, raw);
51
+ const result = await validateMcpJson(agentsRoot, raw);
37
52
  if (result.config === undefined || result.errors.length > 0)
38
53
  throw new Error(result.errors.join('; '));
39
54
  return result.config;
40
55
  }
41
56
  /** Add one service atomically; reject collisions and malformed config before writing. */
42
- export async function addUserMcpServer(dataRoot, name, server) {
57
+ export async function addUserMcpServer(agentsRoot, name, server) {
43
58
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
44
59
  throw new Error('invalid MCP server name');
45
- const suite = await loadUserMcpSuite(dataRoot);
60
+ const suite = await loadUserMcpSuite(agentsRoot);
46
61
  if (suite.errors.length > 0)
47
62
  throw new Error(suite.errors.join('; '));
48
63
  if (Object.hasOwn(suite.mcp.servers, name))
49
64
  throw new Error(`MCP server "${name}" already exists`);
50
65
  const document = { $schema: MCP_SCHEMA_ID, mcpServers: { ...suite.mcp.servers, [name]: server } };
51
- await validateUserMcp(dataRoot, document);
52
- await mkdir(dataRoot, { recursive: true });
53
- const path = join(dataRoot, 'mcp-servers.json');
54
- await writeFile(`${path}.tmp`, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
55
- await rename(`${path}.tmp`, path);
66
+ await validateUserMcp(agentsRoot, document);
67
+ const path = userMcpPath(agentsRoot);
68
+ await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
69
+ }
70
+ /** The user's hand-written MCP declaration file: `<agentsRoot>/mcp.json`. */
71
+ export function userMcpPath(agentsRoot) {
72
+ return join(agentsRoot, 'mcp.json');
56
73
  }
@@ -1,20 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import * as mcpBridge from './mcp-client/bridge.js';
3
- import { applyOverride } from './mcp-overrides.js';
4
- import { credentialRefsInServer, deriveServerName, toMcpMounts } from './mcp-config.js';
3
+ import { toMcpMounts } from './mcp-config.js';
5
4
  import { mcpCredentialResolver } from './mcp-credentials.js';
5
+ import { SerialPassQueue, RetryScheduler } from './mount-lifecycle.js';
6
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;
14
7
  export class McpMountRegistry {
15
8
  ctx;
16
9
  pluginDataRoot;
17
- namespace;
18
10
  live = new Map();
19
11
  names = new Map();
20
12
  credentialRefs = new Set();
@@ -24,11 +16,9 @@ export class McpMountRegistry {
24
16
  /** The active MCP mount backend ('builtin' bridge or host client compat mode). */
25
17
  backendProvider = async () => 'builtin';
26
18
  /** Serialize mount and unmount passes so a disable cannot race an in-flight spawn. */
27
- reconcileQueue = Promise.resolve();
28
- /** Pending retry timers keyed by mount key, so a teardown can cancel them. */
29
- retries = new Map();
30
- /** Attempt count per mount key; reset whenever a retry succeeds. */
31
- attempts = new Map();
19
+ passes = new SerialPassQueue();
20
+ /** Delayed re-attempts for mounts that are not live yet. */
21
+ retries;
32
22
  /**
33
23
  * Mount keys flagged for an explicit rebuild (re-authorize): the next
34
24
  * reconcile must tear down and remount them even when the resolved config
@@ -37,10 +27,13 @@ export class McpMountRegistry {
37
27
  forcedRemounts = new Set();
38
28
  /** Snapshot of the last reconciled suite set, replayed by retry passes. */
39
29
  lastEnabled = [];
40
- constructor(ctx, pluginDataRoot, namespace) {
30
+ constructor(ctx, pluginDataRoot) {
41
31
  this.ctx = ctx;
42
32
  this.pluginDataRoot = pluginDataRoot;
43
- this.namespace = namespace;
33
+ this.retries = new RetryScheduler({
34
+ replay: () => this.reconcile(this.lastEnabled),
35
+ log: message => this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${message}`)
36
+ });
44
37
  }
45
38
  /** Install the per-suite overrides provider (suiteId -> overrides). */
46
39
  setOverridesProvider(provider) {
@@ -71,6 +64,15 @@ export class McpMountRegistry {
71
64
  forceRemount(suiteId, serverKey) {
72
65
  this.forcedRemounts.add(mountKey(suiteId, serverKey));
73
66
  }
67
+ /**
68
+ * Flag every live mount for a rebuild on the next reconcile: the manual
69
+ * retry path. A bridge whose server died underneath it still matches its own
70
+ * config fingerprint, so nothing else would ever re-verify that mount.
71
+ */
72
+ forceRemountAll() {
73
+ for (const key of this.live.keys())
74
+ this.forcedRemounts.add(key);
75
+ }
74
76
  /** The mount key owning one derived serverName; undefined when not mounted here. */
75
77
  serverOwner(serverName) {
76
78
  const owner = this.names.get(serverName);
@@ -82,15 +84,13 @@ export class McpMountRegistry {
82
84
  return { suiteId: owner.slice(0, separator), serverKey: owner.slice(separator + 1) };
83
85
  }
84
86
  /** Queue one reconciliation behind any in-flight mount/unmount pass. */
85
- async reconcile(enabledSuites) {
86
- const run = this.reconcileQueue.then(() => this.reconcileNow(enabledSuites));
87
- this.reconcileQueue = run.then(() => undefined, () => undefined);
88
- return run;
87
+ reconcile(enabledSuites) {
88
+ return this.passes.run(() => this.reconcileNow(enabledSuites));
89
89
  }
90
90
  /** Mount/unmount MCP servers to match the enabled suites exactly. */
91
91
  async reconcileNow(enabledSuites) {
92
92
  this.lastEnabled = [...enabledSuites];
93
- const active = enabledSuites.filter(suite => suite.activeSurfaces?.mcp !== false);
93
+ const active = enabledSuites.filter(suite => suite.activeSurfaces.mcp !== false);
94
94
  const overrides = await this.overridesProvider();
95
95
  const resolver = mcpCredentialResolver(this.ctx);
96
96
  const wanted = new Map();
@@ -98,15 +98,11 @@ export class McpMountRegistry {
98
98
  this.credentialRefs.clear();
99
99
  for (const suite of active) {
100
100
  const suiteOverrides = overrides.get(qualifiedSuiteId(suite.sourceId, suite.id));
101
- for (const [serverKey, source] of Object.entries(suite.mcp?.servers ?? {})) {
102
- const override = suiteOverrides?.[serverKey];
103
- if (override?.enabled === false)
104
- continue;
105
- const effective = applyOverride(source, override);
106
- for (const ref of credentialRefsInServer(effective))
107
- this.credentialRefs.add(ref);
108
- }
109
- const { mounts, failures } = await toMcpMounts(suite, this.pluginDataRoot, suiteOverrides, resolver);
101
+ const { mounts, failures, credentialRefs } = await toMcpMounts(suite, this.pluginDataRoot, suiteOverrides, resolver);
102
+ // The references ride the same effective view the mounts are built from,
103
+ // so the credentials panel cannot show one the mounts never use.
104
+ for (const ref of credentialRefs)
105
+ this.credentialRefs.add(ref);
110
106
  for (const failure of failures) {
111
107
  diagnostics.push({
112
108
  suiteId: qualifiedSuiteId(suite.sourceId, suite.id),
@@ -117,9 +113,10 @@ export class McpMountRegistry {
117
113
  });
118
114
  }
119
115
  for (const mount of mounts) {
120
- // Bridge namespaces are reserved app-wide, even when tools are agent-scoped.
121
- if (this.namespace !== undefined)
122
- mount.config.serverName = deriveServerName(mount.config.serverName, this.namespace);
116
+ // One derived serverName per suite/server, whatever the dimension: the
117
+ // host keeps an agent's registrations in that agent's own scope and lets
118
+ // them shadow globals, so suffixing a per-session id would only fork the
119
+ // server's identity — including its stored OAuth grant — per session.
123
120
  wanted.set(mountKey(mount.suiteId, mount.serverKey), { suite, serverKey: mount.serverKey, request: mount });
124
121
  }
125
122
  }
@@ -164,67 +161,30 @@ export class McpMountRegistry {
164
161
  // next full reconcile re-checks them anyway, so the self-heal path
165
162
  // is intact.
166
163
  const informational = failure.code === 'foreign-mount' || failure.code === 'duplicate-mount';
167
- this.scheduleRetry(key, entry.suite.id, entry.serverKey, informational ? undefined : failure.reason);
164
+ this.retries.schedule({ key, label: `${entry.suite.id}/${entry.serverKey}`, ...(informational ? {} : { reason: failure.reason }) });
168
165
  }
169
166
  }
170
167
  return diagnostics.filter(diagnostic => diagnostic.reason !== 'unmounted');
171
168
  }
172
- /**
173
- * Schedule a delayed re-attempt for a mount that still is not live.
174
- *
175
- * Mounts are retried because a remote endpoint or a credential may become
176
- * available slightly after the suite is enabled. The schedule is bounded, and
177
- * a server that keeps failing simply stops retrying until the next reconcile.
178
- */
179
- scheduleRetry(key, suiteId, serverKey, reason) {
180
- const pending = this.retries.get(key);
181
- if (pending !== undefined)
182
- clearTimeout(pending);
183
- this.retries.delete(key);
184
- if (reason === undefined) {
185
- this.attempts.delete(key);
186
- return;
187
- }
188
- const attempt = (this.attempts.get(key) ?? 0) + 1;
189
- this.attempts.set(key, attempt);
190
- if (attempt > MAX_RETRY_ATTEMPTS) {
191
- this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${suiteId}/${serverKey}: giving up after ${MAX_RETRY_ATTEMPTS} attempts — ${reason}`);
192
- return;
193
- }
194
- const delay = RETRY_SCHEDULE_MS[attempt - 1] ?? RETRY_SCHEDULE_MS[RETRY_SCHEDULE_MS.length - 1];
195
- const timer = setTimeout(() => {
196
- this.retries.delete(key);
197
- // Re-run against the last known suite set: a retry must not resurrect a
198
- // server that has since been disabled or uninstalled.
199
- void this.reconcile(this.lastEnabled).catch(() => { });
200
- }, delay);
201
- timer.unref?.();
202
- this.retries.set(key, timer);
203
- }
204
169
  /** Dispose every live mount after queued reconciliation passes settle. */
205
170
  async disposeAll() {
206
- const run = this.reconcileQueue.then(async () => {
171
+ await this.passes.run(async () => {
207
172
  for (const [key, live] of [...this.live]) {
208
173
  await this.unmount(key, live);
209
174
  }
210
175
  });
211
- this.reconcileQueue = run.then(() => undefined, () => undefined);
212
- await run;
213
- for (const timer of this.retries.values())
214
- clearTimeout(timer);
215
176
  this.retries.clear();
216
- this.attempts.clear();
217
177
  this.credentialRefs.clear();
218
178
  }
219
179
  /** Mount one precomputed request (source config merged with overrides). */
220
180
  async mountWith(request) {
221
- const owner = this.names.get(request.config.serverName);
181
+ const owner = this.serverOwner(request.config.serverName);
222
182
  if (owner !== undefined) {
223
183
  // Two sources shipping the same suite/server pair derive one serverName:
224
184
  // the model only needs one copy, so later arrivals skip with an
225
185
  // informational diagnostic instead of double-registering.
226
186
  return {
227
- reason: `server "${request.config.serverName}" is already mounted from ${owner} — this suite's copy is redundant and was skipped`,
187
+ reason: `server "${request.config.serverName}" is already mounted from ${owner.suiteId} — this suite's copy is redundant and was skipped`,
228
188
  code: 'duplicate-mount'
229
189
  };
230
190
  }
@@ -11,8 +11,9 @@
11
11
  * resolved by the Host credentials service (or launch-environment fallback)
12
12
  * so keys never persist in plain text.
13
13
  */
14
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
15
- import { dirname, join } from 'node:path';
14
+ import { readFile } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
16
17
  import { isSensitiveKey } from './mcp-redaction.js';
17
18
  /** The overrides directory under the plugin data root. */
18
19
  export function overridesDir(dataRoot) {
@@ -35,11 +36,10 @@ export async function loadSuiteOverrides(dataRoot, suiteId) {
35
36
  return {};
36
37
  }
37
38
  }
38
- /** Persist one suite's overrides atomically-ish (single writer, small file). */
39
+ /** Persist one suite's overrides through the harness atomic write. */
39
40
  export async function saveSuiteOverrides(dataRoot, suiteId, overrides) {
40
41
  const path = suiteOverridePath(dataRoot, suiteId);
41
- await mkdir(dirname(path), { recursive: true });
42
- await writeFile(path, `${JSON.stringify(overrides, null, 2)}\n`, { mode: 0o600 });
42
+ await writeFileAtomic(path, `${JSON.stringify(overrides, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
43
43
  }
44
44
  /** Keep only recognized fields with correct shapes; drop everything else. */
45
45
  export function sanitizeOverrides(raw) {
@@ -13,7 +13,8 @@ const SENSITIVE_KEY = /(authorization|auth|bearer|token|secret|password|passwd|c
13
13
  * walk descends into it and the per-value checks apply instead.
14
14
  */
15
15
  const STRUCTURE_KEY = /^auth$/i;
16
- const PLACEHOLDER = /\$\{[^}]+\}/g;
16
+ /** A `${NAME}` credential reference. Deliberately not global: it is only ever probed with `test`. */
17
+ const PLACEHOLDER = /\$\{[^}]+\}/;
17
18
  export function redactMcpConfig(value) {
18
19
  return redactValue(value);
19
20
  }
@@ -76,7 +77,7 @@ function redactValue(value, key = '') {
76
77
  }
77
78
  if (isSensitiveKey(key)) {
78
79
  if (typeof value === 'string' && PLACEHOLDER.test(value))
79
- return value.replace(PLACEHOLDER, match => match);
80
+ return value;
80
81
  return '[redacted]';
81
82
  }
82
83
  if (key === 'url' && typeof value === 'string')