dsh-agent-plugins-market 0.6.2 → 0.7.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 (136) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +547 -540
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +181 -0
  6. package/lib/application/catalog.js +217 -1110
  7. package/lib/application/details.js +6 -6
  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 +246 -0
  11. package/lib/application/panel-resources.js +29 -12
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +1 -1
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +34 -29
  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 +66 -55
  22. package/lib/catalog/project-config.js +2 -1
  23. package/lib/catalog/project-hooks.js +9 -1
  24. package/lib/catalog/scan-resolvers.js +29 -45
  25. package/lib/catalog/skills-parse.js +14 -10
  26. package/lib/catalog/source-catalog.js +3 -2
  27. package/lib/catalog/suite-components.js +5 -5
  28. package/lib/catalog/suite-scanner.js +3 -1
  29. package/lib/catalog/surfaces.js +5 -26
  30. package/lib/catalog/validate.js +7 -11
  31. package/lib/contracts/market.js +1 -0
  32. package/lib/index.js +91 -208
  33. package/lib/model/types.js +26 -3
  34. package/lib/routes.js +45 -15
  35. package/lib/runtime/agent-role-names.js +70 -0
  36. package/lib/runtime/agent-role-router.js +116 -102
  37. package/lib/runtime/commands-mounts.js +9 -6
  38. package/lib/runtime/deadline.js +28 -0
  39. package/lib/runtime/feedback-tool.js +94 -43
  40. package/lib/runtime/hooks-mounts.js +9 -11
  41. package/lib/runtime/host-locale.js +10 -21
  42. package/lib/runtime/lsp-direct-config.js +19 -19
  43. package/lib/runtime/lsp-mounts.js +159 -68
  44. package/lib/runtime/lsp-server-state.js +4 -4
  45. package/lib/runtime/lsp-status.js +1 -1
  46. package/lib/runtime/mcp-auth-record.js +42 -0
  47. package/lib/runtime/mcp-backend.js +6 -5
  48. package/lib/runtime/mcp-client/config.js +0 -1
  49. package/lib/runtime/mcp-client/connection.js +32 -37
  50. package/lib/runtime/mcp-client/host-contract.js +10 -0
  51. package/lib/runtime/mcp-client/oauth.js +5 -7
  52. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  53. package/lib/runtime/mcp-client/projection.js +202 -0
  54. package/lib/runtime/mcp-client/tools.js +4 -189
  55. package/lib/runtime/mcp-config.js +29 -10
  56. package/lib/runtime/mcp-direct-config.js +33 -16
  57. package/lib/runtime/mcp-mounts.js +35 -75
  58. package/lib/runtime/mcp-overrides.js +5 -5
  59. package/lib/runtime/mcp-redaction.js +3 -2
  60. package/lib/runtime/mcp-status.js +7 -8
  61. package/lib/runtime/mount-lifecycle.js +73 -0
  62. package/lib/runtime/profile-seam.js +321 -0
  63. package/lib/runtime/project-runtime.js +11 -8
  64. package/lib/runtime/reconcile-scheduler.js +99 -0
  65. package/lib/runtime/reconciler.js +4 -0
  66. package/lib/runtime/server-config.js +6 -4
  67. package/lib/runtime/settings-namespace.js +136 -0
  68. package/lib/runtime/skills-provider.js +5 -3
  69. package/lib/runtime/source-auto-update.js +70 -0
  70. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  71. package/lib/runtime/storage-migration.js +35 -18
  72. package/lib/runtime/subagent-catalog.js +5 -4
  73. package/lib/runtime/user-commands.js +2 -2
  74. package/lib/runtime/user-panels.js +31 -21
  75. package/lib/runtime/user-store.js +12 -13
  76. package/lib/types/application/catalog-context.d.ts +118 -0
  77. package/lib/types/application/catalog.d.ts +99 -304
  78. package/lib/types/application/install-store.d.ts +21 -0
  79. package/lib/types/application/lsp-service.d.ts +36 -0
  80. package/lib/types/application/mcp-service.d.ts +71 -0
  81. package/lib/types/application/panel-resources.d.ts +8 -0
  82. package/lib/types/application/ports.d.ts +72 -0
  83. package/lib/types/application/queries.d.ts +13 -26
  84. package/lib/types/application/snapshot-cache.d.ts +51 -0
  85. package/lib/types/application/source-store.d.ts +100 -0
  86. package/lib/types/catalog/archive.d.ts +3 -11
  87. package/lib/types/catalog/component-files.d.ts +4 -0
  88. package/lib/types/catalog/fs-probes.d.ts +10 -0
  89. package/lib/types/catalog/manifests.d.ts +17 -16
  90. package/lib/types/catalog/native-project.d.ts +2 -2
  91. package/lib/types/catalog/paths.d.ts +47 -15
  92. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  93. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  94. package/lib/types/catalog/skills-parse.d.ts +0 -7
  95. package/lib/types/catalog/source-catalog.d.ts +3 -3
  96. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  97. package/lib/types/catalog/validate.d.ts +0 -2
  98. package/lib/types/contracts/lsp-status.d.ts +35 -0
  99. package/lib/types/contracts/market.d.ts +3 -2
  100. package/lib/types/contracts/mcp-status.d.ts +4 -0
  101. package/lib/types/model/types.d.ts +29 -5
  102. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  103. package/lib/types/runtime/agent-role-router.d.ts +54 -51
  104. package/lib/types/runtime/deadline.d.ts +18 -0
  105. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  106. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  107. package/lib/types/runtime/host-locale.d.ts +1 -1
  108. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  109. package/lib/types/runtime/lsp-mounts.d.ts +62 -19
  110. package/lib/types/runtime/lsp-status.d.ts +2 -1
  111. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  112. package/lib/types/runtime/mcp-backend.d.ts +7 -4
  113. package/lib/types/runtime/mcp-client/config.d.ts +0 -2
  114. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  115. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  116. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  117. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  118. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  119. package/lib/types/runtime/mcp-config.d.ts +25 -4
  120. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  121. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  122. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  123. package/lib/types/runtime/mcp-status.d.ts +1 -1
  124. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  125. package/lib/types/runtime/profile-seam.d.ts +64 -0
  126. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  127. package/lib/types/runtime/reconciler.d.ts +2 -0
  128. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  129. package/lib/types/runtime/source-auto-update.d.ts +33 -0
  130. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  131. package/lib/types/runtime/storage-migration.d.ts +5 -3
  132. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  133. package/lib/types/runtime/user-panels.d.ts +8 -11
  134. package/lib/types/runtime/user-store.d.ts +2 -4
  135. package/package.json +48 -21
  136. package/schemas/README.md +1 -1
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Structural mirrors of the harness tool-registration surface the bridge
3
+ * registers against: the runtime accepts the same object shape the upstream
4
+ * `@deepseek-ai/dsh-tools` bridge registers, so the model-facing behavior is
5
+ * identical. Mirrored rather than imported so the market never loads a second
6
+ * copy of the host runtime.
7
+ *
8
+ * @module runtime/mcp-client/host-contract
9
+ */
10
+ export {};
@@ -22,18 +22,16 @@
22
22
  * then last exactly as long as the process.
23
23
  *
24
24
  * Ported from the harness `dsh-mcp-client` OAuth implementation (upstream
25
- * commits archived at `docs/upstream-proposal/patches/`); the record format is
25
+ * commits archived at `docs/developer/upstream-proposal/patches/`); the record format is
26
26
  * byte-compatible, so grants written by either implementation are interchangeable.
27
27
  *
28
28
  * @module runtime/mcp-client/oauth
29
29
  */
30
30
  import { createServer } from 'node:http';
31
31
  import { credentialKey, scrubbedParentEnv } from './host-seams.js';
32
+ import { PLUGIN_NAME, PLUGIN_VERSION } from './plugin-identity.js';
32
33
  /** OAuth client identity presented during dynamic registration. */
33
- const CLIENT_NAME = 'DeepSeek Harness (dsh-agent-plugins-market)';
34
- /** Software id/version advertised in RFC 7591 registration metadata. */
35
- const CLIENT_SOFTWARE_ID = 'dsh-agent-plugins-market';
36
- const CLIENT_SOFTWARE_VERSION = '0.1.0';
34
+ const CLIENT_NAME = `DeepSeek Harness (${PLUGIN_NAME})`;
37
35
  /** Credential-record scope for MCP OAuth state; per-server ids follow it. */
38
36
  export const MCP_AUTH_RECORD_SCOPE = 'mcp-auth';
39
37
  /** Loopback callback budget: the browser must land within five minutes. */
@@ -146,8 +144,8 @@ export class LoopbackOAuthClientProvider {
146
144
  // Public client: PKCE carries the proof; no client secret exists.
147
145
  token_endpoint_auth_method: 'none',
148
146
  ...(this.scope === undefined ? {} : { scope: this.scope }),
149
- software_id: CLIENT_SOFTWARE_ID,
150
- software_version: CLIENT_SOFTWARE_VERSION
147
+ software_id: PLUGIN_NAME,
148
+ software_version: PLUGIN_VERSION
151
149
  };
152
150
  }
153
151
  async clientInformation() {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * This plugin's own identity as advertised to the servers it talks to: the MCP
3
+ * client implementation name and version, and the RFC 7591 `software_id` /
4
+ * `software_version` carried in OAuth dynamic-registration metadata.
5
+ *
6
+ * The version is read from the installed manifest through Node's package
7
+ * self-reference, so a release bumps exactly one number — `package.json`. A
8
+ * copy that can no longer resolve its own manifest (a bundle that inlined this
9
+ * module) reports {@link UNKNOWN_VERSION} rather than failing the connection.
10
+ *
11
+ * @module runtime/mcp-client/plugin-identity
12
+ */
13
+ import { createRequire } from 'node:module';
14
+ /** Name npm and the harness know this plugin by. */
15
+ export const PLUGIN_NAME = 'dsh-agent-plugins-market';
16
+ /** Reported when the plugin cannot read its own manifest; never a released version. */
17
+ const UNKNOWN_VERSION = '0.0.0';
18
+ function readVersion() {
19
+ try {
20
+ const manifest = createRequire(import.meta.url)('dsh-agent-plugins-market/package.json');
21
+ return typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : UNKNOWN_VERSION;
22
+ }
23
+ catch {
24
+ return UNKNOWN_VERSION;
25
+ }
26
+ }
27
+ /** This plugin's release version. */
28
+ export const PLUGIN_VERSION = readVersion();
@@ -0,0 +1,202 @@
1
+ /**
2
+ * MCP result projection: turn the content blocks an MCP server returns into
3
+ * the host content vocabulary the model sees.
4
+ *
5
+ * Text-like blocks collapse into newline-joined text runs; declared images are
6
+ * decoded, admitted against the live model route, and saved to the durable
7
+ * attachment store. Every refusal — an unsupported media type, a model that
8
+ * does not declare image input, a storage fault — projects the block as text
9
+ * explaining why, and the canonical raw value stays available to programmatic
10
+ * callers in the tool result.
11
+ *
12
+ * Everything here reads an untrusted network payload: the MCP server is an
13
+ * external process and the SDK's declared-required fields may be absent at
14
+ * runtime, so each block is narrowed field by field.
15
+ *
16
+ * @module runtime/mcp-client/projection
17
+ */
18
+ import { isImageAdmissionError } from './host-seams.js';
19
+ /** Raster formats supported by the durable attachment vocabulary. */
20
+ const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
21
+ /** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
22
+ const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
23
+ /** Whether an untrusted MCP content array contains a declared image block. */
24
+ export function containsImage(content) {
25
+ return content.some(value => isRecord(value) && value.type === 'image');
26
+ }
27
+ /** Narrow one JSON value to a string-keyed object. */
28
+ function isRecord(value) {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+ /** Narrow a declared MIME string to the durable image vocabulary. */
32
+ function isImageMediaType(value) {
33
+ return IMAGE_MEDIA_TYPES.includes(value);
34
+ }
35
+ /** Decode one untrusted MCP image block without accepting base64 aliases. */
36
+ function decodeImage(block) {
37
+ if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) {
38
+ throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF');
39
+ }
40
+ if (block.data === undefined || !CANONICAL_BASE64.test(block.data)) {
41
+ throw new Error('the image data is not canonical base64');
42
+ }
43
+ const data = Buffer.from(block.data, 'base64');
44
+ if (data.toString('base64') !== block.data) {
45
+ throw new Error('the image data is not canonical base64');
46
+ }
47
+ return { data, mediaType: block.mimeType };
48
+ }
49
+ /**
50
+ * Resolve the active model route and durable store for an image-bearing result.
51
+ * @param host - bridge host with optional services.
52
+ * @param exec - exact tool execution whose agent supplies the latest route.
53
+ * @returns the attachment store after exact positive image-capability proof.
54
+ */
55
+ async function resolveImageAdmission(host, exec) {
56
+ const attachments = host.getService?.('attachments');
57
+ if (attachments === undefined || typeof attachments.saveImages !== 'function') {
58
+ throw new Error('no attachment store is mounted');
59
+ }
60
+ const routed = exec.agent?.session?.requestHeader?.()?.config;
61
+ const provider = routed?.provider ?? exec.agent?.options?.provider;
62
+ const model = routed?.model ?? exec.agent?.options?.model;
63
+ const llm = host.getService?.('llm');
64
+ if (provider === undefined || model === undefined || llm === undefined) {
65
+ throw new Error('the current model route could not be resolved');
66
+ }
67
+ let info;
68
+ try {
69
+ info = await llm.resolveModelInfo(provider, model, exec.signal);
70
+ }
71
+ catch {
72
+ throw new Error('the current model route could not be verified');
73
+ }
74
+ if (info?.inputModalities === undefined || !info.inputModalities.includes('image')) {
75
+ throw new Error(`model "${model}" does not declare image input`);
76
+ }
77
+ if (exec.signal.aborted)
78
+ throw new Error('the tool call was canceled before image storage');
79
+ return attachments;
80
+ }
81
+ /** Stable diagnostic text for an image block that was not admitted. */
82
+ function imageDiagnostic(block, reason) {
83
+ return `[image unavailable: ${block.mimeType ?? 'unknown media type'}; ${reason}; raw image data remains available to programmatic callers]`;
84
+ }
85
+ /**
86
+ * Decode, preflight, and durably save one MCP result's ordered image batch.
87
+ * Any refusal projects every image as text while retaining the canonical raw
88
+ * value for programmatic callers.
89
+ */
90
+ export async function prepareImageProjection(host, exec, content, toolName) {
91
+ const decoded = [];
92
+ const validationErrors = new Map();
93
+ const imageIndexes = [];
94
+ for (const [index, value] of content.entries()) {
95
+ if (!isRecord(value) || value.type !== 'image')
96
+ continue;
97
+ imageIndexes.push(index);
98
+ try {
99
+ decoded.push(decodeImage(value));
100
+ }
101
+ catch (error) {
102
+ // decodeImage owns every throw above and always produces Error.
103
+ validationErrors.set(index, error.message);
104
+ }
105
+ }
106
+ if (validationErrors.size > 0) {
107
+ return projectContent(content, toolName, (block, index) => ({
108
+ type: 'text',
109
+ text: imageDiagnostic(block, validationErrors.get(index) ?? 'another image in the same result was invalid')
110
+ }));
111
+ }
112
+ let attachments;
113
+ try {
114
+ attachments = await resolveImageAdmission(host, exec);
115
+ }
116
+ catch (error) {
117
+ // resolveImageAdmission contains provider failures and throws Error only.
118
+ const reason = error.message;
119
+ return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }));
120
+ }
121
+ try {
122
+ const refs = await attachments.saveImages(decoded);
123
+ const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset]]));
124
+ return projectContent(content, toolName, (_block, index) => ({
125
+ type: 'image',
126
+ attachment: byIndex.get(index)
127
+ }));
128
+ }
129
+ catch (error) {
130
+ const reason = isImageAdmissionError(error) ? `image admission rejected the result: ${error.message}` : 'durable image storage rejected the result';
131
+ return projectContent(content, toolName, block => ({
132
+ type: 'text',
133
+ text: imageDiagnostic(block, reason)
134
+ }));
135
+ }
136
+ }
137
+ /**
138
+ * Extract text from an MCP content array into a single string.
139
+ * - text blocks: join with '\n'
140
+ * - image/audio/resource blocks: replaced with a placeholder
141
+ *
142
+ * Defensive: fields that the MCP spec declares required (mimeType, text) are
143
+ * guarded with fallbacks because this is a network trust boundary.
144
+ */
145
+ export function extractText(mcpContent, toolName) {
146
+ const content = projectContent(mcpContent, toolName);
147
+ // The default image projector below also returns text, so this local call
148
+ // cannot produce a core image block.
149
+ return content.map(block => block.text).join('\n');
150
+ }
151
+ /**
152
+ * Project ordered MCP blocks into the core content vocabulary.
153
+ * Text-like runs are newline-coalesced; admitted images split those runs at
154
+ * their original position.
155
+ */
156
+ function projectContent(mcpContent, toolName, image = block => ({
157
+ type: 'text',
158
+ text: imageDiagnostic(block, 'this result was not admitted to durable model context')
159
+ })) {
160
+ const projected = [];
161
+ const text = [];
162
+ const flushText = () => {
163
+ if (text.length === 0)
164
+ return;
165
+ projected.push({ type: 'text', text: text.splice(0).join('\n') });
166
+ };
167
+ for (const [index, value] of mcpContent.entries()) {
168
+ if (!isRecord(value)) {
169
+ text.push('[unsupported MCP content block: expected an object]');
170
+ continue;
171
+ }
172
+ const block = value;
173
+ switch (block.type) {
174
+ case 'text':
175
+ if (block.text !== undefined)
176
+ text.push(block.text);
177
+ break;
178
+ case 'image':
179
+ flushText();
180
+ projected.push(image(block, index));
181
+ break;
182
+ case 'resource_link':
183
+ if (block.name === undefined || block.uri === undefined) {
184
+ text.push('[resource link unavailable: the MCP block is missing its name or URI]');
185
+ }
186
+ else {
187
+ text.push(`Resource link: ${block.name} (${block.uri})`);
188
+ }
189
+ break;
190
+ case 'audio':
191
+ text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`);
192
+ break;
193
+ case 'resource':
194
+ text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]');
195
+ break;
196
+ default:
197
+ text.push(`[unsupported MCP content type: ${block.type}]`);
198
+ }
199
+ }
200
+ flushText();
201
+ return projected.length > 0 ? projected : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }];
202
+ }
@@ -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
- }
@@ -20,6 +20,22 @@ import { PLUGIN_ROOT_VARIABLES, PLUGIN_DATA_VARIABLES } from '../model/layouts.j
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) {
@@ -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
  }