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
|
@@ -226,13 +226,17 @@ export class Catalog {
|
|
|
226
226
|
async addMcpServer(name, server) {
|
|
227
227
|
await this.mcp.addServer(name, server);
|
|
228
228
|
}
|
|
229
|
+
/** Import several pasted services in one write; each entry reports its own outcome. */
|
|
230
|
+
async importMcpServers(servers, overwrite) {
|
|
231
|
+
return this.mcp.importServers(servers, overwrite);
|
|
232
|
+
}
|
|
229
233
|
/** Read effective service configuration without exposing credential literals. */
|
|
230
234
|
async serverConfig(kind, id) {
|
|
231
235
|
return this.mcp.serverConfig(kind, id);
|
|
232
236
|
}
|
|
233
237
|
/** Validate a complete replacement before writing; plugin checkouts remain untouched. */
|
|
234
|
-
async saveServerConfig(kind, id, config) {
|
|
235
|
-
await this.mcp.saveServerConfig(kind, id, config);
|
|
238
|
+
async saveServerConfig(kind, id, config, policy) {
|
|
239
|
+
await this.mcp.saveServerConfig(kind, id, config, policy);
|
|
236
240
|
}
|
|
237
241
|
/** One suite's persisted MCP overrides, addressed by qualified suite id. */
|
|
238
242
|
async mcpOverrides(sourceId, suiteId) {
|
|
@@ -251,6 +255,14 @@ export class Catalog {
|
|
|
251
255
|
async setMcpServerEnabled(suiteKey, serverKey, enabled) {
|
|
252
256
|
await this.mcp.setServerEnabled(suiteKey, serverKey, enabled);
|
|
253
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Allow or deny one tool of a declared MCP server, addressed by the same
|
|
260
|
+
* source-qualified suite id its status row carries. The denial lands in the
|
|
261
|
+
* override record, so the suite's own `mcp.json` stays source-owned.
|
|
262
|
+
*/
|
|
263
|
+
async setMcpServerToolEnabled(suiteKey, serverKey, tool, enabled) {
|
|
264
|
+
await this.mcp.setServerToolEnabled(suiteKey, serverKey, tool, enabled);
|
|
265
|
+
}
|
|
254
266
|
/** Re-run the MCP reconcile pass without changing any catalog state. */
|
|
255
267
|
async retryMounts() {
|
|
256
268
|
await this.mcp.retryMounts();
|
|
@@ -77,7 +77,7 @@ export class InstallStore {
|
|
|
77
77
|
await this.context.notifyChanged(true);
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
-
/** Append
|
|
80
|
+
/** Append seeded sources missing from user state and persist them. */
|
|
81
81
|
async mergeSources(sources) {
|
|
82
82
|
const existing = new Set(this.context.state.sources.map(source => source.id));
|
|
83
83
|
const additions = sources.filter(source => !existing.has(source.id));
|
|
@@ -11,13 +11,17 @@ import { isDirectory } from '../catalog/fs-probes.js';
|
|
|
11
11
|
import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
|
|
12
12
|
import { readLocalePreference } from '../runtime/host-locale.js';
|
|
13
13
|
import { probeHostMcpClient } from '../runtime/mcp-backend.js';
|
|
14
|
+
import { declaredMcpPolicy, namespaceMcpPolicy, resolveMcpPolicy } from '../runtime/mcp-config.js';
|
|
14
15
|
import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
|
|
15
|
-
import { addUserMcpServer, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from '../runtime/mcp-direct-config.js';
|
|
16
|
-
import { applyOverride, loadSuiteOverrides, mergeOverridePatch, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
|
|
16
|
+
import { addUserMcpServer, importUserMcpServers, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from '../runtime/mcp-direct-config.js';
|
|
17
|
+
import { applyOverride, loadSuiteOverrides, mergeOverridePatch, parseMcpPolicyPatch, saveSuiteOverrides, withoutPolicyFields } from '../runtime/mcp-overrides.js';
|
|
17
18
|
import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
|
|
18
19
|
import { buildMcpStatus } from '../runtime/mcp-status.js';
|
|
19
20
|
import { resolveRegion } from '../runtime/regions.js';
|
|
20
21
|
import { applyLspOverrides, lspConfig, restoreRedactedConfig, saveLspOverride, validateServerLsp, validateServerMcp } from '../runtime/server-config.js';
|
|
22
|
+
/** The host compatibility client mounts servers without a startup timeout or tool filters. */
|
|
23
|
+
const HOST_STARTUP_TIMEOUT_UNSUPPORTED = 'the host MCP backend cannot enforce a startup timeout; switch the MCP backend to the built-in client to set one';
|
|
24
|
+
const HOST_TOOL_FILTER_UNSUPPORTED = 'the host MCP backend cannot enforce tool filters; switch the MCP backend to the built-in client to choose tools';
|
|
21
25
|
export class McpService {
|
|
22
26
|
context;
|
|
23
27
|
ports;
|
|
@@ -39,7 +43,9 @@ export class McpService {
|
|
|
39
43
|
entry.kind = 'direct';
|
|
40
44
|
entry.managed = true;
|
|
41
45
|
}
|
|
42
|
-
const
|
|
46
|
+
const backend = await this.backend();
|
|
47
|
+
payload.backend = backend;
|
|
48
|
+
const oauthBackend = backend === 'builtin' && this.reauthorizeAvailable();
|
|
43
49
|
for (const entry of payload.entries) {
|
|
44
50
|
const auth = entry.config?.auth;
|
|
45
51
|
entry.canReauthorize =
|
|
@@ -58,21 +64,71 @@ export class McpService {
|
|
|
58
64
|
await this.context.notifyChanged(true);
|
|
59
65
|
});
|
|
60
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Import several pasted services in one write. Each entry is checked on its
|
|
69
|
+
* own and reports back with its own outcome, so a paste of many services
|
|
70
|
+
* never fails as a block.
|
|
71
|
+
*/
|
|
72
|
+
async importServers(servers, overwrite) {
|
|
73
|
+
if (!Array.isArray(servers))
|
|
74
|
+
throw new Error('servers must be an array');
|
|
75
|
+
const entries = servers.map(entry => {
|
|
76
|
+
if (typeof entry !== 'object' || entry === null)
|
|
77
|
+
throw new Error('each server must be an object');
|
|
78
|
+
const { name, config } = entry;
|
|
79
|
+
if (typeof name !== 'string' || name === '')
|
|
80
|
+
throw new Error('each server needs a name');
|
|
81
|
+
return { name, server: config };
|
|
82
|
+
});
|
|
83
|
+
return this.context.enqueue(async () => {
|
|
84
|
+
const result = await importUserMcpServers(this.context.agentsRoot, entries, overwrite);
|
|
85
|
+
if (result.imported.length > 0)
|
|
86
|
+
await this.context.notifyChanged(true);
|
|
87
|
+
return result;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
61
90
|
/** Read effective service configuration without exposing credential literals. */
|
|
62
91
|
async serverConfig(kind, id) {
|
|
63
92
|
const config = await this.resolveServerConfig(kind, id);
|
|
64
|
-
|
|
93
|
+
const payload = { kind, id, key: config.key, editable: true, config: redactMcpConfig(config.value) };
|
|
94
|
+
if (kind === 'mcp') {
|
|
95
|
+
payload.backend = await this.backend();
|
|
96
|
+
if (config.policy !== undefined)
|
|
97
|
+
payload.policy = serverPolicyPayload(config.policy, config.override, config.declaredAuth);
|
|
98
|
+
}
|
|
99
|
+
return payload;
|
|
65
100
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Validate a complete replacement before writing; plugin checkouts remain
|
|
103
|
+
* untouched. `policy` carries what the portable document has no seat for —
|
|
104
|
+
* the two timeouts, the user's tool denials and the OAuth opt-in; an absent
|
|
105
|
+
* field keeps its stored value, `null` clears it.
|
|
106
|
+
*/
|
|
107
|
+
async saveServerConfig(kind, id, config, policy) {
|
|
68
108
|
return this.context.enqueue(async () => {
|
|
69
109
|
const current = await this.resolveServerConfig(kind, id);
|
|
70
110
|
const value = restoreRedactedConfig(config, current.value);
|
|
71
111
|
if (kind === 'mcp') {
|
|
72
|
-
const
|
|
112
|
+
const backend = await this.backend();
|
|
113
|
+
const patch = parseMcpPolicyPatch(policy);
|
|
114
|
+
if (backend === 'host' && patch.startupTimeoutMs !== undefined && patch.startupTimeoutMs !== null)
|
|
115
|
+
throw new Error(HOST_STARTUP_TIMEOUT_UNSUPPORTED);
|
|
116
|
+
if (backend === 'host' && patch.disabledTools !== undefined)
|
|
117
|
+
throw new Error(HOST_TOOL_FILTER_UNSUPPORTED);
|
|
118
|
+
const server = await validateServerMcp(current.root, current.key, value, { userOwned: isUserOwnedMcp(current.suiteKey) });
|
|
73
119
|
const overrides = await loadSuiteOverrides(this.context.dataRoot, current.suiteKey);
|
|
74
|
-
const
|
|
75
|
-
|
|
120
|
+
const existing = overrides[current.key] ?? {};
|
|
121
|
+
// The complete configuration already carries every connection input, so
|
|
122
|
+
// a leftover url/headers/env/args field would shadow what was just
|
|
123
|
+
// saved. Enablement and the policy fields the save did not touch survive.
|
|
124
|
+
const next = { config: server };
|
|
125
|
+
if (existing.enabled !== undefined)
|
|
126
|
+
next.enabled = existing.enabled;
|
|
127
|
+
applyAuth(next, patch.auth, existing.auth);
|
|
128
|
+
applyToolList(next, patch.disabledTools, existing.disabledTools);
|
|
129
|
+
applyTimeout(next, 'toolCallTimeoutMs', patch.toolCallTimeoutMs, existing.toolCallTimeoutMs);
|
|
130
|
+
applyTimeout(next, 'startupTimeoutMs', patch.startupTimeoutMs, existing.startupTimeoutMs);
|
|
131
|
+
overrides[current.key] = next;
|
|
76
132
|
await saveSuiteOverrides(this.context.dataRoot, current.suiteKey, overrides);
|
|
77
133
|
}
|
|
78
134
|
else {
|
|
@@ -104,6 +160,53 @@ export class McpService {
|
|
|
104
160
|
* in the suite's parsed mcp.json.
|
|
105
161
|
*/
|
|
106
162
|
async setOverride(sourceId, suiteId, serverKey, override) {
|
|
163
|
+
if (override !== null && (override.startupTimeoutMs !== undefined || override.disabledTools !== undefined) && (await this.backend()) === 'host')
|
|
164
|
+
throw new Error(override.startupTimeoutMs !== undefined ? HOST_STARTUP_TIMEOUT_UNSUPPORTED : HOST_TOOL_FILTER_UNSUPPORTED);
|
|
165
|
+
// Merge onto the stored record: the UI never receives literal secret
|
|
166
|
+
// values, so a verbatim write would drop keys it simply redacted.
|
|
167
|
+
await this.updateServerOverride(sourceId, suiteId, serverKey, current => (override === null ? null : mergeOverridePatch(current ?? {}, override)));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Enable or disable one declared server, addressed by the source-qualified
|
|
171
|
+
* suite id the status rows carry. The qualified id is split here rather than
|
|
172
|
+
* by callers so the separator stays a server-side detail.
|
|
173
|
+
*/
|
|
174
|
+
async setServerEnabled(suiteKey, serverKey, enabled) {
|
|
175
|
+
const { sourceId, suiteId } = splitSuiteKey(suiteKey);
|
|
176
|
+
await this.setOverride(sourceId, suiteId, serverKey, { enabled });
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Allow or deny one tool of a declared server, addressed by the
|
|
180
|
+
* source-qualified suite id the status rows carry. The stored record lists
|
|
181
|
+
* only the tools the user turned off, so a server with nothing denied
|
|
182
|
+
* carries no tool field at all.
|
|
183
|
+
*/
|
|
184
|
+
async setServerToolEnabled(suiteKey, serverKey, tool, enabled) {
|
|
185
|
+
const { sourceId, suiteId } = splitSuiteKey(suiteKey);
|
|
186
|
+
if (tool === '')
|
|
187
|
+
throw new Error('tool name is required');
|
|
188
|
+
if ((await this.backend()) === 'host')
|
|
189
|
+
throw new Error(HOST_TOOL_FILTER_UNSUPPORTED);
|
|
190
|
+
await this.updateServerOverride(sourceId, suiteId, serverKey, current => {
|
|
191
|
+
const denied = new Set(current?.disabledTools ?? []);
|
|
192
|
+
if (enabled)
|
|
193
|
+
denied.delete(tool);
|
|
194
|
+
else
|
|
195
|
+
denied.add(tool);
|
|
196
|
+
const next = { ...(current ?? {}) };
|
|
197
|
+
if (denied.size === 0)
|
|
198
|
+
delete next.disabledTools;
|
|
199
|
+
else
|
|
200
|
+
next.disabledTools = [...denied];
|
|
201
|
+
return Object.keys(next).length === 0 ? null : next;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Rewrite one declared server's override record and remount it. The mutate
|
|
206
|
+
* callback receives the stored record — absent when none is persisted — and
|
|
207
|
+
* returns the replacement, or `null` to drop the record entirely.
|
|
208
|
+
*/
|
|
209
|
+
async updateServerOverride(sourceId, suiteId, serverKey, mutate) {
|
|
107
210
|
return this.context.enqueue(async () => {
|
|
108
211
|
const suiteKey = qualifiedSuiteId(sourceId, suiteId);
|
|
109
212
|
const source = this.context.state.sources.find(entry => entry.id === sourceId);
|
|
@@ -129,14 +232,11 @@ export class McpService {
|
|
|
129
232
|
// Override files are keyed by the qualified id so two sources' same-named
|
|
130
233
|
// suites never share one override record.
|
|
131
234
|
const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
|
|
132
|
-
|
|
235
|
+
const next = mutate(overrides[serverKey]);
|
|
236
|
+
if (next === null)
|
|
133
237
|
delete overrides[serverKey];
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// Merge onto the stored record: the UI never receives literal secret
|
|
137
|
-
// values, so a verbatim write would drop keys it simply redacted.
|
|
138
|
-
overrides[serverKey] = mergeOverridePatch(overrides[serverKey] ?? {}, override);
|
|
139
|
-
}
|
|
238
|
+
else
|
|
239
|
+
overrides[serverKey] = next;
|
|
140
240
|
await saveSuiteOverrides(this.context.dataRoot, suiteKey, overrides);
|
|
141
241
|
await this.context.notifyChanged(true);
|
|
142
242
|
// The status surface reads the live tool registry, so an immediate read
|
|
@@ -147,17 +247,6 @@ export class McpService {
|
|
|
147
247
|
await this.context.refreshSettled();
|
|
148
248
|
});
|
|
149
249
|
}
|
|
150
|
-
/**
|
|
151
|
-
* Enable or disable one declared server, addressed by the source-qualified
|
|
152
|
-
* suite id the status rows carry. The qualified id is split here rather than
|
|
153
|
-
* by callers so the separator stays a server-side detail.
|
|
154
|
-
*/
|
|
155
|
-
async setServerEnabled(suiteKey, serverKey, enabled) {
|
|
156
|
-
const separator = suiteKey.indexOf('/');
|
|
157
|
-
if (separator <= 0)
|
|
158
|
-
throw new Error(`invalid suite id "${suiteKey}"`);
|
|
159
|
-
await this.setOverride(suiteKey.slice(0, separator), suiteKey.slice(separator + 1), serverKey, { enabled });
|
|
160
|
-
}
|
|
161
250
|
/**
|
|
162
251
|
* Re-run the MCP reconcile pass: retries failed mounts and clears residual
|
|
163
252
|
* tools, without changing any catalog state.
|
|
@@ -231,7 +320,11 @@ export class McpService {
|
|
|
231
320
|
}
|
|
232
321
|
return map;
|
|
233
322
|
}
|
|
234
|
-
/**
|
|
323
|
+
/**
|
|
324
|
+
* Resolve one editable server: its key, owning suite, root, and the service
|
|
325
|
+
* document the editor renders. An MCP document carries the portable shape
|
|
326
|
+
* only; the client policy behind it rides `policy`.
|
|
327
|
+
*/
|
|
235
328
|
async resolveServerConfig(kind, id) {
|
|
236
329
|
if (kind === 'lsp' && id.startsWith('direct/')) {
|
|
237
330
|
const key = id.slice(7);
|
|
@@ -247,8 +340,17 @@ export class McpService {
|
|
|
247
340
|
for (const [key, server] of Object.entries(suite.mcp?.servers ?? {})) {
|
|
248
341
|
if (id !== `plugin:${suiteKey}/${key}`)
|
|
249
342
|
continue;
|
|
250
|
-
const
|
|
251
|
-
|
|
343
|
+
const override = (await loadSuiteOverrides(this.context.dataRoot, suiteKey))[key];
|
|
344
|
+
const declared = namespaceMcpPolicy(suite, key);
|
|
345
|
+
return {
|
|
346
|
+
key,
|
|
347
|
+
suiteKey,
|
|
348
|
+
root: suite.root,
|
|
349
|
+
value: { ...withoutPolicyFields(applyOverride(server, override)) },
|
|
350
|
+
policy: resolveMcpPolicy(declaredMcpPolicy(server, declared), override),
|
|
351
|
+
...(override === undefined ? {} : { override }),
|
|
352
|
+
...(declared?.auth?.enabled === undefined ? {} : { declaredAuth: declared.auth.enabled })
|
|
353
|
+
};
|
|
252
354
|
}
|
|
253
355
|
}
|
|
254
356
|
else {
|
|
@@ -261,3 +363,56 @@ export class McpService {
|
|
|
261
363
|
throw new Error('service configuration is not managed by this plugin');
|
|
262
364
|
}
|
|
263
365
|
}
|
|
366
|
+
/** Split one source-qualified suite id; the separator stays a server-side detail. */
|
|
367
|
+
function splitSuiteKey(suiteKey) {
|
|
368
|
+
const separator = suiteKey.indexOf('/');
|
|
369
|
+
if (separator <= 0)
|
|
370
|
+
throw new Error(`invalid suite id "${suiteKey}"`);
|
|
371
|
+
return { sourceId: suiteKey.slice(0, separator), suiteId: suiteKey.slice(separator + 1) };
|
|
372
|
+
}
|
|
373
|
+
/** Project one resolved policy onto the wire shape the editor renders. */
|
|
374
|
+
function serverPolicyPayload(policy, override, declaredAuth) {
|
|
375
|
+
const userDenied = override?.disabledTools ?? null;
|
|
376
|
+
const userAuth = override?.auth?.enabled ?? null;
|
|
377
|
+
const suiteAuth = declaredAuth ?? null;
|
|
378
|
+
return {
|
|
379
|
+
toolCallTimeout: policy.toolCallTimeout,
|
|
380
|
+
startupTimeout: policy.startupTimeout,
|
|
381
|
+
deniedTools: { user: userDenied, suite: policy.suiteDisabledTools ?? null, effective: policy.disabledTools ?? [] },
|
|
382
|
+
auth: { user: userAuth, suite: suiteAuth, effective: userAuth ?? suiteAuth ?? true }
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/** The user-owned MCP declaration suite: its file is local data, not a package. */
|
|
386
|
+
function isUserOwnedMcp(suiteKey) {
|
|
387
|
+
return suiteKey === `${USER_MCP_SOURCE}/${USER_MCP_SUITE}`;
|
|
388
|
+
}
|
|
389
|
+
/** Write the OAuth opt-in from a save: absent keeps the stored value, `null` clears it. */
|
|
390
|
+
function applyAuth(target, patch, stored) {
|
|
391
|
+
if (patch === undefined) {
|
|
392
|
+
if (stored !== undefined)
|
|
393
|
+
target.auth = stored;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (patch !== null)
|
|
397
|
+
target.auth = patch;
|
|
398
|
+
}
|
|
399
|
+
/** Write the user's deny list: absent keeps it, `null` clears it, entries replace it. */
|
|
400
|
+
function applyToolList(target, patch, stored) {
|
|
401
|
+
if (patch === undefined) {
|
|
402
|
+
if (stored !== undefined)
|
|
403
|
+
target.disabledTools = stored;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (patch !== null && patch.length > 0)
|
|
407
|
+
target.disabledTools = patch;
|
|
408
|
+
}
|
|
409
|
+
/** Write one timeout from a save: absent keeps the stored value, `null` clears it, a number sets it. */
|
|
410
|
+
function applyTimeout(target, field, patch, stored) {
|
|
411
|
+
if (patch === undefined) {
|
|
412
|
+
if (stored !== undefined)
|
|
413
|
+
target[field] = stored;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (patch !== null)
|
|
417
|
+
target[field] = patch;
|
|
418
|
+
}
|
package/lib/catalog/archive.js
CHANGED
|
@@ -43,6 +43,21 @@ export async function downloadArchive(url, tempFile, options = {}) {
|
|
|
43
43
|
const response = await fetch(trimmed, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' });
|
|
44
44
|
if (!response.ok)
|
|
45
45
|
throw new Error(`archive download failed: HTTP ${response.status} for ${trimmed}`);
|
|
46
|
+
// Fetch follows redirects. The origin was checked above, but the final URL
|
|
47
|
+
// must preserve the HTTPS-only policy as well; otherwise a trusted HTTPS URL
|
|
48
|
+
// could downgrade the payload request to plain HTTP.
|
|
49
|
+
if (options.allowHttp !== true) {
|
|
50
|
+
const finalUrl = response.url === '' ? trimmed : response.url;
|
|
51
|
+
let finalProtocol;
|
|
52
|
+
try {
|
|
53
|
+
finalProtocol = new URL(finalUrl).protocol;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error('archive redirected to an invalid URL');
|
|
57
|
+
}
|
|
58
|
+
if (finalProtocol !== 'https:')
|
|
59
|
+
throw new Error('archive redirected to non-https URL');
|
|
60
|
+
}
|
|
46
61
|
const declared = Number(response.headers.get('content-length') ?? '0');
|
|
47
62
|
if (Number.isFinite(declared) && declared > ARCHIVE_MAX_BYTES) {
|
|
48
63
|
throw new Error(`archive exceeds the ${ARCHIVE_MAX_BYTES} byte limit: ${declared}`);
|
package/lib/catalog/manifests.js
CHANGED
|
@@ -23,7 +23,8 @@ import { join } from 'node:path';
|
|
|
23
23
|
import { sanitizeId } from './paths.js';
|
|
24
24
|
import { isFile } from './fs-probes.js';
|
|
25
25
|
import { isRecord } from './component-files.js';
|
|
26
|
-
import { isRecognizedSchema, validatePluginManifest } from './validate.js';
|
|
26
|
+
import { formatSchemaErrors, isRecognizedSchema, validatePluginManifest } from './validate.js';
|
|
27
|
+
import { EXTENSION_NAMESPACE, NAMESPACE_SCHEMA_VERSIONS, V1_IGNORED_MANIFEST_KEYS } from '../model/layouts.js';
|
|
27
28
|
import { PLUGIN_LAYOUTS, MANIFEST_ALIASES, MARKETPLACE_PATHS } from '../model/layouts.js';
|
|
28
29
|
/**
|
|
29
30
|
* Every manifest file a directory carries, in selection order: highest
|
|
@@ -65,14 +66,20 @@ export function componentDeclarations(record) {
|
|
|
65
66
|
* go to `fallbacks`, so the winner stays usable while the caller can still
|
|
66
67
|
* report why the higher-priority declaration was ignored. The winner's own
|
|
67
68
|
* diagnostics stay in `errors` and remain fatal.
|
|
69
|
+
*
|
|
70
|
+
* `notes` carries the winner's non-fatal diagnostics — §5.2 unknown
|
|
71
|
+
* top-level fields, §8.1 extension data problems. Callers surface them
|
|
72
|
+
* without failing the suite.
|
|
68
73
|
*/
|
|
69
|
-
export async function readManifest(root, errors, hint, fallbacks = []) {
|
|
74
|
+
export async function readManifest(root, errors, hint, fallbacks = [], notes = []) {
|
|
70
75
|
const candidates = await detectManifests(root);
|
|
71
76
|
for (const [index, candidate] of candidates.entries()) {
|
|
72
77
|
const attempt = [];
|
|
73
|
-
const
|
|
78
|
+
const attemptNotes = [];
|
|
79
|
+
const manifest = await readOneManifest(root, candidate, attempt, hint, attemptNotes);
|
|
74
80
|
if (manifest !== undefined) {
|
|
75
81
|
errors.push(...attempt);
|
|
82
|
+
notes.push(...attemptNotes);
|
|
76
83
|
return manifest;
|
|
77
84
|
}
|
|
78
85
|
if (index === candidates.length - 1)
|
|
@@ -83,7 +90,7 @@ export async function readManifest(root, errors, hint, fallbacks = []) {
|
|
|
83
90
|
return undefined;
|
|
84
91
|
}
|
|
85
92
|
/** Parse one manifest candidate; undefined when this candidate is unusable. */
|
|
86
|
-
async function readOneManifest(root, candidate, errors, hint) {
|
|
93
|
+
async function readOneManifest(root, candidate, errors, hint, notes = []) {
|
|
87
94
|
let raw;
|
|
88
95
|
try {
|
|
89
96
|
raw = JSON.parse(await readFile(candidate.path, 'utf8'));
|
|
@@ -130,21 +137,47 @@ async function readOneManifest(root, candidate, errors, hint) {
|
|
|
130
137
|
}
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
// v1 validation verdicts split per §5.2: fatal violations reject this
|
|
141
|
+
// candidate (the next one by priority is tried), ignored violations —
|
|
142
|
+
// unknown top-level fields and the whole `extensions` subtree — are
|
|
143
|
+
// reported through `notes` and the manifest keeps loading.
|
|
144
|
+
const verdict = kind === 'agent-plugin-v1' ? await validatePluginManifest(raw) : undefined;
|
|
145
|
+
if (verdict !== undefined && verdict.fatal.length > 0) {
|
|
146
|
+
errors.push(...formatSchemaErrors(verdict.fatal).map(problem => `${candidate.path}: ${problem}`));
|
|
136
147
|
return undefined;
|
|
137
148
|
}
|
|
149
|
+
const ignored = verdict === undefined ? [] : formatSchemaErrors(verdict.ignored);
|
|
150
|
+
if (kind === 'agent-plugin-v1') {
|
|
151
|
+
// §5.2 / §6.1: the portable manifest carries no component configuration
|
|
152
|
+
// and no client semantics beyond `extensions`; every inline key this
|
|
153
|
+
// manager would otherwise read is an unknown top-level field here.
|
|
154
|
+
for (const key of V1_IGNORED_MANIFEST_KEYS) {
|
|
155
|
+
if (raw[key] !== undefined)
|
|
156
|
+
ignored.push(`${key}: not part of the portable manifest; ignored`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
notes.push(...ignored.map(problem => `${candidate.path}: ${problem}`));
|
|
160
|
+
const harness = kind === 'agent-plugin-v1' ? parseHarnessExtension(record['extensions'], notes) : undefined;
|
|
138
161
|
const name = pickString(record.name) ?? hint?.name ?? syntheticManifestName(root);
|
|
139
162
|
const version = pickString(record.version) ?? hint?.version;
|
|
140
163
|
const description = pickString(record.description) ?? hint?.description;
|
|
141
164
|
const author = record.author;
|
|
142
165
|
return {
|
|
143
|
-
components: {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
166
|
+
components: {
|
|
167
|
+
...(kind === 'agent-plugin-v1' ? {} : componentDeclarations(hint ?? {})),
|
|
168
|
+
...fallbackComponents,
|
|
169
|
+
...(kind === 'agent-plugin-v1' ? {} : componentDeclarations(raw))
|
|
170
|
+
},
|
|
171
|
+
...(typeof raw.skillInstructions === 'string' && kind !== 'agent-plugin-v1'
|
|
172
|
+
? { skillInstructions: raw.skillInstructions }
|
|
173
|
+
: {}),
|
|
174
|
+
...(typeof raw.systemPrompt === 'string' && kind !== 'agent-plugin-v1'
|
|
175
|
+
? { systemPrompt: raw.systemPrompt }
|
|
176
|
+
: {}),
|
|
177
|
+
...(typeof raw.systemPromptPath === 'string' && kind !== 'agent-plugin-v1'
|
|
178
|
+
? { systemPromptPath: raw.systemPromptPath }
|
|
179
|
+
: {}),
|
|
180
|
+
...(kind !== 'agent-plugin-v1' && typeof raw.sessionStart?.skill === 'string'
|
|
148
181
|
? { startupSkill: raw.sessionStart.skill }
|
|
149
182
|
: {}),
|
|
150
183
|
layout: kind,
|
|
@@ -161,9 +194,87 @@ async function readOneManifest(root, candidate, errors, hint) {
|
|
|
161
194
|
? { author: pickString(record.homepage) }
|
|
162
195
|
: {}),
|
|
163
196
|
keywords: Array.isArray(record.keywords) ? record.keywords.filter((entry) => typeof entry === 'string') : [],
|
|
164
|
-
...(isRecognizedSchema(record.$schema) ? { schemaVersion: record.$schema } : {})
|
|
197
|
+
...(isRecognizedSchema(record.$schema) ? { schemaVersion: record.$schema } : {}),
|
|
198
|
+
...(harness === undefined ? {} : { harness })
|
|
165
199
|
};
|
|
166
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Read this client's §8.1 extension data from a v1 manifest's `extensions`
|
|
203
|
+
* object. Unknown namespaces are ignored without validating their contents
|
|
204
|
+
* (§8.1); our own namespace is validated against the namespace contract, and
|
|
205
|
+
* an unusable value is reported through `notes` instead of failing the
|
|
206
|
+
* plugin.
|
|
207
|
+
*
|
|
208
|
+
* @returns the parsed namespace data, or undefined when the suite declares
|
|
209
|
+
* none; diagnostics land in `notes`.
|
|
210
|
+
*/
|
|
211
|
+
function parseHarnessExtension(extensions, notes) {
|
|
212
|
+
if (!isRecord(extensions) || extensions[EXTENSION_NAMESPACE] === undefined)
|
|
213
|
+
return undefined;
|
|
214
|
+
return parseHarnessNamespace(extensions[EXTENSION_NAMESPACE], notes);
|
|
215
|
+
}
|
|
216
|
+
/** Validate one namespace value against the namespace contract; diagnostics go to `notes`. */
|
|
217
|
+
function parseHarnessNamespace(value, notes) {
|
|
218
|
+
if (!isRecord(value)) {
|
|
219
|
+
notes.push(`extensions.${EXTENSION_NAMESPACE}: value must be an object; ignored`);
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
const schemaVersion = value['schemaVersion'];
|
|
223
|
+
if (typeof schemaVersion !== 'string' || !NAMESPACE_SCHEMA_VERSIONS.has(schemaVersion)) {
|
|
224
|
+
notes.push(`extensions.${EXTENSION_NAMESPACE}: unsupported or missing schemaVersion ${JSON.stringify(schemaVersion)}; supported: ${[...NAMESPACE_SCHEMA_VERSIONS].join(', ')}; namespace extensions are ignored`);
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
const policies = Object.create(null);
|
|
228
|
+
const rawPolicies = value['mcpServers'];
|
|
229
|
+
if (rawPolicies !== undefined) {
|
|
230
|
+
if (!isRecord(rawPolicies)) {
|
|
231
|
+
notes.push(`extensions.${EXTENSION_NAMESPACE}.mcpServers: value must be an object; ignored`);
|
|
232
|
+
return { schemaVersion, mcpServers: policies };
|
|
233
|
+
}
|
|
234
|
+
for (const [name, policy] of Object.entries(rawPolicies)) {
|
|
235
|
+
if (!isRecord(policy)) {
|
|
236
|
+
notes.push(`extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}: policy must be an object; ignored`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const auth = policy['auth'];
|
|
240
|
+
if (auth !== undefined &&
|
|
241
|
+
(!isRecord(auth) || (auth['enabled'] !== undefined && typeof auth['enabled'] !== 'boolean') || (auth['scope'] !== undefined && typeof auth['scope'] !== 'string'))) {
|
|
242
|
+
notes.push(`extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.auth: invalid auth declaration; ignored`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const enabledTools = optionalStringArray(policy['enabledTools'], `extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.enabledTools`, notes);
|
|
246
|
+
const disabledTools = optionalStringArray(policy['disabledTools'], `extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.disabledTools`, notes);
|
|
247
|
+
const startupTimeoutMs = optionalPositiveNumber(policy['startupTimeoutMs'], `extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.startupTimeoutMs`, notes);
|
|
248
|
+
const toolCallTimeoutMs = optionalPositiveNumber(policy['toolCallTimeoutMs'], `extensions.${EXTENSION_NAMESPACE}.mcpServers.${name}.toolCallTimeoutMs`, notes);
|
|
249
|
+
policies[name] = {
|
|
250
|
+
...(enabledTools === undefined ? {} : { enabledTools }),
|
|
251
|
+
...(disabledTools === undefined ? {} : { disabledTools }),
|
|
252
|
+
...(startupTimeoutMs === undefined ? {} : { startupTimeoutMs }),
|
|
253
|
+
...(toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs }),
|
|
254
|
+
...(auth === undefined || !isRecord(auth) ? {} : { auth: { enabled: auth['enabled'] !== false, ...(typeof auth['scope'] === 'string' ? { scope: auth['scope'] } : {}) } })
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { schemaVersion, mcpServers: policies };
|
|
259
|
+
}
|
|
260
|
+
function optionalStringArray(value, label, notes) {
|
|
261
|
+
if (value === undefined)
|
|
262
|
+
return undefined;
|
|
263
|
+
if (!Array.isArray(value) || !value.every(entry => typeof entry === 'string')) {
|
|
264
|
+
notes.push(`${label}: must be an array of strings; ignored`);
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
function optionalPositiveNumber(value, label, notes) {
|
|
270
|
+
if (value === undefined)
|
|
271
|
+
return undefined;
|
|
272
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
273
|
+
notes.push(`${label}: must be a positive number; ignored`);
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
167
278
|
function pickString(value) {
|
|
168
279
|
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
169
280
|
}
|