@oh-agent/dsh-mcp-manager 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +407 -2
- package/README.zh.md +225 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/adapters.js +346 -0
- package/lib/canonical.js +488 -0
- package/lib/client.js +1151 -0
- package/lib/dsh-patch.js +329 -0
- package/lib/dsh.js +259 -0
- package/lib/index.js +495 -0
- package/lib/ops.js +325 -0
- package/lib/probe.js +257 -0
- package/lib/scan.js +353 -0
- package/lib/store.js +257 -0
- package/lib/surgical.js +581 -0
- package/lib/toggle.js +194 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host side of the MCP manager.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* 1. serve the matrix, diff, probe, history and mutation routes the settings
|
|
6
|
+
* page calls;
|
|
7
|
+
* 2. expose the same operations to the agent as a `mcp_manage` tool, so the
|
|
8
|
+
* page and the agent cannot diverge (one implementation, two callers);
|
|
9
|
+
* 3. keep every write guarded, backed up, and recorded in the ledger.
|
|
10
|
+
*
|
|
11
|
+
* The plugin is dormant rather than broken in profiles without `webServer` or
|
|
12
|
+
* `tools`: those are optional injection points, and the scan still works with
|
|
13
|
+
* nothing but a filesystem.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { createStore } from './store.js';
|
|
18
|
+
import { scanAll } from './scan.js';
|
|
19
|
+
import { probeMany, probeServer } from './probe.js';
|
|
20
|
+
import { applyServerToTarget, removeServer, syncServer, snippetFor } from './ops.js';
|
|
21
|
+
import { CLIENTS, CLIENT_BY_ID } from './adapters.js';
|
|
22
|
+
import { redactServer, isSecretKey, isSecretValue, maskValue, dshMountName } from './canonical.js';
|
|
23
|
+
import { toggleServer } from './toggle.js';
|
|
24
|
+
import { mcpEntries } from './dsh.js';
|
|
25
|
+
|
|
26
|
+
export const name = 'mcp-manager';
|
|
27
|
+
|
|
28
|
+
export const inject = ['webServer', 'tools', 'configEditor', 'workspaceRegistry'];
|
|
29
|
+
|
|
30
|
+
const ROUTE = '/api/dsh-mcp-manager';
|
|
31
|
+
|
|
32
|
+
function json(res, status, payload) {
|
|
33
|
+
if (!res || res.writableEnded) return;
|
|
34
|
+
const body = JSON.stringify(payload ?? null);
|
|
35
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body), 'cache-control': 'no-store' });
|
|
36
|
+
res.end(body);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function errorJson(res, error) {
|
|
40
|
+
const status = Number.isInteger(error?.statusCode) ? error.statusCode : 500;
|
|
41
|
+
json(res, status, {
|
|
42
|
+
ok: false,
|
|
43
|
+
...(typeof error?.code === 'string' ? { code: error.code } : {}),
|
|
44
|
+
error: String(error?.message ?? error),
|
|
45
|
+
...(error?.hint ? { hint: error.hint } : {}),
|
|
46
|
+
...(error?.conflict ? { conflict: error.conflict } : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Map an operation's failure code onto the HTTP status it deserves.
|
|
52
|
+
*
|
|
53
|
+
* Everything used to be a 400, which hid the one case a caller must handle
|
|
54
|
+
* differently: a compare-and-set conflict means "someone else changed the file
|
|
55
|
+
* since you read it" — that is a 409, and the UI offers a re-scan on it rather
|
|
56
|
+
* than showing a validation error. Unknown code/action stay 400; an unreadable
|
|
57
|
+
* or unwritable file is a 500-class server-side condition.
|
|
58
|
+
*/
|
|
59
|
+
const STATUS_BY_CODE = new Map([
|
|
60
|
+
['error.file.conflict', 409],
|
|
61
|
+
['error.file.read', 500],
|
|
62
|
+
['error.file.write', 500],
|
|
63
|
+
['error.file.parse', 422],
|
|
64
|
+
['error.write.render', 422],
|
|
65
|
+
['error.target.readOnly', 403],
|
|
66
|
+
['error.target.transport', 422],
|
|
67
|
+
['error.secret.projectScope', 422],
|
|
68
|
+
['error.backup.missing', 404],
|
|
69
|
+
['error.route.missing', 404],
|
|
70
|
+
['error.action.unknown', 400],
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function statusFor(result) {
|
|
74
|
+
if (result && typeof result.code === 'string') {
|
|
75
|
+
return STATUS_BY_CODE.get(result.code) ?? 400;
|
|
76
|
+
}
|
|
77
|
+
return 400;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Reject cross-origin and non-JSON mutations, matching host server policy. */
|
|
81
|
+
function validateOrigin(req) {
|
|
82
|
+
const host = typeof req.headers.host === 'string' ? req.headers.host : '';
|
|
83
|
+
if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host)) return 'forbidden host';
|
|
84
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return 'forbidden cross-site request';
|
|
85
|
+
const origin = req.headers.origin;
|
|
86
|
+
if (typeof origin === 'string') {
|
|
87
|
+
try {
|
|
88
|
+
if (new URL(origin).host !== host) return 'forbidden origin';
|
|
89
|
+
} catch {
|
|
90
|
+
return 'forbidden origin';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readBody(req) {
|
|
97
|
+
const chunks = [];
|
|
98
|
+
let size = 0;
|
|
99
|
+
for await (const chunk of req) {
|
|
100
|
+
size += chunk.length;
|
|
101
|
+
if (size > 2 * 1024 * 1024) throw Object.assign(new Error('request body too large'), { statusCode: 413 });
|
|
102
|
+
chunks.push(chunk);
|
|
103
|
+
}
|
|
104
|
+
if (chunks.length === 0) return {};
|
|
105
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(text);
|
|
108
|
+
} catch {
|
|
109
|
+
throw Object.assign(new Error('body must be JSON'), { statusCode: 400, code: 'error.proto.json' });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function registeredWorkspaceRoots(ctx) {
|
|
114
|
+
const out = new Set();
|
|
115
|
+
try {
|
|
116
|
+
const registry = typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : undefined;
|
|
117
|
+
if (registry && typeof registry.list === 'function') {
|
|
118
|
+
for (const item of registry.list()) {
|
|
119
|
+
const path = item?.path ?? item?.cwd;
|
|
120
|
+
if (typeof path === 'string' && path.trim()) out.add(path.trim());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch { /* workspace registry is optional */ }
|
|
124
|
+
return [...out];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function apply(ctx, config = {}) {
|
|
128
|
+
const options = {
|
|
129
|
+
probeTimeoutMs: Number.isFinite(config.probeTimeoutMs) ? config.probeTimeoutMs : 20000,
|
|
130
|
+
backupsToKeep: Number.isFinite(config.backupsToKeep) ? config.backupsToKeep : 20,
|
|
131
|
+
includeProjectScope: config.includeProjectScope !== false,
|
|
132
|
+
probeConcurrency: Number.isFinite(config.probeConcurrency) ? config.probeConcurrency : 4,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const home = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh');
|
|
136
|
+
const store = createStore({ root: join(home, 'mcp-manager'), backupsToKeep: options.backupsToKeep });
|
|
137
|
+
store.ensureDirs();
|
|
138
|
+
|
|
139
|
+
/*
|
|
140
|
+
* The patch layer lives in the profile directory, which the harness exports
|
|
141
|
+
* separately from DSH_HOME; derive it so a non-default profile still works.
|
|
142
|
+
*/
|
|
143
|
+
const profileDir = process.env.DSH_PROFILE_DIR || join(home, 'profiles', process.env.DSH_PROFILE || 'desktop');
|
|
144
|
+
|
|
145
|
+
const configEditor = () => (typeof ctx.get === 'function' ? ctx.get('configEditor') : undefined);
|
|
146
|
+
|
|
147
|
+
const projectRoots = () => (options.includeProjectScope ? registeredWorkspaceRoots(ctx) : []);
|
|
148
|
+
|
|
149
|
+
const snapshot = (reveal = false) => scanAll({ store, configEditor: configEditor(), projectRoots: projectRoots(), reveal, profileDir });
|
|
150
|
+
|
|
151
|
+
/* ---------------------------------------------------------- operations -- */
|
|
152
|
+
|
|
153
|
+
async function opScan({ reveal = false } = {}) {
|
|
154
|
+
const state = snapshot(reveal);
|
|
155
|
+
return {
|
|
156
|
+
...state,
|
|
157
|
+
clients: state.clients.map((client) => ({ ...client, canWrite: !client.readOnly || client.managed })),
|
|
158
|
+
history: store.recentWrites(20),
|
|
159
|
+
backups: store.listBackups(20),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The switch: mount or unmount one server in DeepSeek Harness.
|
|
165
|
+
*
|
|
166
|
+
* A server that only exists in another tool is not "off" in any file — it is
|
|
167
|
+
* simply not mounted here. Turning it on therefore mounts it (via the patch
|
|
168
|
+
* layer, which is how a new mcp-client row gets created), and turning it off
|
|
169
|
+
* after that unmounts it. Nothing in the originating tool is ever written.
|
|
170
|
+
*/
|
|
171
|
+
async function opToggle(body) {
|
|
172
|
+
const name = body.name;
|
|
173
|
+
if (!name) return { ok: false, code: 'error.input', error: 'name is required' };
|
|
174
|
+
if (typeof body.enabled !== 'boolean') return { ok: false, code: 'error.input', error: 'enabled must be a boolean' };
|
|
175
|
+
const state = snapshot(true);
|
|
176
|
+
const node = state.servers.find((item) => item.name === name);
|
|
177
|
+
const mounted = node?.targets.find((target) => target.clientId === 'dsh');
|
|
178
|
+
/*
|
|
179
|
+
* The composition keys the row by mount name; a CJK or spaced display name
|
|
180
|
+
* is mapped (see `dshMountName`). Use the name the scan actually read off
|
|
181
|
+
* the row when it knows it, and derive the same mapping otherwise, so the
|
|
182
|
+
* row we address, the row we wait for and the row the scan reports are one
|
|
183
|
+
* and the same.
|
|
184
|
+
*/
|
|
185
|
+
const mountName = mounted?.mountedAs ?? mounted?.server?.mountName ?? dshMountName(name);
|
|
186
|
+
if (mounted) {
|
|
187
|
+
const result = await toggleServer({ get: (key) => ctx.get(key), profileDir, store }, { name, mountName, enabled: body.enabled, entryId: mounted.entryId ?? node.canonical?.entryId });
|
|
188
|
+
/*
|
|
189
|
+
* A patch-layer flip (an override item added or removed) is picked up by the
|
|
190
|
+
* Loader on its own, so give it the same chance to reconcile. As above, a
|
|
191
|
+
* timeout means "still activating", not "restart required".
|
|
192
|
+
*/
|
|
193
|
+
if (result.ok && result.via === 'patch' && body.enabled === true) {
|
|
194
|
+
const reconciled = await waitForRow(mountName, 8000);
|
|
195
|
+
return { ...result, name, mountName, live: reconciled !== null, activating: reconciled === null, restartRequired: false, state: snapshot(body.reveal === true) };
|
|
196
|
+
}
|
|
197
|
+
return { ...result, name, state: snapshot(body.reveal === true) };
|
|
198
|
+
}
|
|
199
|
+
if (body.enabled !== true) {
|
|
200
|
+
return { ok: true, changed: false, enabled: false, name, notMounted: true, state: snapshot(body.reveal === true) };
|
|
201
|
+
}
|
|
202
|
+
if (!node) return { ok: false, code: 'error.server.missing', error: `unknown server ${name}` };
|
|
203
|
+
const applied = await applyServerToTarget({ store, configEditor: configEditor(), profileDir }, { clientId: 'dsh', scope: 'profile', server: node.canonical, force: body.force === true });
|
|
204
|
+
if (!applied.ok) return { ...applied, name, enabled: true };
|
|
205
|
+
/*
|
|
206
|
+
* A row written into the profile patch layer is picked up by the Loader on
|
|
207
|
+
* its own — no restart. Wait for it so the answer reflects what actually
|
|
208
|
+
* happened instead of telling the user to restart a service that already has
|
|
209
|
+
* the server.
|
|
210
|
+
*
|
|
211
|
+
* Measured on this machine a row usually reconciles in 2-4s, but mounting
|
|
212
|
+
* several back to back (each one reloading the composition and doing an MCP
|
|
213
|
+
* handshake) can push past any fixed budget. Bumping the timeout alone did
|
|
214
|
+
* not fix it, so the timeout is no longer allowed to decide the ANSWER:
|
|
215
|
+
*
|
|
216
|
+
* reconciled inside the window -> live: true
|
|
217
|
+
* not yet -> activating: true (never "restart")
|
|
218
|
+
*
|
|
219
|
+
* A patch row is only ever written when the Loader can see it, so it always
|
|
220
|
+
* arrives eventually; claiming a restart is needed was simply wrong and sent
|
|
221
|
+
* the user to restart for nothing. `restartRequired` is kept for the caller
|
|
222
|
+
* contract but is now false on this path.
|
|
223
|
+
*/
|
|
224
|
+
const reconciled = applied.via === 'patch' ? await waitForRow(mountName, 8000) : null;
|
|
225
|
+
return {
|
|
226
|
+
...applied,
|
|
227
|
+
name,
|
|
228
|
+
mountName,
|
|
229
|
+
enabled: true,
|
|
230
|
+
via: applied.via ?? 'patch',
|
|
231
|
+
live: reconciled !== null,
|
|
232
|
+
activating: applied.via === 'patch' && reconciled === null,
|
|
233
|
+
restartRequired: false,
|
|
234
|
+
state: snapshot(body.reveal === true),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Poll the composition until the Loader has reconciled a row we just wrote.
|
|
240
|
+
* Returns the entryId, or null when it never showed up inside the budget — in
|
|
241
|
+
* which case the caller reports it as pending rather than claiming it is live.
|
|
242
|
+
*/
|
|
243
|
+
async function waitForRow(name, budgetMs) {
|
|
244
|
+
const deadline = Date.now() + budgetMs;
|
|
245
|
+
for (;;) {
|
|
246
|
+
const row = mcpEntries(configEditor()).find((item) => {
|
|
247
|
+
const config = { ...item.inherited, ...item.override };
|
|
248
|
+
return (config.serverName ?? item.entry?.id) === name;
|
|
249
|
+
});
|
|
250
|
+
if (row) return row.entry?.id ?? true;
|
|
251
|
+
if (Date.now() >= deadline) return null;
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function opProbe({ names, timeoutMs } = {}) {
|
|
257
|
+
const state = snapshot(true);
|
|
258
|
+
const wanted = Array.isArray(names) && names.length ? new Set(names) : null;
|
|
259
|
+
const queue = state.servers
|
|
260
|
+
.filter((server) => !wanted || wanted.has(server.name))
|
|
261
|
+
.map((server) => ({ key: server.name, server: server.canonical }));
|
|
262
|
+
const results = await probeMany(queue, { timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : options.probeTimeoutMs, concurrency: options.probeConcurrency });
|
|
263
|
+
return {
|
|
264
|
+
results: Object.fromEntries([...results].map(([key, value]) => [key, {
|
|
265
|
+
...value,
|
|
266
|
+
tools: (value.tools ?? []).map((tool) => ({ name: tool.name, description: tool.description })),
|
|
267
|
+
}])),
|
|
268
|
+
probedAt: new Date().toISOString(),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function opApply(body) {
|
|
273
|
+
/*
|
|
274
|
+
* Resolve the definition from the host-side scan (revealed), never from the
|
|
275
|
+
* request body: what the browser holds is the redacted copy, so writing
|
|
276
|
+
* `body.server` back would persist `abcd••••wxyz` over a real credential.
|
|
277
|
+
* The body is only consulted for servers the scan does not know about.
|
|
278
|
+
*/
|
|
279
|
+
const state = snapshot(true);
|
|
280
|
+
const known = state.servers.find((item) => item.name === (body.name ?? body.server?.name));
|
|
281
|
+
const server = known?.canonical ?? body.server;
|
|
282
|
+
if (!server) return { ok: false, code: 'error.server.missing', error: `unknown server ${body.name}` };
|
|
283
|
+
const target = body.target ?? {};
|
|
284
|
+
const expectedHash = body.expectedHash ?? state.locations.find((item) => item.clientId === target.clientId && item.scope === target.scope && item.path === target.path)?.hash ?? undefined;
|
|
285
|
+
return applyServerToTarget({ store, configEditor: configEditor(), profileDir }, { ...target, server, expectedHash, force: body.force === true, reuseName: body.reuseName !== false });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function opRemove(body) {
|
|
289
|
+
const state = snapshot(true);
|
|
290
|
+
const expectedHash = body.expectedHash ?? state.locations.find((item) => item.clientId === body.clientId && item.scope === body.scope)?.hash ?? undefined;
|
|
291
|
+
return removeServer({ store, configEditor: configEditor(), profileDir }, { ...body, expectedHash });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function opSync(body) {
|
|
295
|
+
/* Same rule as opApply: the host holds the real definition, the client does not. */
|
|
296
|
+
const state = snapshot(true);
|
|
297
|
+
const known = state.servers.find((item) => item.name === (body.name ?? body.server?.name));
|
|
298
|
+
const server = known?.canonical ?? body.server;
|
|
299
|
+
if (!server) return { ok: false, code: 'error.server.missing', error: `unknown server ${body.name}` };
|
|
300
|
+
const expectedHashes = Object.fromEntries(state.locations.map((item) => [`${item.clientId}:${item.scope}:${item.path}`, item.hash]));
|
|
301
|
+
return syncServer({ store, configEditor: configEditor(), profileDir }, { server, targets: body.targets ?? [], expectedHashes, force: body.force === true });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function opSnippet(body) {
|
|
305
|
+
const state = snapshot(true);
|
|
306
|
+
const known = state.servers.find((item) => item.name === (body.name ?? body.server?.name));
|
|
307
|
+
const server = known?.canonical ?? body.server;
|
|
308
|
+
if (!server) return { ok: false, code: 'error.server.missing', error: `unknown server ${body.name}` };
|
|
309
|
+
return { ok: true, yaml: snippetFor(server) };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function opReveal(body) {
|
|
313
|
+
const state = snapshot(false);
|
|
314
|
+
const server = state.servers.find((item) => item.name === body.name);
|
|
315
|
+
if (!server) return { ok: false, code: 'error.server.missing', error: `unknown server ${body.name}` };
|
|
316
|
+
return { ok: true, server: redactServer(server.canonical, true) };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function opRestore(body) {
|
|
320
|
+
if (!body.backup || !body.path) return { ok: false, code: 'error.input', error: 'backup and path are required' };
|
|
321
|
+
return store.restore(body.backup, body.path);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const dispatch = async (action, body = {}) => {
|
|
325
|
+
switch (action) {
|
|
326
|
+
case 'scan': return opScan(body);
|
|
327
|
+
case 'probe': return opProbe(body);
|
|
328
|
+
case 'apply': return opApply(body);
|
|
329
|
+
case 'remove': return opRemove(body);
|
|
330
|
+
case 'sync': return opSync(body);
|
|
331
|
+
case 'snippet': return opSnippet(body);
|
|
332
|
+
case 'reveal': return opReveal(body);
|
|
333
|
+
case 'toggle': return opToggle(body);
|
|
334
|
+
case 'restore': return opRestore(body);
|
|
335
|
+
case 'history': return { ok: true, writes: store.recentWrites(100), backups: store.listBackups(100) };
|
|
336
|
+
default: return { ok: false, code: 'error.action.unknown', error: `unknown action ${action}` };
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/* --------------------------------------------------------------- routes -- */
|
|
341
|
+
|
|
342
|
+
const webServer = typeof ctx.get === 'function' ? ctx.get('webServer') : undefined;
|
|
343
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
344
|
+
ctx.effect(() => webServer.register({
|
|
345
|
+
kind: 'prefix',
|
|
346
|
+
path: ROUTE,
|
|
347
|
+
handler: async (req, res) => {
|
|
348
|
+
try {
|
|
349
|
+
const originError = validateOrigin(req);
|
|
350
|
+
if (originError) { json(res, 403, { ok: false, code: 'error.proto.forbidden', error: originError }); return; }
|
|
351
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
352
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
353
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
354
|
+
if (path === `${ROUTE}/state`) {
|
|
355
|
+
const state = await opScan({ reveal: url.searchParams.get('reveal') === '1' });
|
|
356
|
+
json(res, 200, { ok: true, data: state });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (path === `${ROUTE}/clients`) {
|
|
360
|
+
json(res, 200, { ok: true, data: { clients: CLIENTS } });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (path === `${ROUTE}/history`) {
|
|
364
|
+
json(res, 200, { ok: true, data: { writes: store.recentWrites(100), backups: store.listBackups(100) } });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
json(res, 404, { ok: false, code: 'error.route.missing', error: `no route ${path}` });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, code: 'error.method', error: 'method not allowed' }); return; }
|
|
371
|
+
const contentType = String(req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
|
|
372
|
+
if (contentType !== 'application/json') { json(res, 415, { ok: false, code: 'error.proto.contentType', error: 'content-type must be application/json' }); return; }
|
|
373
|
+
/*
|
|
374
|
+
* A custom header cannot be set by a cross-site form or a plain
|
|
375
|
+
* <script>/<img> request, so requiring it (on top of the JSON
|
|
376
|
+
* content-type and the origin check) is what makes a mutation
|
|
377
|
+
* unreachable without our own page or an explicit caller. Checked
|
|
378
|
+
* before the body is read so a refusal costs nothing.
|
|
379
|
+
*/
|
|
380
|
+
if (String(req.headers['x-dsh-mcp-manager'] ?? '') !== '1') {
|
|
381
|
+
json(res, 403, { ok: false, code: 'error.proto.guard', error: 'missing x-dsh-mcp-manager: 1 header' });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const body = await readBody(req);
|
|
385
|
+
const action = path.startsWith(`${ROUTE}/`) ? path.slice(ROUTE.length + 1) : '';
|
|
386
|
+
const result = await dispatch(action, body);
|
|
387
|
+
const failed = result && result.ok === false;
|
|
388
|
+
json(res, failed ? statusFor(result) : 200, failed ? result : { ok: true, data: result });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
errorJson(res, error);
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
}), 'mcp-manager routes');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ----------------------------------------------------------------- tool -- */
|
|
397
|
+
|
|
398
|
+
const tools = typeof ctx.get === 'function' ? ctx.get('tools') : undefined;
|
|
399
|
+
if (tools && typeof tools.register === 'function') {
|
|
400
|
+
ctx.effect(() => tools.register({
|
|
401
|
+
name: 'mcp_manage',
|
|
402
|
+
description: [
|
|
403
|
+
'Inspect and manage Model Context Protocol servers across the local AI development tools installed on this machine',
|
|
404
|
+
'(DeepSeek Harness, Claude Code, Cursor, VS Code, Roo Code, OpenCode, Trae, Trae CN, Qoder, Gemini CLI, Zed and others).',
|
|
405
|
+
'Use action "scan" to see every server and which tools it is configured in, "probe" to check whether a server actually starts and lists tools,',
|
|
406
|
+
'"toggle" to mount or unmount one server in DeepSeek Harness only (it never edits another tool\'s own config file),',
|
|
407
|
+
'"sync" to copy one definition to other tools, "apply" to write one definition to one target, "remove" to delete one, and "snippet" to get a YAML row for adding a brand-new DeepSeek Harness server.',
|
|
408
|
+
].join(' '),
|
|
409
|
+
parameters: {
|
|
410
|
+
type: 'object',
|
|
411
|
+
properties: {
|
|
412
|
+
action: { type: 'string', enum: ['scan', 'probe', 'apply', 'remove', 'sync', 'snippet', 'toggle', 'reveal', 'history'], description: 'Operation to perform.' },
|
|
413
|
+
name: { type: 'string', description: 'Server name, as reported by scan.' },
|
|
414
|
+
enabled: { type: 'boolean', description: 'For toggle: true mounts the server in DeepSeek Harness, false unmounts it.' },
|
|
415
|
+
clientId: { type: 'string', description: 'Target client id (see scan output).' },
|
|
416
|
+
scope: { type: 'string', enum: ['global', 'project', 'profile'], description: 'Target scope. Defaults to global.' },
|
|
417
|
+
path: { type: 'string', description: 'Explicit target file path, overriding the client/scope resolution.' },
|
|
418
|
+
clientIds: { type: 'array', items: { type: 'string' }, description: 'For sync: client ids to copy the definition into.' },
|
|
419
|
+
names: { type: 'array', items: { type: 'string' }, description: 'For probe: server names; omit to probe everything.' },
|
|
420
|
+
server: { type: 'object', description: 'For apply/sync: a full canonical server definition (name, transport, command, args, env, url, headers, timeoutMs).' },
|
|
421
|
+
reveal: { type: 'boolean', description: 'Return credential literals instead of masked values.' },
|
|
422
|
+
},
|
|
423
|
+
required: ['action'],
|
|
424
|
+
additionalProperties: false,
|
|
425
|
+
},
|
|
426
|
+
output: {
|
|
427
|
+
schema: { type: 'object' },
|
|
428
|
+
render(_args, value) {
|
|
429
|
+
if (!value || value.ok === false) return [{ type: 'text', text: `MCP operation failed${value?.code ? ` (${value.code})` : ''}: ${value?.error ?? 'unknown error'}` }];
|
|
430
|
+
const data = value.data ?? value;
|
|
431
|
+
/*
|
|
432
|
+
* A toggle answers with a fresh snapshot attached, which would dump
|
|
433
|
+
* every server into the transcript. Report the one line that matters.
|
|
434
|
+
*/
|
|
435
|
+
if (data.name && typeof data.enabled === 'boolean' && data.state) {
|
|
436
|
+
const what = data.enabled ? 'mounted in DeepSeek Harness' : 'unmounted from DeepSeek Harness';
|
|
437
|
+
const when = data.live
|
|
438
|
+
? ' — live now, its tools are callable'
|
|
439
|
+
: (data.activating ? ' — DeepSeek Harness is activating it; its tools appear in a moment' : '');
|
|
440
|
+
return [{ type: 'text', text: `${data.name}: ${what}${when}` }];
|
|
441
|
+
}
|
|
442
|
+
if (data.clients && data.servers) {
|
|
443
|
+
const lines = [`${data.servers.length} MCP server(s) across ${data.clients.filter((client) => client.installed).length} installed tool(s).`];
|
|
444
|
+
for (const server of data.servers.slice(0, 40)) {
|
|
445
|
+
/*
|
|
446
|
+
* Mount state is the one thing a reader acts on, so it belongs on
|
|
447
|
+
* the line rather than in a second pass over the payload.
|
|
448
|
+
*/
|
|
449
|
+
const dsh = (server.targets ?? []).find((target) => target.clientId === 'dsh');
|
|
450
|
+
const mount = dsh ? (dsh.enabled === false ? ' [dsh: off]' : ' [dsh: mounted]') : '';
|
|
451
|
+
lines.push(`- ${server.name} [${server.transport}]${mount} in: ${server.clients.join(', ') || 'none'}`);
|
|
452
|
+
}
|
|
453
|
+
if (data.servers.length > 40) lines.push(`… ${data.servers.length - 40} more`);
|
|
454
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
455
|
+
}
|
|
456
|
+
if (data.results) {
|
|
457
|
+
const entries = Object.entries(data.results);
|
|
458
|
+
const ok = entries.filter(([, item]) => item.status === 'ok').length;
|
|
459
|
+
const lines = [`Probed ${entries.length} server(s): ${ok} healthy, ${entries.length - ok} failed.`];
|
|
460
|
+
for (const [key, item] of entries) {
|
|
461
|
+
lines.push(`- ${key}: ${item.status === 'ok' ? `ok (${item.toolCount} tools, ${item.elapsedMs}ms)` : `${item.code} — ${item.detail}`}`);
|
|
462
|
+
}
|
|
463
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
464
|
+
}
|
|
465
|
+
if (data.yaml) return [{ type: 'text', text: data.yaml }];
|
|
466
|
+
return [{ type: 'text', text: JSON.stringify(data, null, 2) }];
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
async execute(args) {
|
|
470
|
+
const body = {
|
|
471
|
+
name: args.name,
|
|
472
|
+
clientId: args.clientId,
|
|
473
|
+
scope: args.scope,
|
|
474
|
+
path: args.path,
|
|
475
|
+
names: args.names,
|
|
476
|
+
server: args.server,
|
|
477
|
+
reveal: args.reveal === true,
|
|
478
|
+
enabled: args.enabled,
|
|
479
|
+
targets: (args.clientIds ?? []).map((clientId) => ({ clientId, scope: args.scope ?? 'global' })),
|
|
480
|
+
};
|
|
481
|
+
if (args.action === 'apply' || args.action === 'remove' || args.action === 'sync' || args.action === 'toggle') {
|
|
482
|
+
// A tool call must not silently overwrite a file the user edited.
|
|
483
|
+
body.force = false;
|
|
484
|
+
}
|
|
485
|
+
const result = await dispatch(args.action, body);
|
|
486
|
+
return result;
|
|
487
|
+
},
|
|
488
|
+
presentCall(args) {
|
|
489
|
+
return { card: 'generic', title: `MCP ${args.action}`, kind: 'edit', rawInput: args.name ?? args.action };
|
|
490
|
+
},
|
|
491
|
+
}), 'mcp-manager tool');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export { maskValue, isSecretKey, isSecretValue, CLIENT_BY_ID };
|