dsh-agent-plugins-market 0.7.0 → 0.7.2

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 (114) hide show
  1. package/README.md +31 -26
  2. package/README.zh.md +31 -26
  3. package/client/client.js +29164 -1136
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +8 -1
  6. package/lib/application/catalog.js +27 -2
  7. package/lib/application/details.js +13 -2
  8. package/lib/application/install-store.js +1 -1
  9. package/lib/application/mcp-service.js +191 -19
  10. package/lib/application/panel-resources.js +16 -4
  11. package/lib/application/project-agent-roles.js +7 -1
  12. package/lib/catalog/archive.js +16 -1
  13. package/lib/catalog/manifests.js +124 -13
  14. package/lib/catalog/paths.js +8 -0
  15. package/lib/catalog/plugin-variables.js +38 -0
  16. package/lib/catalog/project-hooks.js +14 -1
  17. package/lib/catalog/scan-resolvers.js +117 -12
  18. package/lib/catalog/skills-parse.js +47 -0
  19. package/lib/catalog/source-catalog.js +8 -3
  20. package/lib/catalog/suite-components.js +2 -2
  21. package/lib/catalog/surfaces.js +73 -27
  22. package/lib/catalog/validate.js +205 -54
  23. package/lib/contracts/market.js +3 -0
  24. package/lib/contracts/settings.js +63 -0
  25. package/lib/index.js +32 -13
  26. package/lib/model/layouts.js +44 -1
  27. package/lib/model/preset-source.js +8 -0
  28. package/lib/routes.js +43 -2
  29. package/lib/runtime/agent-role-router.js +203 -56
  30. package/lib/runtime/commands-mounts.js +50 -9
  31. package/lib/runtime/deadline.js +32 -6
  32. package/lib/runtime/dynamic-context.js +140 -0
  33. package/lib/runtime/feedback-tool.js +7 -3
  34. package/lib/runtime/host-locale.js +45 -15
  35. package/lib/runtime/lsp-mounts.js +23 -2
  36. package/lib/runtime/mcp-backend.js +14 -15
  37. package/lib/runtime/mcp-client/config.js +8 -0
  38. package/lib/runtime/mcp-client/connection.js +9 -8
  39. package/lib/runtime/mcp-client/host-seams.js +8 -88
  40. package/lib/runtime/mcp-client/oauth.js +82 -14
  41. package/lib/runtime/mcp-client/tools.js +2 -1
  42. package/lib/runtime/mcp-config.js +182 -35
  43. package/lib/runtime/mcp-direct-config.js +49 -6
  44. package/lib/runtime/mcp-mounts.js +15 -3
  45. package/lib/runtime/mcp-overrides.js +131 -1
  46. package/lib/runtime/mcp-redaction.js +8 -0
  47. package/lib/runtime/mcp-status.js +21 -5
  48. package/lib/runtime/project-runtime.js +23 -9
  49. package/lib/runtime/reconcile-scheduler.js +6 -8
  50. package/lib/runtime/reconciler.js +2 -1
  51. package/lib/runtime/regions.js +0 -19
  52. package/lib/runtime/server-config.js +49 -8
  53. package/lib/runtime/settings-namespace.js +21 -13
  54. package/lib/runtime/skills-provider.js +33 -11
  55. package/lib/runtime/source-auto-update.js +10 -14
  56. package/lib/runtime/subagent-catalog.js +62 -22
  57. package/lib/runtime/timer-seat.js +80 -0
  58. package/lib/runtime/tool-registry-observer.js +24 -20
  59. package/lib/runtime/user-commands.js +5 -1
  60. package/lib/runtime/user-panels.js +110 -41
  61. package/lib/runtime/user-store.js +114 -58
  62. package/lib/types/application/catalog-context.d.ts +6 -0
  63. package/lib/types/application/catalog.d.ts +19 -1
  64. package/lib/types/application/install-store.d.ts +1 -1
  65. package/lib/types/application/mcp-service.d.ts +38 -3
  66. package/lib/types/application/ports.d.ts +1 -1
  67. package/lib/types/application/queries.d.ts +8 -1
  68. package/lib/types/catalog/manifests.d.ts +6 -2
  69. package/lib/types/catalog/paths.d.ts +6 -0
  70. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  71. package/lib/types/catalog/skills-parse.d.ts +13 -0
  72. package/lib/types/catalog/source-catalog.d.ts +7 -2
  73. package/lib/types/catalog/surfaces.d.ts +14 -4
  74. package/lib/types/catalog/validate.d.ts +72 -11
  75. package/lib/types/contracts/market.d.ts +77 -0
  76. package/lib/types/contracts/mcp-status.d.ts +11 -0
  77. package/lib/types/contracts/settings.d.ts +59 -0
  78. package/lib/types/model/layouts.d.ts +30 -0
  79. package/lib/types/model/preset-source.d.ts +18 -0
  80. package/lib/types/model/types.d.ts +37 -1
  81. package/lib/types/runtime/agent-role-router.d.ts +96 -18
  82. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  83. package/lib/types/runtime/deadline.d.ts +10 -11
  84. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  85. package/lib/types/runtime/host-locale.d.ts +20 -2
  86. package/lib/types/runtime/lsp-mounts.d.ts +7 -0
  87. package/lib/types/runtime/mcp-backend.d.ts +8 -15
  88. package/lib/types/runtime/mcp-client/config.d.ts +8 -0
  89. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  90. package/lib/types/runtime/mcp-client/oauth.d.ts +4 -0
  91. package/lib/types/runtime/mcp-config.d.ts +75 -13
  92. package/lib/types/runtime/mcp-direct-config.d.ts +22 -0
  93. package/lib/types/runtime/mcp-overrides.d.ts +44 -0
  94. package/lib/types/runtime/mcp-redaction.d.ts +6 -0
  95. package/lib/types/runtime/project-runtime.d.ts +10 -3
  96. package/lib/types/runtime/reconcile-scheduler.d.ts +1 -1
  97. package/lib/types/runtime/regions.d.ts +1 -4
  98. package/lib/types/runtime/server-config.d.ts +26 -1
  99. package/lib/types/runtime/settings-namespace.d.ts +10 -10
  100. package/lib/types/runtime/skills-provider.d.ts +11 -2
  101. package/lib/types/runtime/source-auto-update.d.ts +3 -4
  102. package/lib/types/runtime/subagent-catalog.d.ts +14 -2
  103. package/lib/types/runtime/timer-seat.d.ts +51 -0
  104. package/lib/types/runtime/tool-registry-observer.d.ts +14 -6
  105. package/lib/types/runtime/user-panels.d.ts +65 -12
  106. package/lib/types/runtime/user-store.d.ts +65 -16
  107. package/package.json +23 -6
  108. package/schemas/1.0.0/mcp.schema.json +0 -22
  109. package/schemas/1.1.0/mcp.schema.json +120 -0
  110. package/schemas/1.1.0/plugin.schema.json +65 -0
  111. package/schemas/README.md +25 -19
  112. package/schemas/agent-plugins/spec.md +23 -9
  113. package/schemas/com.deepseek.harness/namespace.schema.json +42 -0
  114. package/schemas/com.deepseek.harness/spec.md +52 -0
@@ -1,88 +1,8 @@
1
- /**
2
- * Local ports of the tiny host seams the self-built MCP client needs.
3
- *
4
- * The market plugin must stay self-contained at runtime: it cannot import
5
- * host-internal packages that are not published at compatible versions, and
6
- * every one of these seams is a small pure function or constant. Each port
7
- * below is copied verbatim (semantics, not prose) from the harness source at
8
- * `0.1.1-rc.2` and carries the upstream reference it came from, so a future
9
- * host change can be re-diffed against `~/workspace/deepseek-harness`.
10
- *
11
- * Anything larger than a handful of lines (the JSON Schema subset validator)
12
- * lives in its own module.
13
- *
14
- * @module runtime/mcp-client/host-seams
15
- */
16
- // ---- dsh-timeout: MAX_TIMER_DELAY_MS (packages/util/timeout/src/index.ts) ----
17
- /**
18
- * The largest delay `setTimeout` accepts without overflowing to a 1ms clamp
19
- * (2^31 - 1). Shared guard for reconnect and callback timers.
20
- */
21
- export const MAX_TIMER_DELAY_MS = 2_147_483_647;
22
- // ---- dsh-subprocess: scrubbedParentEnv (packages/subprocess/subprocess/src/index.ts) ----
23
- /** `DSH_*` environment namespace owned by the harness. */
24
- export const DSH_ENV_PREFIX = 'DSH_';
25
- /**
26
- * Credential-shaped environment names are NOT forwarded to MCP server child
27
- * processes (the harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a
28
- * spawned server implicitly). Deliberately supplied env entries survive
29
- * because the config's explicit env merges after the scrub.
30
- */
31
- export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i;
32
- /**
33
- * The ambient parent environment minus credential-shaped names and minus all
34
- * `DSH_*` names — the canonical base every harness child starts from. `PATH`,
35
- * `HOME`, locale, and proxy variables survive, so child CLIs run normally;
36
- * harness identity never leaks implicitly. Both scrubs match case-insensitively:
37
- * Windows environment names are case-insensitive, so a parent `dsh_*` entry
38
- * would otherwise survive and read back as `$env:DSH_*` in the child.
39
- * @returns a fresh environment object safe to hand to a child spawn.
40
- */
41
- export function scrubbedParentEnv() {
42
- const env = {};
43
- for (const [key, value] of Object.entries(process.env)) {
44
- if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX))
45
- env[key] = value;
46
- }
47
- return env;
48
- }
49
- /** Key segments are lowercase hyphenated identifiers. */
50
- const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/;
51
- /**
52
- * Brand a scope and an id as a {@link CredentialKey}.
53
- * @param scope - the owning plugin's registered name (here: the `mcp-auth` record scope).
54
- * @param id - the plugin's own addressing unit (here: the folded serverName).
55
- * @returns the branded key.
56
- * @throws TypeError when either segment is not a lowercase hyphenated identifier.
57
- */
58
- export function credentialKey(scope, id) {
59
- for (const segment of [scope, id]) {
60
- if (!KEY_SEGMENT_PATTERN.test(segment)) {
61
- throw new TypeError(`credential key segment "${segment}" must match ${String(KEY_SEGMENT_PATTERN)}`);
62
- }
63
- }
64
- return `${scope}/${id}`;
65
- }
66
- // ---- dsh-attachment: image admission error routing (packages/attachment/attachment/src/error.ts) ----
67
- /** Caller-correctable attachment failure codes raised while admitting image input. */
68
- const IMAGE_ADMISSION_ERROR_CODES = [
69
- 'TOO_MANY_IMAGES',
70
- 'IMAGES_TOO_LARGE',
71
- 'UNSUPPORTED_IMAGE_TYPE',
72
- 'INVALID_IMAGE_BASE64',
73
- 'INVALID_IMAGE',
74
- 'IMAGE_TYPE_MISMATCH',
75
- 'IMAGE_TOO_LARGE',
76
- 'IMAGE_TOO_MANY_PIXELS',
77
- 'IMAGE_DIMENSION_TOO_LARGE'
78
- ];
79
- /** Runtime membership for structurally compatible errors crossing package boundaries. */
80
- const IMAGE_ADMISSION_ERROR_CODE_SET = new Set(IMAGE_ADMISSION_ERROR_CODES);
81
- /**
82
- * Distinguish caller-correctable image admission failures from storage faults,
83
- * so an admission refusal projects text diagnostics while a storage fault is
84
- * reported as such.
85
- */
86
- export function isImageAdmissionError(error) {
87
- return error instanceof Error && 'code' in error && typeof error.code === 'string' && IMAGE_ADMISSION_ERROR_CODE_SET.has(error.code);
88
- }
1
+ // ---- dsh-timeout: timer bounds ----
2
+ export { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout';
3
+ // ---- dsh-subprocess: the canonical child environment ----
4
+ export { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess';
5
+ // ---- dsh-credentials: key grammar ----
6
+ export { credentialKey } from '@deepseek-ai/dsh-credentials';
7
+ // ---- dsh-attachment: durable image vocabulary ----
8
+ export { isImageAdmissionError } from '@deepseek-ai/dsh-attachment';
@@ -28,7 +28,10 @@
28
28
  * @module runtime/mcp-client/oauth
29
29
  */
30
30
  import { createServer } from 'node:http';
31
+ import { randomBytes } from 'node:crypto';
32
+ import { isDeepStrictEqual } from 'node:util';
31
33
  import { credentialKey, scrubbedParentEnv } from './host-seams.js';
34
+ import { redactErrorMessage, redactUrl } from '../mcp-redaction.js';
32
35
  import { PLUGIN_NAME, PLUGIN_VERSION } from './plugin-identity.js';
33
36
  /** OAuth client identity presented during dynamic registration. */
34
37
  const CLIENT_NAME = `DeepSeek Harness (${PLUGIN_NAME})`;
@@ -65,6 +68,18 @@ function asMcpAuthRecord(record) {
65
68
  ...(payload.tokens === undefined ? {} : { tokens: payload.tokens })
66
69
  };
67
70
  }
71
+ /**
72
+ * Merge one locally changed durable field without overwriting a concurrent
73
+ * process's update. If the value read before the mutation no longer matches
74
+ * the store's locked current value, the current value wins conservatively.
75
+ */
76
+ function mergeDurableField(base, desired, current) {
77
+ if (isDeepStrictEqual(base, desired))
78
+ return current;
79
+ if (isDeepStrictEqual(base, current))
80
+ return desired;
81
+ return current;
82
+ }
68
83
  /**
69
84
  * Error thrown when the human leg fails or is abandoned: the browser never
70
85
  * returned, the callback carried an OAuth error, or no browser could open.
@@ -89,7 +104,7 @@ export const platformBrowserOpener = async (authorizationUrl, serverName, log) =
89
104
  detached: platform !== 'win32'
90
105
  });
91
106
  child.once('error', error => {
92
- log(`${serverName}: could not open a browser automatically (${String(error)}); open this URL manually: ${authorizationUrl.toString()}`);
107
+ log(`${serverName}: could not open a browser automatically (${redactErrorMessage(String(error))}); open this URL manually: ${redactUrl(authorizationUrl.toString())}`);
93
108
  });
94
109
  child.once('spawn', () => {
95
110
  child.unref();
@@ -113,6 +128,8 @@ export class LoopbackOAuthClientProvider {
113
128
  providerState = {};
114
129
  /** The transport generation whose `finishAuth` completes the browser leg. */
115
130
  activeTransport;
131
+ /** One process-local OAuth state value for the current browser leg. */
132
+ expectedState;
116
133
  /** Resolves `true` when the leg saved tokens, `false` when it failed; undefined while no leg is open. */
117
134
  browserLegSettled;
118
135
  callbackServer;
@@ -148,6 +165,12 @@ export class LoopbackOAuthClientProvider {
148
165
  software_version: PLUGIN_VERSION
149
166
  };
150
167
  }
168
+ /** Issue one unpredictable state value for the SDK's authorization request. */
169
+ state() {
170
+ const state = randomBytes(32).toString('base64url');
171
+ this.expectedState = state;
172
+ return state;
173
+ }
151
174
  async clientInformation() {
152
175
  return (await this.load()).clientInformation;
153
176
  }
@@ -177,6 +200,11 @@ export class LoopbackOAuthClientProvider {
177
200
  if (transport === undefined) {
178
201
  throw new AuthorizationAbortedError(`${this.serverName}: no active transport to complete authorization into`);
179
202
  }
203
+ // The SDK normally calls state() before this hook. Keep a defensive path
204
+ // for direct callers too, and overwrite any caller-supplied value with the
205
+ // state issued by this provider.
206
+ const state = this.expectedState ?? this.state();
207
+ authorizationUrl.searchParams.set('state', state);
180
208
  this.browserLegSettled = Promise.withResolvers();
181
209
  let succeeded = false;
182
210
  try {
@@ -222,6 +250,7 @@ export class LoopbackOAuthClientProvider {
222
250
  this.callbackServer?.close(() => undefined);
223
251
  this.callbackServer = undefined;
224
252
  this.boundPort = undefined;
253
+ this.expectedState = undefined;
225
254
  }
226
255
  saveCodeVerifier(codeVerifier) {
227
256
  // The verifier only needs to survive the browser leg; keeping it out of
@@ -287,17 +316,41 @@ export class LoopbackOAuthClientProvider {
287
316
  async mutate(mutator) {
288
317
  const run = this.writeChain.then(async () => {
289
318
  await this.load();
319
+ const base = {
320
+ clientInformation: this.providerState.clientInformation,
321
+ tokens: this.providerState.tokens
322
+ };
290
323
  mutator(this.providerState);
291
324
  if (this.store === undefined)
292
325
  return;
293
- const record = {
294
- kind: 'grant',
295
- payload: {
296
- ...(this.providerState.clientInformation === undefined ? {} : { clientInformation: this.providerState.clientInformation }),
297
- ...(this.providerState.tokens === undefined ? {} : { tokens: this.providerState.tokens })
298
- }
326
+ const desired = {
327
+ clientInformation: this.providerState.clientInformation,
328
+ tokens: this.providerState.tokens
329
+ };
330
+ const saved = await this.store.modifyRecord(this.recordKey, async (current) => {
331
+ const currentPayload = asMcpAuthRecord(current);
332
+ // Never replace a record owned by another credential provider just
333
+ // because this process raced it; the next read will observe it as
334
+ // absent and the caller can decide what to do.
335
+ if (current !== undefined && currentPayload === undefined)
336
+ return current;
337
+ const payload = currentPayload ?? {};
338
+ const clientInformation = mergeDurableField(base.clientInformation, desired.clientInformation, payload.clientInformation);
339
+ const tokens = mergeDurableField(base.tokens, desired.tokens, payload.tokens);
340
+ const merged = {
341
+ kind: 'grant',
342
+ payload: {
343
+ ...(clientInformation === undefined ? {} : { clientInformation }),
344
+ ...(tokens === undefined ? {} : { tokens })
345
+ }
346
+ };
347
+ return merged;
348
+ });
349
+ const savedPayload = asMcpAuthRecord(saved);
350
+ this.providerState = {
351
+ ...(savedPayload ?? {}),
352
+ ...(this.providerState.codeVerifier === undefined ? {} : { codeVerifier: this.providerState.codeVerifier })
299
353
  };
300
- await this.store.modifyRecord(this.recordKey, () => Promise.resolve(record));
301
354
  });
302
355
  // A failed write must not poison the chain; the caller's rejection is the
303
356
  // signal, and the next write re-reads whatever is durable now.
@@ -317,7 +370,7 @@ export class LoopbackOAuthClientProvider {
317
370
  });
318
371
  server.on('error', error => {
319
372
  this.clearCallbackTimer();
320
- reject(new AuthorizationAbortedError(`${this.serverName}: loopback callback server failed: ${String(error)}`));
373
+ reject(new AuthorizationAbortedError(`${this.serverName}: loopback callback server failed: ${redactErrorMessage(String(error))}`));
321
374
  });
322
375
  server.listen(this.storage.callbackPort ?? 0, '127.0.0.1', () => {
323
376
  const address = server.address();
@@ -343,19 +396,34 @@ export class LoopbackOAuthClientProvider {
343
396
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
344
397
  const oauthError = url.searchParams.get('error');
345
398
  const code = url.searchParams.get('code');
346
- const respond = (status, message) => {
399
+ const respond = (status, message, closeServer = true) => {
347
400
  // Drain the request, then answer with a plain page the user can close.
348
401
  request.resume();
349
402
  request.socket.end(`HTTP/1.1 ${status} ${status === 200 ? 'OK' : 'Bad Request'}\r\n` + 'content-type: text/plain; charset=utf-8\r\nconnection: close\r\n\r\n' + `${message}\n`);
350
- request.on('close', () => {
351
- server.close(() => undefined);
352
- });
403
+ if (closeServer) {
404
+ request.on('close', () => {
405
+ server.close(() => undefined);
406
+ });
407
+ }
353
408
  };
409
+ // Keep the listener alive for malformed or forged callbacks so the real
410
+ // browser redirect can still complete the same authorization leg.
411
+ if (url.pathname !== '/callback') {
412
+ respond(404, 'Not an OAuth callback endpoint.', false);
413
+ return;
414
+ }
415
+ const expectedState = this.expectedState;
416
+ const callbackState = url.searchParams.get('state');
417
+ if (expectedState === undefined || callbackState !== expectedState) {
418
+ respond(400, 'Authorization callback carried an invalid state.', false);
419
+ return;
420
+ }
354
421
  this.clearCallbackTimer();
355
422
  this.callbackServer = undefined;
356
423
  this.boundPort = undefined;
424
+ this.expectedState = undefined;
357
425
  if (oauthError !== null) {
358
- const description = url.searchParams.get('error_description') ?? '';
426
+ const description = redactErrorMessage(url.searchParams.get('error_description') ?? '');
359
427
  respond(400, `Authorization failed: ${oauthError}${description === '' ? '' : ` — ${description}`}`);
360
428
  reject(new AuthorizationAbortedError(`${this.serverName}: the authorization server returned "${oauthError}"${description === '' ? '' : ` (${description})`}`));
361
429
  return;
@@ -21,6 +21,7 @@ import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
21
21
  import { z } from 'zod';
22
22
  import { assertSupportedJsonSchema } from './json-schema-subset.js';
23
23
  import { containsImage, extractText, prepareImageProjection } from './projection.js';
24
+ import { redactErrorMessage } from '../mcp-redaction.js';
24
25
  /**
25
26
  * DeepSeek function-name contract: at most 64 characters. Wire-protocol
26
27
  * constant, not configuration.
@@ -122,7 +123,7 @@ export async function syncTools(client, host, opts, previous) {
122
123
  // sees either the full generation or none of it — never a partial set.
123
124
  for (const dispose of disposers.values())
124
125
  dispose();
125
- host.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`);
126
+ host.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${redactErrorMessage(String(error))}`);
126
127
  if (opts.registrationFailure === 'throw')
127
128
  throw error;
128
129
  return new Map();
@@ -4,16 +4,25 @@
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
- * 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.
7
+ * the child environment carries both variables (§9.1) with the data directory
8
+ * created before launch. Legacy HTTP+SSE servers are supported by the
9
+ * market's own bridge (the host client had no such transport); unrecognized
10
+ * shapes are still skipped with a per-server reason.
11
+ *
12
+ * Placeholder discipline follows the package's origin. A suite mounted from
13
+ * the portable v1 format (`portable: true`) is package data: §9.2 forbids any
14
+ * expansion beyond the two built-in variables, and per-server client policy
15
+ * (OAuth, tool lists, timeouts) comes from the `com.deepseek.harness`
16
+ * namespace instead of the `mcp.json` body. Dialect-native files and
17
+ * user-owned config keep the credential seam: every `${NAME}` — including
18
+ * ones inside a streamable-http `url` — expands through the optional DSH
19
+ * credentials resolver at mount time, and missing references fail closed per
20
+ * server instead of becoming empty strings.
12
21
  */
13
22
  import { createHash } from 'node:crypto';
14
- import { DEFAULT_TOOL_CALL_TIMEOUT_MS } from './mcp-client/config.js';
23
+ import { DEFAULT_STARTUP_TIMEOUT_MS, DEFAULT_TOOL_CALL_TIMEOUT_MS } from './mcp-client/config.js';
15
24
  import { resolveCwd } from '../catalog/validate.js';
16
- import { qualifiedSuiteId } from '../catalog/paths.js';
25
+ import { qualifiedSuiteId, suiteDataDir } from '../catalog/paths.js';
17
26
  import { applyOverride } from './mcp-overrides.js';
18
27
  import { PLUGIN_ROOT_VARIABLES, PLUGIN_DATA_VARIABLES } from '../model/layouts.js';
19
28
  /** The max length the bridge accepts for a serverName. */
@@ -21,26 +30,10 @@ const SERVER_NAME_MAX = 32;
21
30
  const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
22
31
  const BUILTIN_PLACEHOLDERS = new Set([...PLUGIN_ROOT_VARIABLES, ...PLUGIN_DATA_VARIABLES]);
23
32
  /**
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
- }
39
- /**
40
- * Build one mount request per supported mcp.json server, resolving every
41
- * `${NAME}` through the credential resolver before a child process or HTTP
42
- * request is created. Missing references fail closed per server instead of
43
- * becoming empty strings.
33
+ * Build one mount request per supported mcp.json server, expanding built-in
34
+ * variables (and, for user-owned data, `${NAME}` credential references)
35
+ * before a child process or HTTP request is created. Missing references fail
36
+ * closed per server instead of becoming empty strings.
44
37
  *
45
38
  * @param overrides user-owned per-server overrides (url/headers/env/args
46
39
  * replacement plus enable/disable); applied before mount. Disabled servers
@@ -74,6 +67,141 @@ export async function toMcpMounts(suite, pluginDataRoot, overrides = {}, resolve
74
67
  }
75
68
  return { mounts, failures, credentialRefs: [...credentialRefs] };
76
69
  }
70
+ /**
71
+ * Whether a suite's MCP declarations are package data under the portable
72
+ * format. Installed v1 suites are; the user's own `~/.agents/mcp.json`,
73
+ * project-native config, and every dialect-native file are user-owned data
74
+ * where `${NAME}` credential references stay resolvable.
75
+ */
76
+ export function isPortableMcp(suite) {
77
+ return suite.dimension !== 'user' && suite.manifest.layout === 'agent-plugin-v1';
78
+ }
79
+ /**
80
+ * The `com.deepseek.harness` policy declared for one portable server.
81
+ * Server keys in the namespace match the server's own name in `mcp.json`;
82
+ * `serverKey` is that name at discovery time.
83
+ */
84
+ export function namespaceMcpPolicy(suite, serverKey) {
85
+ return suite.manifest.harness?.mcpServers?.[serverKey];
86
+ }
87
+ /**
88
+ * The effective view of one suite's `mcp.json` servers: the user's per-server
89
+ * overrides applied, every row flagged with whether it is used at all, and each
90
+ * row's credential references read off the effective shape. Mounting, status
91
+ * and detail rendering read this one projection, so a server cannot be listed
92
+ * as enabled while mounting something else.
93
+ *
94
+ * Portable v1 suites (`isPortableMcp`) carry package data: their credential
95
+ * reference list is empty by definition (§9.2 leaves unrecognized
96
+ * placeholder-like text literal), and client policy comes from the
97
+ * `com.deepseek.harness` namespace instead. Overrides applied by the user are
98
+ * user-owned data, so a reference a user typed into an override still
99
+ * resolves.
100
+ */
101
+ export function effectiveMcpServers(suite, overrides = {}) {
102
+ const rows = [];
103
+ const portable = isPortableMcp(suite);
104
+ for (const [serverKey, source] of Object.entries(suite.mcp?.servers ?? {})) {
105
+ const override = overrides[serverKey];
106
+ const declared = namespaceMcpPolicy(suite, serverKey);
107
+ const policy = resolveMcpPolicy(declaredMcpPolicy(source, declared), override);
108
+ const connection = applyNamespaceAuth(applyOverride(source, override), declared);
109
+ const effective = applyMcpPolicy(connection, policy);
110
+ const credentialRefs = portable ? credentialRefsInServer(overrideValues(override)) : credentialRefsInServer(effective);
111
+ rows.push({ serverKey, server: effective, override, enabled: override?.enabled !== false, credentialRefs, policy });
112
+ }
113
+ return rows;
114
+ }
115
+ /** Credential references a user typed into their own override values. */
116
+ function overrideValues(override) {
117
+ if (override === undefined)
118
+ return { type: 'stdio', command: '' };
119
+ return {
120
+ type: 'stdio',
121
+ command: '',
122
+ ...(override.args !== undefined ? { args: override.args } : {}),
123
+ ...(override.env !== undefined ? { env: override.env } : {}),
124
+ ...(override.headers !== undefined ? { headers: override.headers } : {}),
125
+ ...(override.url !== undefined ? { url: override.url, type: 'streamable-http' } : {})
126
+ };
127
+ }
128
+ /**
129
+ * The suite's declared per-server policy. The `com.deepseek.harness`
130
+ * namespace seat carries the policy a portable `mcp.json` has no room for and
131
+ * wins over an inline declaration; dialect-native files declare it inline.
132
+ */
133
+ export function declaredMcpPolicy(server, policy) {
134
+ const enabledTools = policy?.enabledTools ?? server.enabledTools;
135
+ const disabledTools = policy?.disabledTools ?? server.disabledTools;
136
+ const startupTimeoutMs = policy?.startupTimeoutMs ?? server.startupTimeoutMs;
137
+ const toolCallTimeoutMs = policy?.toolCallTimeoutMs ?? server.toolCallTimeoutMs;
138
+ return {
139
+ ...(enabledTools === undefined ? {} : { enabledTools }),
140
+ ...(disabledTools === undefined ? {} : { disabledTools }),
141
+ ...(startupTimeoutMs === undefined ? {} : { startupTimeoutMs }),
142
+ ...(toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs })
143
+ };
144
+ }
145
+ /**
146
+ * Bring the suite's declaration and the user's override together.
147
+ *
148
+ * A timeout is a plain override: the user's value wins, a declaration fills in
149
+ * when the user set none, and the built-in default stands behind both. Tool
150
+ * filtering only tightens: the effective deny list is the suite's entries
151
+ * unioned with the user's, and the suite's allow-list stands as declared, so a
152
+ * user cannot open a tool the suite left out.
153
+ */
154
+ export function resolveMcpPolicy(declared, override) {
155
+ const enabledTools = dedupe(declared.enabledTools);
156
+ const suiteDisabledTools = dedupe(declared.disabledTools);
157
+ const disabledTools = dedupe([...(declared.disabledTools ?? []), ...(override?.disabledTools ?? [])]);
158
+ return {
159
+ ...(enabledTools === undefined ? {} : { enabledTools }),
160
+ ...(suiteDisabledTools === undefined ? {} : { suiteDisabledTools }),
161
+ ...(disabledTools === undefined ? {} : { disabledTools }),
162
+ toolCallTimeout: timeoutResolution(override?.toolCallTimeoutMs, declared.toolCallTimeoutMs, DEFAULT_TOOL_CALL_TIMEOUT_MS),
163
+ startupTimeout: timeoutResolution(override?.startupTimeoutMs, declared.startupTimeoutMs, DEFAULT_STARTUP_TIMEOUT_MS)
164
+ };
165
+ }
166
+ function timeoutResolution(user, suite, fallback) {
167
+ const effective = user ?? suite ?? fallback;
168
+ return {
169
+ user: user ?? null,
170
+ suite: suite ?? null,
171
+ effective,
172
+ source: user !== undefined ? 'user' : suite !== undefined ? 'suite' : 'default'
173
+ };
174
+ }
175
+ /** Apply one resolved policy onto a server, in the shape the bridge reads. */
176
+ function applyMcpPolicy(server, policy) {
177
+ return {
178
+ ...server,
179
+ toolCallTimeoutMs: policy.toolCallTimeout.effective,
180
+ // An undeclared startup timeout stays absent: the built-in bridge applies
181
+ // its own default at connect time, and host compatibility mode, which
182
+ // cannot enforce one, keeps accepting the server.
183
+ ...(policy.startupTimeout.source === 'default' ? {} : { startupTimeoutMs: policy.startupTimeout.effective }),
184
+ ...(policy.enabledTools === undefined ? {} : { enabledTools: policy.enabledTools }),
185
+ ...(policy.disabledTools === undefined ? {} : { disabledTools: policy.disabledTools })
186
+ };
187
+ }
188
+ /**
189
+ * Apply the namespace policy's OAuth seat onto one server. A remote server
190
+ * keeps its OAuth opt-out from the namespace; a policy on a portable server
191
+ * also supplies the credential seam the portable file may not declare.
192
+ */
193
+ function applyNamespaceAuth(server, policy) {
194
+ if (policy?.auth === undefined || server.type === 'stdio')
195
+ return server;
196
+ return { ...server, auth: { enabled: policy.auth.enabled, ...(policy.auth.scope === undefined ? {} : { scope: policy.auth.scope }) } };
197
+ }
198
+ /** Deduplicate a tool-name list, preserving declaration order; an empty result is absent. */
199
+ function dedupe(values) {
200
+ if (values === undefined)
201
+ return undefined;
202
+ const names = [...new Set(values.filter(name => name !== ''))];
203
+ return names.length > 0 ? names : undefined;
204
+ }
77
205
  /** Find external credential references used by one MCP server definition. */
78
206
  export function credentialRefsInServer(server) {
79
207
  const values = [];
@@ -96,9 +224,11 @@ export function credentialRefsInServer(server) {
96
224
  return [...refs].sort();
97
225
  }
98
226
  async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolver) {
227
+ const portable = isPortableMcp(suite);
228
+ const dataDir = suiteDataDir(pluginDataRoot, suite.sourceId, suite.id);
99
229
  if (server.type === 'sse') {
100
230
  // The market bridge supports the legacy HTTP+SSE transport natively.
101
- const expand = expander(suite, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id)), resolver);
231
+ const expand = expander(suite, dataDir, resolver, portable);
102
232
  const url = await expand.one(server.url);
103
233
  const headers = await expand.map(server.headers ?? {});
104
234
  const missing = unique([...url.missing, ...headers.missing]);
@@ -115,7 +245,7 @@ async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolve
115
245
  };
116
246
  return { request: { suiteId: qualifiedSuiteId(suite.sourceId, suite.id), serverKey, config: sseConfig } };
117
247
  }
118
- const expand = expander(suite, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id)), resolver);
248
+ const expand = expander(suite, dataDir, resolver, portable);
119
249
  const serverName = deriveServerName(suite.id, serverKey);
120
250
  if (server.type === 'stdio') {
121
251
  const args = await expand.all(server.args ?? []);
@@ -133,8 +263,10 @@ async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolve
133
263
  serverName,
134
264
  command: server.command.startsWith('./') ? joinInside(suite.root, server.command.slice(2)) : server.command,
135
265
  args: args.values,
136
- env: env.values,
137
- cwd: resolveCwd(cwd.value, suite.root, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id))),
266
+ // §9.1: configured env overlays the base, then the client sets
267
+ // PLUGIN_ROOT and PLUGIN_DATA, replacing same-name entries.
268
+ env: { ...env.values, PLUGIN_ROOT: suite.root, PLUGIN_DATA: dataDir },
269
+ cwd: resolveCwd(cwd.value, suite.root, dataDir),
138
270
  ...bridgePolicy(server),
139
271
  failOnStartupError: true
140
272
  }
@@ -177,13 +309,24 @@ function mapAuth(auth) {
177
309
  function bridgePolicy(server) {
178
310
  return {
179
311
  toolCallTimeoutMs: server.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS,
312
+ // Only a DECLARED startup timeout is a policy: host compatibility mode
313
+ // cannot enforce one, so an undeclared server stays enforceable there and
314
+ // the built-in bridge applies its own default at connect time.
180
315
  ...(server.startupTimeoutMs === undefined ? {} : { startupTimeoutMs: server.startupTimeoutMs }),
181
316
  ...(server.enabledTools === undefined ? {} : { enabledTools: server.enabledTools }),
182
317
  ...(server.disabledTools === undefined ? {} : { disabledTools: server.disabledTools })
183
318
  };
184
319
  }
185
- /** Per-mount expansion context; credential lookups are memoized per call. */
186
- function expander(suite, pluginData, resolver) {
320
+ /**
321
+ * Per-mount expansion context.
322
+ *
323
+ * Portable package data (`portable: true`) honors §9.2: only the two built-in
324
+ * variables expand, single pass, and unrecognized placeholder-like text stays
325
+ * literal — the credential resolver is never consulted. User-owned data
326
+ * keeps the `${NAME}` seam, with `${NAME:-default}` fallbacks and missing
327
+ * references reported.
328
+ */
329
+ function expander(suite, pluginData, resolver, portable) {
187
330
  // Promise-valued cache: concurrent expansions of the same reference share
188
331
  // one in-flight lookup, so a config using a token twice resolves it once.
189
332
  const inflight = new Map();
@@ -211,15 +354,19 @@ function expander(suite, pluginData, resolver) {
211
354
  replacement = suite.root;
212
355
  else if (PLUGIN_DATA_VARIABLES.has(name))
213
356
  replacement = pluginData;
214
- else
357
+ else if (!portable)
215
358
  replacement = (await lookup(name))?.value;
216
359
  if (replacement === undefined || replacement === '') {
217
360
  if (fallback !== undefined && fallback !== '')
218
361
  replacement = fallback;
219
- else {
362
+ else if (!portable) {
220
363
  missing.push(name);
221
364
  replacement = '';
222
365
  }
366
+ else {
367
+ // §9.2: unrecognized placeholder-like text stays literal.
368
+ replacement = match[0];
369
+ }
223
370
  }
224
371
  output += replacement;
225
372
  cursor = index + match[0].length;
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
4
- import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalog/validate.js';
4
+ import { MCP_SCHEMA_ID, validateMcpJson } from '../catalog/validate.js';
5
5
  import { effectiveSurfaces } from '../model/types.js';
6
6
  export const USER_MCP_SOURCE = '@user-mcp';
7
7
  export const USER_MCP_SUITE = 'user-mcp';
@@ -44,13 +44,16 @@ export async function loadUserMcpSuite(agentsRoot) {
44
44
  errors
45
45
  };
46
46
  }
47
+ /**
48
+ * The user's own declaration file is local data, not a distributable package:
49
+ * keys this client does not know ride along untouched rather than failing the
50
+ * whole file, and the closed-set rule the package schema applies to suites
51
+ * does not apply here.
52
+ */
47
53
  async function validateUserMcp(agentsRoot, raw) {
48
- const errors = await validateAgainstSchema(MCP_SCHEMA_ID, raw);
49
- if (errors.length > 0)
50
- throw new Error(`invalid MCP configuration: ${errors.join('; ')}`);
51
- const result = await validateMcpJson(agentsRoot, raw);
54
+ const result = await validateMcpJson(agentsRoot, raw, { packageRules: false });
52
55
  if (result.config === undefined || result.errors.length > 0)
53
- throw new Error(result.errors.join('; '));
56
+ throw new Error(`invalid MCP configuration: ${result.errors.join('; ')}`);
54
57
  return result.config;
55
58
  }
56
59
  /** Add one service atomically; reject collisions and malformed config before writing. */
@@ -71,3 +74,43 @@ export async function addUserMcpServer(agentsRoot, name, server) {
71
74
  export function userMcpPath(agentsRoot) {
72
75
  return join(agentsRoot, 'mcp.json');
73
76
  }
77
+ const SERVER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
78
+ /**
79
+ * Import several pasted servers in one write.
80
+ *
81
+ * Every entry is judged on its own so one bad definition cannot take the rest
82
+ * of the paste with it, and an existing name is kept unless the caller asked to
83
+ * overwrite. The file is written once, after every entry has been checked, so a
84
+ * rejected entry never lands half-written beside the accepted ones.
85
+ */
86
+ export async function importUserMcpServers(agentsRoot, entries, overwrite = false) {
87
+ const suite = await loadUserMcpSuite(agentsRoot);
88
+ if (suite.errors.length > 0)
89
+ throw new Error(suite.errors.join('; '));
90
+ const servers = { ...suite.mcp.servers };
91
+ const imported = [];
92
+ const skipped = [];
93
+ for (const entry of entries) {
94
+ if (!SERVER_NAME_PATTERN.test(entry.name)) {
95
+ skipped.push({ name: entry.name, reason: 'invalid name' });
96
+ continue;
97
+ }
98
+ if (Object.hasOwn(servers, entry.name) && !overwrite) {
99
+ skipped.push({ name: entry.name, reason: 'already exists' });
100
+ continue;
101
+ }
102
+ try {
103
+ await validateUserMcp(agentsRoot, { $schema: MCP_SCHEMA_ID, mcpServers: { ...servers, [entry.name]: entry.server } });
104
+ }
105
+ catch (reason) {
106
+ skipped.push({ name: entry.name, reason: reason instanceof Error ? reason.message : String(reason) });
107
+ continue;
108
+ }
109
+ servers[entry.name] = entry.server;
110
+ imported.push(entry.name);
111
+ }
112
+ if (imported.length > 0) {
113
+ await writeFileAtomic(userMcpPath(agentsRoot), `${JSON.stringify({ $schema: MCP_SCHEMA_ID, mcpServers: servers }, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
114
+ }
115
+ return { imported, skipped };
116
+ }