dsh-agent-plugins-market 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -3
- package/README.zh.md +29 -3
- package/client/client.js +1388 -204
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +537 -71
- package/lib/application/details.js +39 -12
- package/lib/catalog/archive.js +341 -0
- package/lib/catalog/git.js +85 -12
- package/lib/catalog/legacy-root-migration.js +51 -0
- package/lib/catalog/lsp-spec.js +87 -0
- package/lib/catalog/manifests.js +84 -32
- package/lib/catalog/paths.js +65 -7
- package/lib/catalog/scan-pipeline.js +50 -0
- package/lib/catalog/scan-resolvers.js +402 -0
- package/lib/catalog/source-catalog.js +22 -8
- package/lib/catalog/suite-scanner.js +5 -176
- package/lib/catalog/surfaces.js +80 -30
- package/lib/contracts/lsp-status.js +2 -0
- package/lib/contracts/market.js +8 -1
- package/lib/index.js +118 -13
- package/lib/model/state.js +14 -1
- package/lib/model/types.js +21 -2
- package/lib/routes.js +88 -6
- package/lib/runtime/commands-mounts.js +6 -2
- package/lib/runtime/hooks-mounts.js +9 -6
- package/lib/runtime/lsp-direct-config.js +44 -0
- package/lib/runtime/lsp-mounts.js +236 -0
- package/lib/runtime/lsp-status.js +96 -0
- package/lib/runtime/mcp-backend.js +60 -0
- package/lib/runtime/mcp-client/bridge.js +126 -0
- package/lib/runtime/mcp-client/config.js +42 -0
- package/lib/runtime/mcp-client/connection.js +380 -0
- package/lib/runtime/mcp-client/host-seams.js +88 -0
- package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
- package/lib/runtime/mcp-client/oauth.js +379 -0
- package/lib/runtime/mcp-client/tools.js +432 -0
- package/lib/runtime/mcp-client/transport.js +103 -0
- package/lib/runtime/mcp-config.js +196 -54
- package/lib/runtime/mcp-credentials.js +20 -0
- package/lib/runtime/mcp-mounts.js +237 -27
- package/lib/runtime/mcp-overrides.js +64 -6
- package/lib/runtime/mcp-redaction.js +92 -0
- package/lib/runtime/mcp-status.js +76 -32
- package/lib/runtime/reconciler.js +35 -1
- package/lib/runtime/regions.js +56 -0
- package/lib/types/application/catalog.d.ts +191 -14
- package/lib/types/application/details.d.ts +1 -6
- package/lib/types/application/queries.d.ts +28 -1
- package/lib/types/catalog/archive.d.ts +48 -0
- package/lib/types/catalog/git.d.ts +27 -3
- package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
- package/lib/types/catalog/lsp-spec.d.ts +14 -0
- package/lib/types/catalog/manifests.d.ts +16 -1
- package/lib/types/catalog/paths.d.ts +23 -2
- package/lib/types/catalog/scan-pipeline.d.ts +76 -0
- package/lib/types/catalog/scan-resolvers.d.ts +81 -0
- package/lib/types/catalog/source-catalog.d.ts +5 -0
- package/lib/types/catalog/suite-scanner.d.ts +14 -7
- package/lib/types/catalog/surfaces.d.ts +3 -3
- package/lib/types/contracts/lsp-status.d.ts +53 -0
- package/lib/types/contracts/market.d.ts +55 -1
- package/lib/types/contracts/mcp-status.d.ts +14 -2
- package/lib/types/index.d.ts +24 -8
- package/lib/types/model/types.d.ts +52 -3
- package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
- package/lib/types/runtime/lsp-mounts.d.ts +89 -0
- package/lib/types/runtime/lsp-status.d.ts +30 -0
- package/lib/types/runtime/mcp-backend.d.ts +49 -0
- package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
- package/lib/types/runtime/mcp-client/config.d.ts +123 -0
- package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
- package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
- package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
- package/lib/types/runtime/mcp-config.d.ts +29 -12
- package/lib/types/runtime/mcp-credentials.d.ts +9 -0
- package/lib/types/runtime/mcp-mounts.d.ts +56 -6
- package/lib/types/runtime/mcp-overrides.d.ts +23 -4
- package/lib/types/runtime/mcp-redaction.d.ts +7 -0
- package/lib/types/runtime/mcp-status.d.ts +4 -2
- package/lib/types/runtime/reconciler.d.ts +22 -4
- package/lib/types/runtime/regions.d.ts +37 -0
- package/package.json +15 -11
- package/schemas/1.0.0/mcp.schema.json +22 -0
|
@@ -1,29 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Maps one validated suite `mcp.json` onto
|
|
2
|
+
* Maps one validated suite `mcp.json` onto self-built bridge config rows.
|
|
3
3
|
*
|
|
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
|
-
* `${NAME}`
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
* are skipped with a per-server
|
|
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.
|
|
11
12
|
*/
|
|
12
13
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import {
|
|
14
|
+
import { DEFAULT_TOOL_CALL_TIMEOUT_MS } from './mcp-client/config.js';
|
|
15
|
+
import { resolveCwd } from '../catalog/validate.js';
|
|
16
|
+
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
14
17
|
import { applyOverride } from './mcp-overrides.js';
|
|
15
|
-
/** The max length
|
|
18
|
+
/** The max length the bridge accepts for a serverName. */
|
|
16
19
|
const SERVER_NAME_MAX = 32;
|
|
20
|
+
const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
21
|
+
const BUILTIN_PLACEHOLDERS = new Set(['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA']);
|
|
17
22
|
/**
|
|
18
|
-
* Build one mount request per supported mcp.json server
|
|
23
|
+
* Build one mount request per supported mcp.json server, resolving every
|
|
24
|
+
* `${NAME}` through the credential resolver before a child process or HTTP
|
|
25
|
+
* request is created. Missing references fail closed per server instead of
|
|
26
|
+
* becoming empty strings.
|
|
27
|
+
*
|
|
19
28
|
* @param overrides user-owned per-server overrides (url/headers/env/args
|
|
20
|
-
* replacement plus enable/disable); applied
|
|
21
|
-
*
|
|
22
|
-
* @returns mount requests plus per-server failures
|
|
23
|
-
*
|
|
24
|
-
* checked by the mount registry, not here).
|
|
29
|
+
* replacement plus enable/disable); applied before mount. Disabled servers
|
|
30
|
+
* are omitted entirely.
|
|
31
|
+
* @returns mount requests plus per-server failures. serverName collisions are
|
|
32
|
+
* reported by the mount registry, not here.
|
|
25
33
|
*/
|
|
26
|
-
export function toMcpMounts(suite, pluginDataRoot, overrides = {}) {
|
|
34
|
+
export async function toMcpMounts(suite, pluginDataRoot, overrides = {}, resolver = { resolve: async () => undefined }) {
|
|
27
35
|
if (suite.mcp === undefined)
|
|
28
36
|
return { mounts: [], failures: [] };
|
|
29
37
|
const mounts = [];
|
|
@@ -32,57 +40,191 @@ export function toMcpMounts(suite, pluginDataRoot, overrides = {}) {
|
|
|
32
40
|
const override = overrides[serverKey];
|
|
33
41
|
if (override?.enabled === false)
|
|
34
42
|
continue;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
const server = applyOverride(source, override);
|
|
44
|
+
try {
|
|
45
|
+
const result = await toResolvedMount(suite, serverKey, server, pluginDataRoot, resolver);
|
|
46
|
+
if (result.failure !== undefined)
|
|
47
|
+
failures.push(result.failure);
|
|
48
|
+
if (result.request !== undefined)
|
|
49
|
+
mounts.push(result.request);
|
|
38
50
|
}
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
catch {
|
|
52
|
+
failures.push({ serverKey, code: 'credential-error', credentialRefs: credentialRefsInServer(server), reason: 'credential lookup failed' });
|
|
41
53
|
}
|
|
42
54
|
}
|
|
43
55
|
return { mounts, failures };
|
|
44
56
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
/** Find external credential references used by one MCP server definition. */
|
|
58
|
+
export function credentialRefsInServer(server) {
|
|
59
|
+
const values = [];
|
|
60
|
+
if (server.type === 'stdio') {
|
|
61
|
+
values.push(...(server.args ?? []), ...(server.cwd === undefined ? [] : [server.cwd]), ...Object.values(server.env ?? {}));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// A remote endpoint may carry the token in the URL query as well as in a
|
|
65
|
+
// header, so both are scanned and both are resolved at mount time.
|
|
66
|
+
values.push(server.url ?? '', ...Object.values(server.headers ?? {}));
|
|
67
|
+
}
|
|
68
|
+
const refs = new Set();
|
|
69
|
+
for (const value of values) {
|
|
70
|
+
for (const match of value.matchAll(PLACEHOLDER)) {
|
|
71
|
+
const name = match[1];
|
|
72
|
+
if (name !== undefined && !BUILTIN_PLACEHOLDERS.has(name))
|
|
73
|
+
refs.add(name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [...refs].sort();
|
|
77
|
+
}
|
|
78
|
+
async function toResolvedMount(suite, serverKey, server, pluginDataRoot, resolver) {
|
|
79
|
+
if (server.type === 'sse') {
|
|
80
|
+
// The market bridge supports the legacy HTTP+SSE transport natively.
|
|
81
|
+
const expand = expander(suite, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id)), resolver);
|
|
82
|
+
const url = await expand.one(server.url);
|
|
83
|
+
const headers = await expand.map(server.headers ?? {});
|
|
84
|
+
const missing = unique([...url.missing, ...headers.missing]);
|
|
85
|
+
if (missing.length > 0)
|
|
86
|
+
return { failure: missingFailure(serverKey, missing) };
|
|
87
|
+
const sseConfig = {
|
|
88
|
+
transport: 'sse',
|
|
89
|
+
serverName: deriveServerName(qualifiedSuiteId(suite.sourceId, suite.id), serverKey),
|
|
90
|
+
url: url.value,
|
|
91
|
+
headers: headers.values,
|
|
92
|
+
...(server.auth === undefined ? {} : { auth: mapAuth(server.auth) }),
|
|
93
|
+
toolCallTimeoutMs: DEFAULT_TOOL_CALL_TIMEOUT_MS,
|
|
94
|
+
failOnStartupError: true
|
|
95
|
+
};
|
|
96
|
+
return { request: { suiteId: qualifiedSuiteId(suite.sourceId, suite.id), serverKey, config: sseConfig } };
|
|
97
|
+
}
|
|
98
|
+
const expand = expander(suite, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id)), resolver);
|
|
99
|
+
const serverName = deriveServerName(qualifiedSuiteId(suite.sourceId, suite.id), serverKey);
|
|
100
|
+
if (server.type === 'stdio') {
|
|
101
|
+
const args = await expand.all(server.args ?? []);
|
|
102
|
+
const env = await expand.map(server.env ?? {});
|
|
103
|
+
const cwd = server.cwd === undefined ? { value: suite.root, missing: [] } : await expand.one(server.cwd);
|
|
104
|
+
const missing = unique([...args.missing, ...env.missing, ...cwd.missing]);
|
|
105
|
+
if (missing.length > 0)
|
|
106
|
+
return { failure: missingFailure(serverKey, missing) };
|
|
107
|
+
return {
|
|
108
|
+
request: {
|
|
109
|
+
suiteId: qualifiedSuiteId(suite.sourceId, suite.id),
|
|
110
|
+
serverKey,
|
|
111
|
+
config: {
|
|
112
|
+
transport: 'stdio',
|
|
113
|
+
serverName,
|
|
114
|
+
command: server.command.startsWith('./') ? joinInside(suite.root, server.command.slice(2)) : server.command,
|
|
115
|
+
args: args.values,
|
|
116
|
+
env: env.values,
|
|
117
|
+
cwd: resolveCwd(cwd.value, suite.root, joinInside(pluginDataRoot, qualifiedSuiteId(suite.sourceId, suite.id))),
|
|
118
|
+
toolCallTimeoutMs: DEFAULT_TOOL_CALL_TIMEOUT_MS,
|
|
119
|
+
failOnStartupError: true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const url = await expand.one(server.url);
|
|
125
|
+
const headers = await expand.map(server.headers ?? {});
|
|
126
|
+
const missing = unique([...url.missing, ...headers.missing]);
|
|
127
|
+
if (missing.length > 0)
|
|
128
|
+
return { failure: missingFailure(serverKey, missing) };
|
|
129
|
+
return {
|
|
130
|
+
request: {
|
|
131
|
+
suiteId: qualifiedSuiteId(suite.sourceId, suite.id),
|
|
132
|
+
serverKey,
|
|
133
|
+
config: {
|
|
134
|
+
transport: 'streamable-http',
|
|
135
|
+
serverName,
|
|
136
|
+
url: url.value,
|
|
137
|
+
headers: headers.values,
|
|
138
|
+
...(server.auth === undefined ? {} : { auth: mapAuth(server.auth) }),
|
|
139
|
+
toolCallTimeoutMs: DEFAULT_TOOL_CALL_TIMEOUT_MS,
|
|
140
|
+
failOnStartupError: true
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Map the suite format's auth declaration onto the bridge's OAuth config.
|
|
147
|
+
* `enabled: false` must survive the mapping — it is the explicit opt-out —
|
|
148
|
+
* while `enabled: true` (or a bare declaration) requests the default-on
|
|
149
|
+
* flow, optionally with a scope.
|
|
150
|
+
*/
|
|
151
|
+
function mapAuth(auth) {
|
|
152
|
+
return {
|
|
153
|
+
enabled: auth.enabled !== false,
|
|
154
|
+
...(auth.scope === undefined ? {} : { scope: auth.scope })
|
|
155
|
+
};
|
|
52
156
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
157
|
+
/** Per-mount expansion context; credential lookups are memoized per call. */
|
|
158
|
+
function expander(suite, pluginData, resolver) {
|
|
159
|
+
// Promise-valued cache: concurrent expansions of the same reference share
|
|
160
|
+
// one in-flight lookup, so a config using a token twice resolves it once.
|
|
161
|
+
const inflight = new Map();
|
|
162
|
+
const lookup = (name) => {
|
|
163
|
+
const pending = inflight.get(name);
|
|
164
|
+
if (pending !== undefined)
|
|
165
|
+
return pending;
|
|
166
|
+
const created = Promise.resolve(resolver.resolve(name));
|
|
167
|
+
inflight.set(name, created);
|
|
168
|
+
return created;
|
|
169
|
+
};
|
|
170
|
+
const one = async (value) => {
|
|
171
|
+
let cursor = 0;
|
|
172
|
+
let output = '';
|
|
173
|
+
const missing = [];
|
|
174
|
+
for (const match of value.matchAll(PLACEHOLDER)) {
|
|
175
|
+
const index = match.index ?? 0;
|
|
176
|
+
const name = match[1];
|
|
177
|
+
if (name === undefined)
|
|
178
|
+
continue;
|
|
179
|
+
output += value.slice(cursor, index);
|
|
180
|
+
const fallback = match[2];
|
|
181
|
+
let replacement;
|
|
182
|
+
if (name === 'PLUGIN_ROOT' || name === 'CLAUDE_PLUGIN_ROOT')
|
|
183
|
+
replacement = suite.root;
|
|
184
|
+
else if (name === 'PLUGIN_DATA' || name === 'CLAUDE_PLUGIN_DATA')
|
|
185
|
+
replacement = pluginData;
|
|
186
|
+
else
|
|
187
|
+
replacement = (await lookup(name))?.value;
|
|
188
|
+
if (replacement === undefined || replacement === '') {
|
|
189
|
+
if (fallback !== undefined && fallback !== '')
|
|
190
|
+
replacement = fallback;
|
|
191
|
+
else {
|
|
192
|
+
missing.push(name);
|
|
193
|
+
replacement = '';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
output += replacement;
|
|
197
|
+
cursor = index + match[0].length;
|
|
198
|
+
}
|
|
199
|
+
output += value.slice(cursor);
|
|
200
|
+
return { value: output, missing };
|
|
201
|
+
};
|
|
59
202
|
return {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
203
|
+
one,
|
|
204
|
+
async all(values) {
|
|
205
|
+
const expanded = await Promise.all(values.map(one));
|
|
206
|
+
return { values: expanded.map(item => item.value), missing: expanded.flatMap(item => item.missing) };
|
|
207
|
+
},
|
|
208
|
+
async map(values) {
|
|
209
|
+
const entries = await Promise.all(Object.entries(values).map(async ([key, value]) => [key, await one(value)]));
|
|
210
|
+
return {
|
|
211
|
+
values: Object.fromEntries(entries.map(([key, result]) => [key, result.value])),
|
|
212
|
+
missing: entries.flatMap(([, result]) => result.missing)
|
|
213
|
+
};
|
|
214
|
+
}
|
|
68
215
|
};
|
|
69
216
|
}
|
|
70
|
-
function
|
|
71
|
-
const
|
|
72
|
-
const headers = Object.fromEntries(Object.entries(server.headers ?? {}).map(([key, value]) => [key, expandPlaceholders(value, suite.root, pluginData)]));
|
|
217
|
+
function missingFailure(serverKey, refs) {
|
|
218
|
+
const uniqueRefs = unique(refs);
|
|
73
219
|
return {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
toolCallTimeoutMs: 60_000,
|
|
79
|
-
failOnStartupError: false
|
|
220
|
+
serverKey,
|
|
221
|
+
code: 'missing-credential',
|
|
222
|
+
credentialRefs: uniqueRefs,
|
|
223
|
+
reason: uniqueRefs.map(ref => `missing credential reference ${ref}`).join('; ')
|
|
80
224
|
};
|
|
81
225
|
}
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
return 'legacy HTTP+SSE transport is not supported by the dsh MCP client';
|
|
85
|
-
return 'unsupported mcp.json server shape';
|
|
226
|
+
function unique(values) {
|
|
227
|
+
return [...new Set(values)].sort();
|
|
86
228
|
}
|
|
87
229
|
function joinInside(root, segment) {
|
|
88
230
|
return `${root.replace(/[\\/]$/, '')}/${segment}`;
|
|
@@ -96,7 +238,7 @@ function sanitizeToken(raw) {
|
|
|
96
238
|
return cleaned === '' ? 'server' : cleaned;
|
|
97
239
|
}
|
|
98
240
|
/**
|
|
99
|
-
* Derive a stable, unique-ish
|
|
241
|
+
* Derive a stable, unique-ish bridge serverName from the suite and
|
|
100
242
|
* server ids: `${suiteId}__${serverKey}` sanitized, truncated to 32 chars
|
|
101
243
|
* with a deterministic 12-hex suffix when the join exceeds the budget (the
|
|
102
244
|
* same deterministic-hash policy the MCP client uses for long tool names).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the resolver used at mount time. Standard DSH profiles provide
|
|
3
|
+
* `ctx.credentials`; minimal profiles retain the historical process-env
|
|
4
|
+
* fallback so MCP mounting remains backwards compatible.
|
|
5
|
+
*/
|
|
6
|
+
export function mcpCredentialResolver(ctx) {
|
|
7
|
+
const host = ctx;
|
|
8
|
+
const service = host.get?.('credentials');
|
|
9
|
+
if (typeof service?.resolve === 'function') {
|
|
10
|
+
return {
|
|
11
|
+
resolve: ref => service.resolve(ref)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
async resolve(ref) {
|
|
16
|
+
const value = process.env[ref];
|
|
17
|
+
return value === undefined || value === '' ? undefined : { value, source: 'env' };
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -1,12 +1,41 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import * as mcpBridge from './mcp-client/bridge.js';
|
|
3
|
+
import { applyOverride } from './mcp-overrides.js';
|
|
4
|
+
import { credentialRefsInServer, toMcpMounts } from './mcp-config.js';
|
|
5
|
+
import { mcpCredentialResolver } from './mcp-credentials.js';
|
|
6
|
+
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
7
|
+
/**
|
|
8
|
+
* Bounded retry schedule for a failed mount/unmount. A crash-looping or
|
|
9
|
+
* permanently broken server must eventually stop consuming attempt budget, so
|
|
10
|
+
* the schedule is capped and resets with every reconcile pass.
|
|
11
|
+
*/
|
|
12
|
+
const RETRY_SCHEDULE_MS = [1_500, 5_000, 15_000, 45_000, 120_000];
|
|
13
|
+
const MAX_RETRY_ATTEMPTS = RETRY_SCHEDULE_MS.length;
|
|
2
14
|
export class McpMountRegistry {
|
|
3
15
|
ctx;
|
|
4
16
|
pluginDataRoot;
|
|
5
17
|
live = new Map();
|
|
6
18
|
names = new Map();
|
|
19
|
+
credentialRefs = new Set();
|
|
7
20
|
overridesProvider = async () => new Map();
|
|
21
|
+
/** Live model-facing tool names, for foreign-namespace detection before a mount. */
|
|
22
|
+
toolNamesProvider = () => [];
|
|
23
|
+
/** The active MCP mount backend ('builtin' bridge or host client compat mode). */
|
|
24
|
+
backendProvider = async () => 'builtin';
|
|
8
25
|
/** Serialize mount and unmount passes so a disable cannot race an in-flight spawn. */
|
|
9
26
|
reconcileQueue = Promise.resolve();
|
|
27
|
+
/** Pending retry timers keyed by mount key, so a teardown can cancel them. */
|
|
28
|
+
retries = new Map();
|
|
29
|
+
/** Attempt count per mount key; reset whenever a retry succeeds. */
|
|
30
|
+
attempts = new Map();
|
|
31
|
+
/**
|
|
32
|
+
* Mount keys flagged for an explicit rebuild (re-authorize): the next
|
|
33
|
+
* reconcile must tear down and remount them even when the resolved config
|
|
34
|
+
* fingerprint is unchanged — dropping a grant changes no config field.
|
|
35
|
+
*/
|
|
36
|
+
forcedRemounts = new Set();
|
|
37
|
+
/** Snapshot of the last reconciled suite set, replayed by retry passes. */
|
|
38
|
+
lastEnabled = [];
|
|
10
39
|
constructor(ctx, pluginDataRoot) {
|
|
11
40
|
this.ctx = ctx;
|
|
12
41
|
this.pluginDataRoot = pluginDataRoot;
|
|
@@ -15,6 +44,41 @@ export class McpMountRegistry {
|
|
|
15
44
|
setOverridesProvider(provider) {
|
|
16
45
|
this.overridesProvider = provider;
|
|
17
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Install the live model-facing tool-name provider (the host registry
|
|
49
|
+
* snapshot) used to detect foreign `mcp__<serverName>__` namespaces before
|
|
50
|
+
* a mount is attempted.
|
|
51
|
+
*/
|
|
52
|
+
setToolNamesProvider(provider) {
|
|
53
|
+
this.toolNamesProvider = provider;
|
|
54
|
+
}
|
|
55
|
+
/** Install the backend provider deciding which client mounts each server. */
|
|
56
|
+
setBackendProvider(provider) {
|
|
57
|
+
this.backendProvider = provider;
|
|
58
|
+
}
|
|
59
|
+
/** Whether the last catalog snapshot uses one credential reference. */
|
|
60
|
+
usesCredential(ref) {
|
|
61
|
+
return this.credentialRefs.has(ref);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Flag one mount for an explicit rebuild on the next reconcile: used by
|
|
65
|
+
* re-authorize, which deletes a grant without changing any resolved config
|
|
66
|
+
* field, so a fingerprint-only reconcile would keep the live bridge (and
|
|
67
|
+
* its in-memory tokens) untouched.
|
|
68
|
+
*/
|
|
69
|
+
forceRemount(suiteId, serverKey) {
|
|
70
|
+
this.forcedRemounts.add(mountKey(suiteId, serverKey));
|
|
71
|
+
}
|
|
72
|
+
/** The mount key owning one derived serverName; undefined when not mounted here. */
|
|
73
|
+
serverOwner(serverName) {
|
|
74
|
+
const owner = this.names.get(serverName);
|
|
75
|
+
if (owner === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
// Values are `${suiteId}\u0000${serverKey}` — suite ids may contain a
|
|
78
|
+
// slash (qualified ids), so the separator is the NUL byte, not '/'.
|
|
79
|
+
const separator = owner.indexOf('\u0000');
|
|
80
|
+
return { suiteId: owner.slice(0, separator), serverKey: owner.slice(separator + 1) };
|
|
81
|
+
}
|
|
18
82
|
/** Queue one reconciliation behind any in-flight mount/unmount pass. */
|
|
19
83
|
async reconcile(enabledSuites) {
|
|
20
84
|
const run = this.reconcileQueue.then(() => this.reconcileNow(enabledSuites));
|
|
@@ -23,14 +87,32 @@ export class McpMountRegistry {
|
|
|
23
87
|
}
|
|
24
88
|
/** Mount/unmount MCP servers to match the enabled suites exactly. */
|
|
25
89
|
async reconcileNow(enabledSuites) {
|
|
90
|
+
this.lastEnabled = [...enabledSuites];
|
|
26
91
|
const active = enabledSuites.filter(suite => suite.activeSurfaces?.mcp !== false);
|
|
27
92
|
const overrides = await this.overridesProvider();
|
|
93
|
+
const resolver = mcpCredentialResolver(this.ctx);
|
|
28
94
|
const wanted = new Map();
|
|
29
95
|
const diagnostics = [];
|
|
96
|
+
this.credentialRefs.clear();
|
|
30
97
|
for (const suite of active) {
|
|
31
|
-
const
|
|
98
|
+
const suiteOverrides = overrides.get(qualifiedSuiteId(suite.sourceId, suite.id));
|
|
99
|
+
for (const [serverKey, source] of Object.entries(suite.mcp?.servers ?? {})) {
|
|
100
|
+
const override = suiteOverrides?.[serverKey];
|
|
101
|
+
if (override?.enabled === false)
|
|
102
|
+
continue;
|
|
103
|
+
const effective = applyOverride(source, override);
|
|
104
|
+
for (const ref of credentialRefsInServer(effective))
|
|
105
|
+
this.credentialRefs.add(ref);
|
|
106
|
+
}
|
|
107
|
+
const { mounts, failures } = await toMcpMounts(suite, this.pluginDataRoot, suiteOverrides, resolver);
|
|
32
108
|
for (const failure of failures) {
|
|
33
|
-
diagnostics.push({
|
|
109
|
+
diagnostics.push({
|
|
110
|
+
suiteId: suite.id,
|
|
111
|
+
serverKey: failure.serverKey,
|
|
112
|
+
reason: failure.reason,
|
|
113
|
+
...(failure.code === undefined ? {} : { code: failure.code }),
|
|
114
|
+
...(failure.credentialRefs === undefined ? {} : { credentialRefs: failure.credentialRefs })
|
|
115
|
+
});
|
|
34
116
|
}
|
|
35
117
|
for (const mount of mounts) {
|
|
36
118
|
wanted.set(mountKey(mount.suiteId, mount.serverKey), { suite, serverKey: mount.serverKey, request: mount });
|
|
@@ -38,19 +120,80 @@ export class McpMountRegistry {
|
|
|
38
120
|
}
|
|
39
121
|
for (const [key, live] of [...this.live]) {
|
|
40
122
|
if (!wanted.has(key)) {
|
|
41
|
-
await this.unmount(key, live);
|
|
42
|
-
diagnostics.push({
|
|
123
|
+
const reason = await this.unmount(key, live);
|
|
124
|
+
diagnostics.push({
|
|
125
|
+
suiteId: live.suiteId,
|
|
126
|
+
serverKey: live.serverKey,
|
|
127
|
+
reason: reason ?? 'unmounted',
|
|
128
|
+
...(reason === undefined ? {} : { code: 'unmount-failed' })
|
|
129
|
+
});
|
|
43
130
|
}
|
|
44
131
|
}
|
|
45
132
|
for (const [key, entry] of wanted) {
|
|
46
|
-
|
|
133
|
+
const live = this.live.get(key);
|
|
134
|
+
// A forced remount (re-authorize) skips the fingerprint match on
|
|
135
|
+
// purpose: dropping a grant does not change the resolved config, so
|
|
136
|
+
// only the explicit instruction tears the live bridge down and lets
|
|
137
|
+
// the server's 401 restart the browser authorization.
|
|
138
|
+
if (live !== undefined && !this.forcedRemounts.has(key) && live.configFingerprint === fingerprintOf(entry.request))
|
|
47
139
|
continue;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
140
|
+
this.forcedRemounts.delete(key);
|
|
141
|
+
// Either a fresh mount, or a remount because the resolved config moved
|
|
142
|
+
// (a credential rotation rewrites the env/header/url values while the
|
|
143
|
+
// suite stays enabled) — keeping the old mount would keep serving the
|
|
144
|
+
// old token until disable or restart.
|
|
145
|
+
if (live !== undefined) {
|
|
146
|
+
const reason = await this.unmount(key, live);
|
|
147
|
+
if (reason !== undefined) {
|
|
148
|
+
diagnostics.push({ suiteId: live.suiteId, serverKey: live.serverKey, reason, code: 'unmount-failed' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// A retry attempt runs the same mount path; only the last failure is
|
|
153
|
+
// reported so a transient error does not shadow the final state.
|
|
154
|
+
const failure = await this.mountWith(entry.request);
|
|
155
|
+
if (failure !== undefined) {
|
|
156
|
+
diagnostics.push({ suiteId: entry.suite.id, serverKey: entry.serverKey, reason: failure.reason, code: failure.code });
|
|
157
|
+
// A foreign-namespace skip is deterministic, not transient: retrying
|
|
158
|
+
// it just burns the attempt budget and log lines. The next full
|
|
159
|
+
// reconcile re-checks it anyway, so the self-heal path is intact.
|
|
160
|
+
this.scheduleRetry(key, entry.suite.id, entry.serverKey, failure.code === 'foreign-mount' ? undefined : failure.reason);
|
|
161
|
+
}
|
|
51
162
|
}
|
|
52
163
|
return diagnostics.filter(diagnostic => diagnostic.reason !== 'unmounted');
|
|
53
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Schedule a delayed re-attempt for a mount that still is not live.
|
|
167
|
+
*
|
|
168
|
+
* Mounts are retried because a remote endpoint or a credential may become
|
|
169
|
+
* available slightly after the suite is enabled. The schedule is bounded, and
|
|
170
|
+
* a server that keeps failing simply stops retrying until the next reconcile.
|
|
171
|
+
*/
|
|
172
|
+
scheduleRetry(key, suiteId, serverKey, reason) {
|
|
173
|
+
const pending = this.retries.get(key);
|
|
174
|
+
if (pending !== undefined)
|
|
175
|
+
clearTimeout(pending);
|
|
176
|
+
this.retries.delete(key);
|
|
177
|
+
if (reason === undefined) {
|
|
178
|
+
this.attempts.delete(key);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const attempt = (this.attempts.get(key) ?? 0) + 1;
|
|
182
|
+
this.attempts.set(key, attempt);
|
|
183
|
+
if (attempt > MAX_RETRY_ATTEMPTS) {
|
|
184
|
+
this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${suiteId}/${serverKey}: giving up after ${MAX_RETRY_ATTEMPTS} attempts — ${reason}`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const delay = RETRY_SCHEDULE_MS[attempt - 1] ?? RETRY_SCHEDULE_MS[RETRY_SCHEDULE_MS.length - 1];
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
this.retries.delete(key);
|
|
190
|
+
// Re-run against the last known suite set: a retry must not resurrect a
|
|
191
|
+
// server that has since been disabled or uninstalled.
|
|
192
|
+
void this.reconcile(this.lastEnabled).catch(() => { });
|
|
193
|
+
}, delay);
|
|
194
|
+
timer.unref?.();
|
|
195
|
+
this.retries.set(key, timer);
|
|
196
|
+
}
|
|
54
197
|
/** Dispose every live mount after queued reconciliation passes settle. */
|
|
55
198
|
async disposeAll() {
|
|
56
199
|
const run = this.reconcileQueue.then(async () => {
|
|
@@ -60,45 +203,112 @@ export class McpMountRegistry {
|
|
|
60
203
|
});
|
|
61
204
|
this.reconcileQueue = run.then(() => undefined, () => undefined);
|
|
62
205
|
await run;
|
|
206
|
+
for (const timer of this.retries.values())
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
this.retries.clear();
|
|
209
|
+
this.attempts.clear();
|
|
210
|
+
this.credentialRefs.clear();
|
|
63
211
|
}
|
|
64
212
|
/** Mount one precomputed request (source config merged with overrides). */
|
|
65
213
|
async mountWith(request) {
|
|
66
214
|
const owner = this.names.get(request.config.serverName);
|
|
67
|
-
if (owner !== undefined)
|
|
68
|
-
return `derived serverName "${request.config.serverName}" already mounted by ${owner}
|
|
69
|
-
let mcpClient;
|
|
70
|
-
try {
|
|
71
|
-
mcpClient = await import('@deepseek-ai/dsh-mcp-client');
|
|
215
|
+
if (owner !== undefined) {
|
|
216
|
+
return { reason: `derived serverName "${request.config.serverName}" already mounted by ${owner}`, code: 'mount-failed' };
|
|
72
217
|
}
|
|
73
|
-
|
|
74
|
-
|
|
218
|
+
// Foreign-namespace guard: a native host MCP client (or another plugin's
|
|
219
|
+
// mount) may already own this `mcp__<serverName>__` namespace. Registering
|
|
220
|
+
// into it would fail loudly mid-mount; skipping here reports the conflict
|
|
221
|
+
// as a clear per-server diagnostic instead, and a later reconcile mounts
|
|
222
|
+
// this server if the foreign owner goes away. The matching names ride the
|
|
223
|
+
// diagnostic — a leftover of this plugin's own earlier mount (registry
|
|
224
|
+
// record lost without a teardown) reads as an orphan here, and a Host
|
|
225
|
+
// restart clears it; a genuinely foreign owner keeps the skip sticky.
|
|
226
|
+
const prefix = `mcp__${request.config.serverName}__`;
|
|
227
|
+
const foreign = this.toolNamesProvider().filter(name => name.startsWith(prefix));
|
|
228
|
+
if (foreign.length > 0) {
|
|
229
|
+
return {
|
|
230
|
+
reason: `serverName "${request.config.serverName}" is already mounted by another MCP client (native config or another plugin; matching tools: ${foreign.slice(0, 3).join(', ')}${foreign.length > 3 ? `, +${foreign.length - 3} more` : ''}) — skipped to avoid a duplicate mount; restart the Host if these tools are a leftover`,
|
|
231
|
+
code: 'foreign-mount'
|
|
232
|
+
};
|
|
75
233
|
}
|
|
234
|
+
// Backend dispatch: the built-in bridge connects stdio, Streamable HTTP
|
|
235
|
+
// (with OAuth), and legacy SSE servers in-process; the host backend
|
|
236
|
+
// mounts the host's own `dsh-mcp-client` for compatibility, at the cost
|
|
237
|
+
// of OAuth and SSE support.
|
|
76
238
|
const mountCtx = this.ctx;
|
|
77
|
-
if (typeof mountCtx.plugin !== 'function')
|
|
78
|
-
return 'the host context does not support dynamic plugin mounting';
|
|
239
|
+
if (typeof mountCtx.plugin !== 'function') {
|
|
240
|
+
return { reason: 'the host context does not support dynamic plugin mounting', code: 'mount-failed' };
|
|
241
|
+
}
|
|
242
|
+
let pluginModule = mcpBridge;
|
|
243
|
+
if ((await this.backendProvider()) === 'host') {
|
|
244
|
+
if (request.config.transport === 'sse') {
|
|
245
|
+
return {
|
|
246
|
+
reason: 'the host dsh-mcp-client does not support the legacy SSE transport — switch the MCP backend back to the built-in client for this server',
|
|
247
|
+
code: 'mount-failed'
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
pluginModule = await import('@deepseek-ai/dsh-mcp-client');
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return {
|
|
255
|
+
reason: 'the @deepseek-ai/dsh-mcp-client package is not installed in this profile — switch the MCP backend back to the built-in client',
|
|
256
|
+
code: 'mount-failed'
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
let handle;
|
|
79
261
|
try {
|
|
80
|
-
|
|
262
|
+
handle = mountCtx.plugin(pluginModule, request.config);
|
|
81
263
|
await handle.await();
|
|
82
|
-
const key = mountKey(request.suiteId, request.serverKey);
|
|
83
|
-
this.live.set(key, { suiteId: request.suiteId, serverKey: request.serverKey, serverName: request.config.serverName, disposer: () => handle.dispose() });
|
|
84
|
-
this.names.set(request.config.serverName, `${request.suiteId}/${request.serverKey}`);
|
|
85
|
-
return undefined;
|
|
86
264
|
}
|
|
87
265
|
catch (error) {
|
|
88
|
-
|
|
266
|
+
// The bridge mounts with failOnStartupError for suite servers, so a
|
|
267
|
+
// connection or initialization failure rejects here. Drop the
|
|
268
|
+
// half-mounted handle before reporting so no orphan child survives a
|
|
269
|
+
// failed startup.
|
|
270
|
+
if (handle !== undefined) {
|
|
271
|
+
try {
|
|
272
|
+
await handle.dispose();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Ignore teardown errors: the startup failure is the real signal.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { reason: `mount failed: ${error instanceof Error ? error.message : String(error)}`, code: 'mount-failed' };
|
|
89
279
|
}
|
|
280
|
+
this.live.set(mountKey(request.suiteId, request.serverKey), {
|
|
281
|
+
suiteId: request.suiteId,
|
|
282
|
+
serverKey: request.serverKey,
|
|
283
|
+
serverName: request.config.serverName,
|
|
284
|
+
configFingerprint: fingerprintOf(request),
|
|
285
|
+
disposer: () => handle.dispose()
|
|
286
|
+
});
|
|
287
|
+
this.names.set(request.config.serverName, `${request.suiteId}\u0000${request.serverKey}`);
|
|
288
|
+
return undefined;
|
|
90
289
|
}
|
|
91
290
|
async unmount(key, live) {
|
|
92
|
-
this.live.delete(key);
|
|
93
|
-
this.names.delete(live.serverName);
|
|
94
291
|
try {
|
|
95
292
|
await live.disposer();
|
|
293
|
+
this.live.delete(key);
|
|
294
|
+
this.names.delete(live.serverName);
|
|
295
|
+
return undefined;
|
|
96
296
|
}
|
|
97
297
|
catch (error) {
|
|
98
|
-
|
|
298
|
+
const reason = `unmount failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
299
|
+
this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${reason} (${live.suiteId}/${live.serverKey})`);
|
|
300
|
+
return reason;
|
|
99
301
|
}
|
|
100
302
|
}
|
|
101
303
|
}
|
|
102
304
|
function mountKey(suiteId, serverKey) {
|
|
103
305
|
return `${suiteId}\u0000${serverKey}`;
|
|
104
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Stable fingerprint of one resolved mount request: any change to the
|
|
309
|
+
* transport, url, headers, env, args, or auth shape produces a different
|
|
310
|
+
* fingerprint and forces a remount on the next reconcile pass.
|
|
311
|
+
*/
|
|
312
|
+
function fingerprintOf(request) {
|
|
313
|
+
return createHash('sha256').update(JSON.stringify(request.config)).digest('hex');
|
|
314
|
+
}
|