dsh-agent-plugins-market 0.7.1 → 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.
- package/README.md +29 -24
- package/README.zh.md +29 -24
- package/client/client.js +1172 -159
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +14 -2
- package/lib/application/install-store.js +1 -1
- package/lib/application/mcp-service.js +185 -30
- package/lib/catalog/archive.js +15 -0
- package/lib/catalog/manifests.js +124 -13
- package/lib/catalog/scan-resolvers.js +117 -12
- package/lib/catalog/surfaces.js +73 -27
- package/lib/catalog/validate.js +205 -54
- package/lib/contracts/market.js +2 -0
- package/lib/index.js +6 -2
- package/lib/model/layouts.js +34 -0
- package/lib/model/preset-source.js +8 -0
- package/lib/routes.js +31 -2
- package/lib/runtime/agent-role-router.js +176 -51
- package/lib/runtime/host-locale.js +6 -12
- package/lib/runtime/mcp-client/connection.js +3 -2
- package/lib/runtime/mcp-client/oauth.js +82 -14
- package/lib/runtime/mcp-client/tools.js +2 -1
- package/lib/runtime/mcp-config.js +178 -34
- package/lib/runtime/mcp-direct-config.js +49 -6
- package/lib/runtime/mcp-mounts.js +15 -3
- package/lib/runtime/mcp-overrides.js +131 -1
- package/lib/runtime/mcp-redaction.js +8 -0
- package/lib/runtime/mcp-status.js +21 -5
- package/lib/runtime/server-config.js +49 -8
- package/lib/runtime/subagent-catalog.js +62 -22
- package/lib/runtime/tool-registry-observer.js +8 -4
- package/lib/types/application/catalog.d.ts +10 -1
- package/lib/types/application/install-store.d.ts +1 -1
- package/lib/types/application/mcp-service.d.ts +32 -3
- package/lib/types/application/queries.d.ts +6 -1
- package/lib/types/catalog/manifests.d.ts +6 -2
- package/lib/types/catalog/surfaces.d.ts +14 -4
- package/lib/types/catalog/validate.d.ts +72 -11
- package/lib/types/contracts/market.d.ts +67 -0
- package/lib/types/contracts/mcp-status.d.ts +11 -0
- package/lib/types/model/layouts.d.ts +20 -0
- package/lib/types/model/preset-source.d.ts +18 -0
- package/lib/types/model/types.d.ts +37 -1
- package/lib/types/runtime/agent-role-router.d.ts +90 -16
- package/lib/types/runtime/host-locale.d.ts +1 -1
- package/lib/types/runtime/mcp-client/oauth.d.ts +4 -0
- package/lib/types/runtime/mcp-config.d.ts +75 -13
- package/lib/types/runtime/mcp-direct-config.d.ts +22 -0
- package/lib/types/runtime/mcp-overrides.d.ts +44 -0
- package/lib/types/runtime/mcp-redaction.d.ts +6 -0
- package/lib/types/runtime/server-config.d.ts +26 -1
- package/lib/types/runtime/subagent-catalog.d.ts +14 -2
- package/lib/types/runtime/tool-registry-observer.d.ts +5 -3
- package/package.json +8 -8
- package/schemas/1.0.0/mcp.schema.json +0 -22
- package/schemas/1.1.0/mcp.schema.json +120 -0
- package/schemas/1.1.0/plugin.schema.json +65 -0
- package/schemas/README.md +25 -19
- package/schemas/agent-plugins/spec.md +23 -9
- package/schemas/com.deepseek.harness/namespace.schema.json +42 -0
- package/schemas/com.deepseek.harness/spec.md +52 -0
|
@@ -4,14 +4,23 @@
|
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
25
|
import { qualifiedSuiteId, suiteDataDir } from '../catalog/paths.js';
|
|
17
26
|
import { applyOverride } from './mcp-overrides.js';
|
|
@@ -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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
|
|
137
|
-
|
|
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
|
}
|
|
@@ -185,8 +317,16 @@ function bridgePolicy(server) {
|
|
|
185
317
|
...(server.disabledTools === undefined ? {} : { disabledTools: server.disabledTools })
|
|
186
318
|
};
|
|
187
319
|
}
|
|
188
|
-
/**
|
|
189
|
-
|
|
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) {
|
|
190
330
|
// Promise-valued cache: concurrent expansions of the same reference share
|
|
191
331
|
// one in-flight lookup, so a config using a token twice resolves it once.
|
|
192
332
|
const inflight = new Map();
|
|
@@ -214,15 +354,19 @@ function expander(suite, pluginData, resolver) {
|
|
|
214
354
|
replacement = suite.root;
|
|
215
355
|
else if (PLUGIN_DATA_VARIABLES.has(name))
|
|
216
356
|
replacement = pluginData;
|
|
217
|
-
else
|
|
357
|
+
else if (!portable)
|
|
218
358
|
replacement = (await lookup(name))?.value;
|
|
219
359
|
if (replacement === undefined || replacement === '') {
|
|
220
360
|
if (fallback !== undefined && fallback !== '')
|
|
221
361
|
replacement = fallback;
|
|
222
|
-
else {
|
|
362
|
+
else if (!portable) {
|
|
223
363
|
missing.push(name);
|
|
224
364
|
replacement = '';
|
|
225
365
|
}
|
|
366
|
+
else {
|
|
367
|
+
// §9.2: unrecognized placeholder-like text stays literal.
|
|
368
|
+
replacement = match[0];
|
|
369
|
+
}
|
|
226
370
|
}
|
|
227
371
|
output += replacement;
|
|
228
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,
|
|
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
|
|
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
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
3
|
import * as mcpBridge from './mcp-client/bridge.js';
|
|
3
4
|
import { toMcpMounts } from './mcp-config.js';
|
|
4
5
|
import { mcpCredentialResolver } from './mcp-credentials.js';
|
|
5
6
|
import { SerialPassQueue, RetryScheduler } from './mount-lifecycle.js';
|
|
6
|
-
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
7
|
+
import { qualifiedSuiteId, suiteDataDir } from '../catalog/paths.js';
|
|
8
|
+
import { redactErrorMessage } from './mcp-redaction.js';
|
|
7
9
|
export class McpMountRegistry {
|
|
8
10
|
ctx;
|
|
9
11
|
pluginDataRoot;
|
|
@@ -178,6 +180,16 @@ export class McpMountRegistry {
|
|
|
178
180
|
}
|
|
179
181
|
/** Mount one precomputed request (source config merged with overrides). */
|
|
180
182
|
async mountWith(request) {
|
|
183
|
+
// §9.1: the client-managed PLUGIN_DATA directory must exist and be
|
|
184
|
+
// writable before any plugin subprocess starts. Created recursively and
|
|
185
|
+
// idempotently on every mount so an uninstalled-then-reinstalled suite
|
|
186
|
+
// starts over with a fresh directory while an update keeps its contents.
|
|
187
|
+
if (request.config.transport === 'stdio') {
|
|
188
|
+
const separator = request.suiteId.indexOf('/');
|
|
189
|
+
const sourceId = separator === -1 ? request.suiteId : request.suiteId.slice(0, separator);
|
|
190
|
+
const suiteId = separator === -1 ? request.suiteId : request.suiteId.slice(separator + 1);
|
|
191
|
+
await mkdir(suiteDataDir(this.pluginDataRoot, sourceId, suiteId), { recursive: true });
|
|
192
|
+
}
|
|
181
193
|
const owner = this.serverOwner(request.config.serverName);
|
|
182
194
|
if (owner !== undefined) {
|
|
183
195
|
// Two sources shipping the same suite/server pair derive one serverName:
|
|
@@ -251,7 +263,7 @@ export class McpMountRegistry {
|
|
|
251
263
|
// Ignore teardown errors: the startup failure is the real signal.
|
|
252
264
|
}
|
|
253
265
|
}
|
|
254
|
-
return { reason: `mount failed: ${error instanceof Error ? error.message : String(error)}`, code: 'mount-failed' };
|
|
266
|
+
return { reason: `mount failed: ${redactErrorMessage(error instanceof Error ? error.message : String(error))}`, code: 'mount-failed' };
|
|
255
267
|
}
|
|
256
268
|
this.live.set(mountKey(request.suiteId, request.serverKey), {
|
|
257
269
|
suiteId: request.suiteId,
|
|
@@ -271,7 +283,7 @@ export class McpMountRegistry {
|
|
|
271
283
|
return undefined;
|
|
272
284
|
}
|
|
273
285
|
catch (error) {
|
|
274
|
-
const reason = `unmount failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
286
|
+
const reason = `unmount failed: ${redactErrorMessage(error instanceof Error ? error.message : String(error))}`;
|
|
275
287
|
this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${reason} (${live.suiteId}/${live.serverKey})`);
|
|
276
288
|
return reason;
|
|
277
289
|
}
|
|
@@ -15,6 +15,8 @@ import { readFile } from 'node:fs/promises';
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
17
17
|
import { isSensitiveKey } from './mcp-redaction.js';
|
|
18
|
+
/** The largest timeout a bridge config and the host timer can hold. */
|
|
19
|
+
export const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
18
20
|
/** The overrides directory under the plugin data root. */
|
|
19
21
|
export function overridesDir(dataRoot) {
|
|
20
22
|
return join(dataRoot, 'overrides');
|
|
@@ -69,11 +71,41 @@ export function sanitizeOverrides(raw) {
|
|
|
69
71
|
if (Array.isArray(record['args']) && record['args'].every(entry => typeof entry === 'string')) {
|
|
70
72
|
override.args = record['args'];
|
|
71
73
|
}
|
|
74
|
+
const toolCallTimeoutMs = timeoutMs(record['toolCallTimeoutMs']);
|
|
75
|
+
if (toolCallTimeoutMs !== undefined)
|
|
76
|
+
override.toolCallTimeoutMs = toolCallTimeoutMs;
|
|
77
|
+
const startupTimeoutMs = timeoutMs(record['startupTimeoutMs']);
|
|
78
|
+
if (startupTimeoutMs !== undefined)
|
|
79
|
+
override.startupTimeoutMs = startupTimeoutMs;
|
|
80
|
+
const disabledTools = toolNames(record['disabledTools']);
|
|
81
|
+
if (disabledTools !== undefined)
|
|
82
|
+
override.disabledTools = disabledTools;
|
|
72
83
|
if (Object.keys(override).length > 0)
|
|
73
84
|
result[serverKey] = override;
|
|
74
85
|
}
|
|
75
86
|
return result;
|
|
76
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Keep a timeout that a millisecond count can hold. Anything else passes
|
|
90
|
+
* nothing through, so one malformed field cannot take the rest of the record
|
|
91
|
+
* with it.
|
|
92
|
+
*/
|
|
93
|
+
function timeoutMs(raw) {
|
|
94
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0 || raw > MAX_TIMEOUT_MS)
|
|
95
|
+
return undefined;
|
|
96
|
+
return raw;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Keep a tool-name list for a deny list: non-empty names, deduplicated, with
|
|
100
|
+
* the empty result reading as "nothing denied" so the field never persists as
|
|
101
|
+
* an empty array.
|
|
102
|
+
*/
|
|
103
|
+
function toolNames(raw) {
|
|
104
|
+
if (!Array.isArray(raw))
|
|
105
|
+
return undefined;
|
|
106
|
+
const names = [...new Set(raw.filter((entry) => typeof entry === 'string' && entry !== ''))];
|
|
107
|
+
return names.length > 0 ? names : undefined;
|
|
108
|
+
}
|
|
77
109
|
/** Keep a well-formed auth block; anything else passes nothing through. */
|
|
78
110
|
function sanitizeAuth(raw) {
|
|
79
111
|
if (typeof raw !== 'object' || raw === null)
|
|
@@ -94,6 +126,69 @@ export function sanitizeOverridePatch(patch) {
|
|
|
94
126
|
return undefined;
|
|
95
127
|
return sanitizeOverrides({ server: patch })['server'];
|
|
96
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Validate one policy save as it arrives over the wire. An absent field leaves
|
|
131
|
+
* its stored value alone, `null` clears one back to inheritance, and a positive
|
|
132
|
+
* whole number of milliseconds within the timer range sets it; anything else
|
|
133
|
+
* rejects the save with a readable reason.
|
|
134
|
+
*/
|
|
135
|
+
export function parseTimeoutPatch(raw) {
|
|
136
|
+
if (raw === undefined)
|
|
137
|
+
return {};
|
|
138
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
139
|
+
throw new Error('policy must be an object');
|
|
140
|
+
const record = raw;
|
|
141
|
+
return {
|
|
142
|
+
...(record['toolCallTimeoutMs'] === undefined ? {} : { toolCallTimeoutMs: timeoutField(record['toolCallTimeoutMs'], 'tool call timeout') }),
|
|
143
|
+
...(record['startupTimeoutMs'] === undefined ? {} : { startupTimeoutMs: timeoutField(record['startupTimeoutMs'], 'startup timeout') })
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Validate a client-supplied policy patch. An absent field keeps its stored
|
|
148
|
+
* value, `null` clears it back to the suite's declaration or the default, and
|
|
149
|
+
* any other shape is rejected with the reason it failed.
|
|
150
|
+
*/
|
|
151
|
+
export function parseMcpPolicyPatch(raw) {
|
|
152
|
+
if (raw === undefined)
|
|
153
|
+
return {};
|
|
154
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
155
|
+
throw new Error('policy must be an object');
|
|
156
|
+
const record = raw;
|
|
157
|
+
const patch = {};
|
|
158
|
+
if (record['toolCallTimeoutMs'] !== undefined)
|
|
159
|
+
patch.toolCallTimeoutMs = timeoutField(record['toolCallTimeoutMs'], 'tool call timeout');
|
|
160
|
+
if (record['startupTimeoutMs'] !== undefined)
|
|
161
|
+
patch.startupTimeoutMs = timeoutField(record['startupTimeoutMs'], 'startup timeout');
|
|
162
|
+
if (record['disabledTools'] !== undefined) {
|
|
163
|
+
if (record['disabledTools'] === null)
|
|
164
|
+
patch.disabledTools = null;
|
|
165
|
+
else {
|
|
166
|
+
const names = toolNames(record['disabledTools']);
|
|
167
|
+
if (names === undefined && record['disabledTools'].length > 0)
|
|
168
|
+
throw new Error('denied tools must be a list of tool names');
|
|
169
|
+
patch.disabledTools = names ?? [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (record['auth'] !== undefined) {
|
|
173
|
+
if (record['auth'] === null)
|
|
174
|
+
patch.auth = null;
|
|
175
|
+
else {
|
|
176
|
+
const auth = sanitizeAuth(record['auth']);
|
|
177
|
+
if (auth === undefined)
|
|
178
|
+
throw new Error('auth must be an object with a boolean "enabled"');
|
|
179
|
+
patch.auth = auth;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return patch;
|
|
183
|
+
}
|
|
184
|
+
function timeoutField(value, label) {
|
|
185
|
+
if (value === null)
|
|
186
|
+
return null;
|
|
187
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > MAX_TIMEOUT_MS) {
|
|
188
|
+
throw new Error(`${label} must be a positive whole number of milliseconds no greater than ${MAX_TIMEOUT_MS}`);
|
|
189
|
+
}
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
97
192
|
/**
|
|
98
193
|
* Merge one client patch into the override already on disk.
|
|
99
194
|
*
|
|
@@ -113,7 +208,18 @@ export function mergeOverridePatch(existing, patch) {
|
|
|
113
208
|
...(headers === undefined ? {} : { headers }),
|
|
114
209
|
...(env === undefined ? {} : { env }),
|
|
115
210
|
...(patch.args !== undefined ? { args: patch.args } : existing.args === undefined ? {} : { args: existing.args }),
|
|
116
|
-
...(patch.auth !== undefined ? { auth: patch.auth } : existing.auth === undefined ? {} : { auth: existing.auth })
|
|
211
|
+
...(patch.auth !== undefined ? { auth: patch.auth } : existing.auth === undefined ? {} : { auth: existing.auth }),
|
|
212
|
+
...(patch.toolCallTimeoutMs !== undefined
|
|
213
|
+
? { toolCallTimeoutMs: patch.toolCallTimeoutMs }
|
|
214
|
+
: existing.toolCallTimeoutMs === undefined
|
|
215
|
+
? {}
|
|
216
|
+
: { toolCallTimeoutMs: existing.toolCallTimeoutMs }),
|
|
217
|
+
...(patch.startupTimeoutMs !== undefined
|
|
218
|
+
? { startupTimeoutMs: patch.startupTimeoutMs }
|
|
219
|
+
: existing.startupTimeoutMs === undefined
|
|
220
|
+
? {}
|
|
221
|
+
: { startupTimeoutMs: existing.startupTimeoutMs }),
|
|
222
|
+
...(patch.disabledTools !== undefined ? { disabledTools: patch.disabledTools } : existing.disabledTools === undefined ? {} : { disabledTools: existing.disabledTools })
|
|
117
223
|
};
|
|
118
224
|
}
|
|
119
225
|
function mergeStringMap(existing, patch) {
|
|
@@ -156,22 +262,46 @@ function stringMap(value) {
|
|
|
156
262
|
export function applyOverride(server, override) {
|
|
157
263
|
if (override === undefined)
|
|
158
264
|
return server;
|
|
265
|
+
const policy = policyFields(override);
|
|
159
266
|
if (override.config !== undefined)
|
|
160
267
|
server = override.config;
|
|
161
268
|
if (server.type === 'stdio') {
|
|
162
269
|
return {
|
|
163
270
|
...server,
|
|
271
|
+
...policy,
|
|
164
272
|
...(override.args !== undefined ? { args: override.args } : {}),
|
|
165
273
|
...(override.env !== undefined ? { env: override.env } : {})
|
|
166
274
|
};
|
|
167
275
|
}
|
|
168
276
|
return {
|
|
169
277
|
...server,
|
|
278
|
+
...policy,
|
|
170
279
|
...(override.url !== undefined ? { url: override.url } : {}),
|
|
171
280
|
...(override.headers !== undefined ? { headers: override.headers } : {}),
|
|
172
281
|
...(override.auth !== undefined ? { auth: override.auth } : {})
|
|
173
282
|
};
|
|
174
283
|
}
|
|
284
|
+
/** The user-owned policy fields of one override, in server shape. */
|
|
285
|
+
function policyFields(override) {
|
|
286
|
+
return {
|
|
287
|
+
...(override.toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs: override.toolCallTimeoutMs }),
|
|
288
|
+
...(override.startupTimeoutMs === undefined ? {} : { startupTimeoutMs: override.startupTimeoutMs }),
|
|
289
|
+
...(override.disabledTools === undefined ? {} : { disabledTools: override.disabledTools })
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* One server with its client policy fields removed: the shape the portable
|
|
294
|
+
* `mcp.json` schema accepts. The service editor renders and saves this shape,
|
|
295
|
+
* so a timeout never reaches a document that has no seat for it.
|
|
296
|
+
*/
|
|
297
|
+
export function withoutPolicyFields(server) {
|
|
298
|
+
const portable = { ...server };
|
|
299
|
+
delete portable['enabledTools'];
|
|
300
|
+
delete portable['disabledTools'];
|
|
301
|
+
delete portable['startupTimeoutMs'];
|
|
302
|
+
delete portable['toolCallTimeoutMs'];
|
|
303
|
+
return portable;
|
|
304
|
+
}
|
|
175
305
|
function sanitizeFileName(raw) {
|
|
176
306
|
return raw.replace(/[^A-Za-z0-9_-]+/g, '_');
|
|
177
307
|
}
|
|
@@ -45,6 +45,14 @@ export function redactUrl(raw) {
|
|
|
45
45
|
.join('&');
|
|
46
46
|
return safeQuery === '' ? base : `${base}?${safeQuery}`;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Redact every URL embedded in an error message before the message reaches a
|
|
50
|
+
* log line or a diagnostic: connection failures echo the server's endpoint,
|
|
51
|
+
* and endpoints carry credential-bearing queries.
|
|
52
|
+
*/
|
|
53
|
+
export function redactErrorMessage(raw) {
|
|
54
|
+
return raw.replace(/https?:\/\/[^\s"'<>]+/gi, value => redactUrl(value));
|
|
55
|
+
}
|
|
48
56
|
function splitOnce(value, separator) {
|
|
49
57
|
const index = value.indexOf(separator);
|
|
50
58
|
if (index === -1)
|