@aiwayds/dsh-mcp-adapter 0.4.2 → 0.5.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/README.md +12 -5
- package/README.zh.md +12 -5
- package/cordis.patch.yml +6 -0
- package/icon.svg +5 -0
- package/lib/index.d.ts +180 -51
- package/lib/index.js +364 -107
- package/lib/index.js.map +1 -1
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +36 -3
- package/scripts/smoke-boot.mjs +3 -3
package/lib/index.js
CHANGED
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
* three latches at once — its tools force-fold out of every prompt (keep and
|
|
34
34
|
* `servers` whitelist exemptions included), it disappears from the catalog,
|
|
35
35
|
* and `mcp_call` refuses it with an enable hint. Each server gets a stable
|
|
36
|
-
* numeric id (1..99, smallest free first) persisted in the
|
|
37
|
-
*
|
|
36
|
+
* numeric id (1..99, smallest free first) persisted in the plugin's own gate
|
|
37
|
+
* file under the dsh home (`storages/mcp-adapter/gate.json`), so ids survive
|
|
38
|
+
* restarts and re-syncs.
|
|
38
39
|
*
|
|
39
40
|
* Child-owned projections are preserved: `mcp_call` delegates its
|
|
40
41
|
* `output.render` to the dispatched child and forwards the child's
|
|
@@ -52,6 +53,10 @@
|
|
|
52
53
|
*
|
|
53
54
|
* @module @aiwayds/dsh-mcp-adapter
|
|
54
55
|
*/
|
|
56
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
57
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
58
|
+
import { homedir } from 'node:os';
|
|
59
|
+
import { basename, join, resolve as resolvePath } from 'node:path';
|
|
55
60
|
import z from '@deepseek-ai/schemastery';
|
|
56
61
|
/** Cordis plugin name used by loader diagnostics. */
|
|
57
62
|
export const name = 'mcp-adapter';
|
|
@@ -87,19 +92,19 @@ const PREFIX_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
87
92
|
/** Upper bound of the stable per-server id space. */
|
|
88
93
|
export const MCP_SERVER_ID_LIMIT = 99;
|
|
89
94
|
/**
|
|
90
|
-
* dsh
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
95
|
+
* dsh 0.1.7 note: the runtime settings registry this state used to live in
|
|
96
|
+
* (`settings.register('mcp-adapter', …)` on the 0.1.5 `SettingsScope`) is
|
|
97
|
+
* gone — 0.1.7 settings are the projection of a plugin's `Config` schema and
|
|
98
|
+
* volatile fields are user-editable values, which a machine-maintained id
|
|
99
|
+
* registry must never be. The gate therefore persists through the plugin's
|
|
100
|
+
* OWN file under the dsh home (same seam as dsh-cron/dsh-vault):
|
|
101
|
+
* `<dsh home>/storages/mcp-adapter/gate.json`.
|
|
94
102
|
*/
|
|
95
|
-
export const
|
|
103
|
+
export const MCP_ADAPTER_STORAGE_DIRNAME = 'mcp-adapter';
|
|
104
|
+
/** The gate document file name inside {@link MCP_ADAPTER_STORAGE_DIRNAME}. */
|
|
105
|
+
export const GATE_FILE_NAME = 'gate.json';
|
|
96
106
|
/** Gate snapshot meaning "nothing observed, nothing disabled". */
|
|
97
107
|
export const EMPTY_SERVER_GATE = { serverIds: {}, disabled: [] };
|
|
98
|
-
/** Schema of the persisted `mcp-adapter` settings section. */
|
|
99
|
-
export const SERVER_ID_REGISTRY_SCHEMA = z.object({
|
|
100
|
-
serverIds: z.dict(z.number().step(1).min(1).max(MCP_SERVER_ID_LIMIT)).default({}),
|
|
101
|
-
disabled: z.array(z.number().step(1).min(1).max(MCP_SERVER_ID_LIMIT)).default([]),
|
|
102
|
-
});
|
|
103
108
|
/** Whether one number is an allocatable server id. */
|
|
104
109
|
function isValidServerId(id) {
|
|
105
110
|
return typeof id === 'number' && Number.isInteger(id) && id >= 1 && id <= MCP_SERVER_ID_LIMIT;
|
|
@@ -212,11 +217,278 @@ export function setServerDisabledState(state, server, disable) {
|
|
|
212
217
|
changed: disable ? id !== undefined && !has : has,
|
|
213
218
|
};
|
|
214
219
|
}
|
|
220
|
+
/** Resolve the dsh home (`DSH_HOME`, else `~/.dsh`) — same seam as dsh-cron/dsh-vault. */
|
|
221
|
+
export function resolveDshHome() {
|
|
222
|
+
return process.env.DSH_HOME !== undefined && process.env.DSH_HOME !== ''
|
|
223
|
+
? process.env.DSH_HOME
|
|
224
|
+
: join(homedir(), '.dsh');
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Absolute gate directory for one `storageDir` config value: empty (default)
|
|
228
|
+
* means `<dsh home>/storages/mcp-adapter`, anything else is the override used
|
|
229
|
+
* verbatim (resolved).
|
|
230
|
+
*/
|
|
231
|
+
export function resolveGateDir(configured) {
|
|
232
|
+
return configured.trim() === ''
|
|
233
|
+
? join(resolveDshHome(), 'storages', MCP_ADAPTER_STORAGE_DIRNAME)
|
|
234
|
+
: resolvePath(configured);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Load the gate mirror from one gate file. A missing file is the fresh-install
|
|
238
|
+
* zero state (silent); an unreadable or unparseable one is warned about once
|
|
239
|
+
* here and degrades to the empty gate — every server counts as enabled, the
|
|
240
|
+
* fail-open hard requirement.
|
|
241
|
+
*/
|
|
242
|
+
function loadInitialGate(file, warn) {
|
|
243
|
+
try {
|
|
244
|
+
return normalizeServerGate(JSON.parse(readFileSync(file, 'utf8')));
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (error?.code !== 'ENOENT') {
|
|
248
|
+
warn(`mcp-adapter: the persisted enable/disable state at ${file} is unreadable `
|
|
249
|
+
+ `(${error instanceof Error ? error.message : String(error)}) `
|
|
250
|
+
+ '— starting from an empty gate (every server enabled)');
|
|
251
|
+
}
|
|
252
|
+
return EMPTY_SERVER_GATE;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Build the production gate store over one directory. The initial read is
|
|
257
|
+
* synchronous (boot-time, once); writes land atomically (tmp file + rename)
|
|
258
|
+
* so a crash mid-write can never leave a torn document, and the in-memory
|
|
259
|
+
* mirror only advances after the file is durably renamed into place.
|
|
260
|
+
*
|
|
261
|
+
* Legacy absorption (one-time, 0.1.5 → 0.1.7): on a FRESH gate — no
|
|
262
|
+
* gate.json yet — the machine state from the removed settings registry is
|
|
263
|
+
* absorbed once from the host's legacy settings documents (see
|
|
264
|
+
* {@link absorbLegacyGate}). Opt-in through `options.home`: the legacy
|
|
265
|
+
* documents are a HOME-level concern (not this store's `dir`), and stores
|
|
266
|
+
* built without a home (bare test harnesses) stay exactly as isolated as
|
|
267
|
+
* before.
|
|
268
|
+
*/
|
|
269
|
+
export function createFileGateStore(dir, warn, options = {}) {
|
|
270
|
+
const file = join(dir, GATE_FILE_NAME);
|
|
271
|
+
const hadGateFile = existsSync(file);
|
|
272
|
+
let mirror = loadInitialGate(file, warn);
|
|
273
|
+
if (!hadGateFile) {
|
|
274
|
+
// Fresh install: one chance to take over the pre-0.1.7 gate state.
|
|
275
|
+
mirror = absorbLegacyGate(mirror, dir, warn, options);
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
get: () => mirror,
|
|
279
|
+
async write(next) {
|
|
280
|
+
// Copy-on-write payload: the document is complete and self-contained,
|
|
281
|
+
// exactly what the old wholesale settings `replace` guaranteed.
|
|
282
|
+
const payload = JSON.stringify({ serverIds: { ...next.serverIds }, disabled: [...next.disabled] }, null, 2) + '\n';
|
|
283
|
+
await mkdir(dir, { recursive: true });
|
|
284
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
285
|
+
await writeFile(tmp, payload, 'utf8');
|
|
286
|
+
await rename(tmp, file);
|
|
287
|
+
mirror = normalizeServerGate(next);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// ---- Legacy settings absorption (one-time, 0.1.5 → 0.1.7) ----
|
|
292
|
+
/**
|
|
293
|
+
* dsh 0.1.7 renamed the old `<home>/settings.yaml` to
|
|
294
|
+
* `settings.yaml.imported` after a one-shot import keyed by "section name =
|
|
295
|
+
* entry id". This plugin's old `mcp-adapter:` section (the machine-maintained
|
|
296
|
+
* id registry / disabled set) does not match the entry id `dsh-mcp-adapter`
|
|
297
|
+
* and was silently dropped by the host — the state lives on only in the
|
|
298
|
+
* renamed document. {@link absorbLegacyGate} recovers it once into the
|
|
299
|
+
* plugin's own gate.json.
|
|
300
|
+
*/
|
|
301
|
+
/** Legacy host settings documents, in read priority order. */
|
|
302
|
+
export const LEGACY_SETTINGS_FILES = ['settings.yaml.imported', 'settings.yaml'];
|
|
303
|
+
/** Legacy top-level section name in the old settings document. */
|
|
304
|
+
export const LEGACY_GATE_SECTION = 'mcp-adapter';
|
|
305
|
+
/** Audit marker file name: `<home>/storages/mcp-adapter/<name>`. */
|
|
306
|
+
export const LEGACY_IMPORT_MARKER = 'legacy-import.json';
|
|
307
|
+
/**
|
|
308
|
+
* Decode one YAML scalar of the old settings document — the shapes the 0.1.5
|
|
309
|
+
* writer actually produced (plain scalars, booleans, and quoted single-line
|
|
310
|
+
* flow-JSON payloads). A value that opens a quote it does not close on the
|
|
311
|
+
* same line is a FOLDED long scalar (dumpers wrap at 80 columns):
|
|
312
|
+
* unparseable here by design, so the key is skipped — never a half-read
|
|
313
|
+
* value.
|
|
314
|
+
*/
|
|
315
|
+
function decodeLegacyScalar(raw) {
|
|
316
|
+
const value = raw.trim();
|
|
317
|
+
if (value === '' || value === 'null' || value === '~')
|
|
318
|
+
return undefined;
|
|
319
|
+
if (value === 'true')
|
|
320
|
+
return true;
|
|
321
|
+
if (value === 'false')
|
|
322
|
+
return false;
|
|
323
|
+
const quote = value[0];
|
|
324
|
+
const closed = value.length >= 2 && value[value.length - 1] === quote;
|
|
325
|
+
if (quote === "'" || quote === '"') {
|
|
326
|
+
if (!closed || value.length < 2)
|
|
327
|
+
return undefined; // folded / unterminated — skip
|
|
328
|
+
if (quote === '"') {
|
|
329
|
+
// Double-quoted payloads are JSON-compatible as-is.
|
|
330
|
+
try {
|
|
331
|
+
return JSON.parse(value);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
338
|
+
}
|
|
339
|
+
const hash = value.indexOf(' #');
|
|
340
|
+
return hash >= 0 ? value.slice(0, hash).trim() : value;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Extract the top-level `mcp-adapter:` section of the old settings document.
|
|
344
|
+
* Handles both shapes the 0.1.5 writer produced:
|
|
345
|
+
* - single-line values (`serverIds: '{"fs": 1}'`, `disabled: '[1]'`);
|
|
346
|
+
* - block collections (`serverIds:` followed by deeper-indented
|
|
347
|
+
* `name: <int>` lines; `disabled:` followed by `- <int>` lines).
|
|
348
|
+
* Everything else (comments, folded scalars, nested junk) is skipped.
|
|
349
|
+
* undefined = no section found. Pure function — tests run it against
|
|
350
|
+
* literal strings.
|
|
351
|
+
*/
|
|
352
|
+
export function legacyGateSection(text) {
|
|
353
|
+
const lines = text.split('\n');
|
|
354
|
+
const start = lines.findIndex(line => /^mcp-adapter:\s*(#.*)?$/.test(line));
|
|
355
|
+
if (start < 0)
|
|
356
|
+
return undefined;
|
|
357
|
+
const section = {};
|
|
358
|
+
const ids = {};
|
|
359
|
+
const seq = [];
|
|
360
|
+
let block;
|
|
361
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
362
|
+
const line = lines[index];
|
|
363
|
+
if (line.trim() === '' || /^\s*#/.test(line))
|
|
364
|
+
continue;
|
|
365
|
+
if (/^\S/.test(line))
|
|
366
|
+
break; // the next top-level key ends the section
|
|
367
|
+
const indent = line.length - line.trimStart().length;
|
|
368
|
+
if (block !== undefined && indent > block.indent) {
|
|
369
|
+
// Children of an open block collection carry the machine state.
|
|
370
|
+
if (block.key === 'serverIds') {
|
|
371
|
+
const entry = /^([^\s:#]+):\s*(\d+)\s*(?:#.*)?$/.exec(line.trim());
|
|
372
|
+
if (entry !== null)
|
|
373
|
+
ids[entry[1]] = Number(entry[2]);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
const entry = /^-\s*(\d+)\s*(?:#.*)?$/.exec(line.trim());
|
|
377
|
+
if (entry !== null)
|
|
378
|
+
seq.push(Number(entry[1]));
|
|
379
|
+
}
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
block = undefined;
|
|
383
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*):[ \t]?(.*)$/.exec(line.trim());
|
|
384
|
+
if (match === null)
|
|
385
|
+
continue; // nested shape we do not model — skip the line
|
|
386
|
+
const [, key, rawValue] = match;
|
|
387
|
+
if (key !== 'serverIds' && key !== 'disabled')
|
|
388
|
+
continue;
|
|
389
|
+
const value = rawValue.trim();
|
|
390
|
+
if (value === '') {
|
|
391
|
+
// Opens a block collection (or is null — the children decide).
|
|
392
|
+
block = { key, indent };
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
section[key] = decodeLegacyScalar(rawValue);
|
|
396
|
+
}
|
|
397
|
+
if (Object.keys(ids).length > 0)
|
|
398
|
+
section.serverIds = ids;
|
|
399
|
+
if (seq.length > 0)
|
|
400
|
+
section.disabled = seq;
|
|
401
|
+
return section;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Turn one decoded section into the raw candidate {@link normalizeServerGate}
|
|
405
|
+
* sanitizes: flow-JSON strings are JSON-parsed (malformed → undefined, the
|
|
406
|
+
* key degrades to "absent"), already-decoded block collections pass through.
|
|
407
|
+
*/
|
|
408
|
+
export function legacyGateCandidate(section) {
|
|
409
|
+
const fromFlow = (value) => {
|
|
410
|
+
if (typeof value !== 'string')
|
|
411
|
+
return value;
|
|
412
|
+
try {
|
|
413
|
+
return JSON.parse(value);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
return { serverIds: fromFlow(section.serverIds), disabled: fromFlow(section.disabled) };
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The one-time absorption pass over the legacy documents. Marker semantics
|
|
423
|
+
* match the sibling plugins: written for EVERY settled outcome EXCEPT the
|
|
424
|
+
* retry-worthy failure (an unexpected error — no marker, the next boot
|
|
425
|
+
* retries); a present marker short-circuits the whole pass (idempotent — a
|
|
426
|
+
* later manual edit of the imported file must not resurrect old values).
|
|
427
|
+
* Returns the mirror to run on (the absorbed state, or the input unchanged).
|
|
428
|
+
*/
|
|
429
|
+
function absorbLegacyGate(current, dir, warn, options) {
|
|
430
|
+
try {
|
|
431
|
+
const home = options.home;
|
|
432
|
+
if (home === undefined)
|
|
433
|
+
return current;
|
|
434
|
+
const markerPath = join(home, 'storages', MCP_ADAPTER_STORAGE_DIRNAME, LEGACY_IMPORT_MARKER);
|
|
435
|
+
if (existsSync(markerPath))
|
|
436
|
+
return current;
|
|
437
|
+
const writeMarker = (marker) => {
|
|
438
|
+
try {
|
|
439
|
+
mkdirSync(join(markerPath, '..'), { recursive: true });
|
|
440
|
+
writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// contained — a missing audit file only costs a re-scan next boot
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
const source = LEGACY_SETTINGS_FILES
|
|
447
|
+
.map(name => join(home, name))
|
|
448
|
+
.find(path => existsSync(path));
|
|
449
|
+
if (source === undefined) {
|
|
450
|
+
writeMarker({ at: new Date().toISOString(), outcome: 'no-legacy', source: undefined });
|
|
451
|
+
return current;
|
|
452
|
+
}
|
|
453
|
+
const section = legacyGateSection(readFileSync(source, 'utf8'));
|
|
454
|
+
const absorbed = normalizeServerGate(section === undefined ? {} : legacyGateCandidate(section));
|
|
455
|
+
const serverIdCount = Object.keys(absorbed.serverIds).length;
|
|
456
|
+
if (serverIdCount === 0 && absorbed.disabled.length === 0) {
|
|
457
|
+
writeMarker({ at: new Date().toISOString(), outcome: section === undefined ? 'no-section' : 'no-op', source });
|
|
458
|
+
return current;
|
|
459
|
+
}
|
|
460
|
+
// Persist IMMEDIATELY (sync atomic tmp+rename, the write-through pose):
|
|
461
|
+
// gate.json must exist the moment the state was taken over, so ids and
|
|
462
|
+
// toggles visibly survive the upgrade without waiting for a first write.
|
|
463
|
+
const file = join(dir, GATE_FILE_NAME);
|
|
464
|
+
const payload = JSON.stringify({ serverIds: { ...absorbed.serverIds }, disabled: [...absorbed.disabled] }, null, 2) + '\n';
|
|
465
|
+
mkdirSync(dir, { recursive: true });
|
|
466
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
467
|
+
writeFileSync(tmp, payload, 'utf8');
|
|
468
|
+
renameSync(tmp, file);
|
|
469
|
+
writeMarker({
|
|
470
|
+
at: new Date().toISOString(),
|
|
471
|
+
outcome: 'imported',
|
|
472
|
+
source,
|
|
473
|
+
serverIds: serverIdCount,
|
|
474
|
+
disabled: absorbed.disabled.length,
|
|
475
|
+
});
|
|
476
|
+
options.info?.(`mcp-adapter: legacy settings import (${basename(source)} → ${MCP_ADAPTER_STORAGE_DIRNAME}/${GATE_FILE_NAME}): `
|
|
477
|
+
+ `serverIds=${serverIdCount}, disabled=${absorbed.disabled.length}`);
|
|
478
|
+
return absorbed;
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
warn(`mcp-adapter: legacy settings absorption failed (ignored — starting from the current gate, retried next boot): `
|
|
482
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
483
|
+
return current;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
215
486
|
export const Config = z.object({
|
|
216
|
-
prefix: z.string().pattern(PREFIX_PATTERN).default(DEFAULT_PREFIX),
|
|
217
|
-
keep: z.array(String).default([]),
|
|
218
|
-
servers: z.array(String).default([]),
|
|
219
|
-
descriptionLimit: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_DESCRIPTION_LIMIT),
|
|
487
|
+
prefix: z.string().pattern(PREFIX_PATTERN).default(DEFAULT_PREFIX).volatile(),
|
|
488
|
+
keep: z.array(String).default([]).volatile(),
|
|
489
|
+
servers: z.array(String).default([]).volatile(),
|
|
490
|
+
descriptionLimit: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_DESCRIPTION_LIMIT).volatile(),
|
|
491
|
+
storageDir: z.string().default('').volatile(),
|
|
220
492
|
});
|
|
221
493
|
// ---- Keep-pattern matching (pure) ----
|
|
222
494
|
/** Compiled keep patterns, keyed by source pattern. */
|
|
@@ -648,7 +920,7 @@ export function liveServerNames(schemas, options) {
|
|
|
648
920
|
*
|
|
649
921
|
* @param schemas - schemas visible to the receiving agent.
|
|
650
922
|
* @param options - adapter knobs (prefix/keep/servers) plus the live gate
|
|
651
|
-
* snapshot (`gate`) when
|
|
923
|
+
* snapshot (`gate`) when a gate store is available.
|
|
652
924
|
* @param metaToolsLive - whether both meta-tools resolve to this plugin's
|
|
653
925
|
* definitions in the receiving agent's scope.
|
|
654
926
|
* @returns the folding health plus the partition behind it.
|
|
@@ -902,7 +1174,7 @@ export function renderMcpConfig(schemas, options) {
|
|
|
902
1174
|
const hits = candidates.filter(schema => serverOfToolName(schema.name, options.prefix) === server);
|
|
903
1175
|
lines.push(` - ${server}`, hits.length === 0 ? ' (no matching tools)' : ` tools: ${hits.map(schema => annotateDisabled(schema.name)).join(', ')}`);
|
|
904
1176
|
}
|
|
905
|
-
// Persistent gate inventory — only meaningful with a live
|
|
1177
|
+
// Persistent gate inventory — only meaningful with a live gate-backed
|
|
906
1178
|
// snapshot, so absent snapshots keep the legacy output untouched.
|
|
907
1179
|
if (options.gate !== undefined) {
|
|
908
1180
|
const mapped = Object.entries(options.gate.serverIds).sort((left, right) => left[1] - right[1]);
|
|
@@ -1022,8 +1294,8 @@ async function runServerToggle(form, id, registry, view) {
|
|
|
1022
1294
|
if (registry === undefined || view.writeGate === undefined) {
|
|
1023
1295
|
return {
|
|
1024
1296
|
kind: 'error',
|
|
1025
|
-
text: `"/mcp ${form}" needs the
|
|
1026
|
-
+ 'and it is not
|
|
1297
|
+
text: `"/mcp ${form}" needs the persistent gate store to record server state, `
|
|
1298
|
+
+ 'and it is not available in this process',
|
|
1027
1299
|
};
|
|
1028
1300
|
}
|
|
1029
1301
|
const liveServers = new Set(liveServerNames(view.schemas, view.config));
|
|
@@ -1191,7 +1463,11 @@ export function delegateFinalizeContent(metaArgs, result, exec, resolveChild) {
|
|
|
1191
1463
|
}
|
|
1192
1464
|
/**
|
|
1193
1465
|
* Build the `mcp_list` meta-tool definition.
|
|
1194
|
-
* @param options - catalog knobs (prefix, description limit).
|
|
1466
|
+
* @param options - catalog knobs (prefix, description limit). MAY be a live
|
|
1467
|
+
* options object (plain getters): the factory reads `prefix`, `servers`,
|
|
1468
|
+
* `descriptionLimit` and `getGate` through the object on EVERY execution,
|
|
1469
|
+
* so volatile config can be wired by reference — only the description text
|
|
1470
|
+
* freezes at factory time.
|
|
1195
1471
|
* @param listSchemas - visible-schemas source (production: `ctx.tools.schemas`,
|
|
1196
1472
|
* called with the calling agent so restrictions are respected).
|
|
1197
1473
|
* @returns the complete tool definition.
|
|
@@ -1240,24 +1516,29 @@ export function createMcpListTool(options, listSchemas) {
|
|
|
1240
1516
|
}
|
|
1241
1517
|
/**
|
|
1242
1518
|
* Build the `mcp_call` meta-tool definition.
|
|
1243
|
-
* @param prefix - the configured MCP prefix (dispatch boundary).
|
|
1519
|
+
* @param prefix - the configured MCP prefix (dispatch boundary). A thunk is
|
|
1520
|
+
* accepted so volatile config is re-read on every dispatch; a plain string
|
|
1521
|
+
* freezes the boundary at factory time.
|
|
1244
1522
|
* @param resolve - scope-aware definition resolver (production:
|
|
1245
1523
|
* `ctx.tools.get`, called with the calling agent).
|
|
1246
|
-
* @param servers - server-name whitelist (default: no filtering).
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1524
|
+
* @param servers - server-name whitelist (default: no filtering). A thunk is
|
|
1525
|
+
* accepted for live volatile config, same as `prefix`.
|
|
1526
|
+
* @param getGate - live disabled-server reader (production: the file-backed
|
|
1527
|
+
* gate store's current mirror; default/omitted: nothing is disabled).
|
|
1528
|
+
* Deliberately a callback — the definition object outlives any one gate
|
|
1529
|
+
* snapshot and must observe later /mcp enable/disable writes.
|
|
1251
1530
|
* @returns the complete tool definition.
|
|
1252
1531
|
*/
|
|
1253
1532
|
export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
|
|
1533
|
+
const prefixNow = typeof prefix === 'function' ? prefix : () => prefix;
|
|
1534
|
+
const serversNow = typeof servers === 'function' ? servers : () => servers;
|
|
1254
1535
|
return {
|
|
1255
1536
|
name: MCP_CALL_TOOL_NAME,
|
|
1256
1537
|
description: [
|
|
1257
|
-
`Invoke one MCP tool by its full registered name (like "${
|
|
1538
|
+
`Invoke one MCP tool by its full registered name (like "${prefixNow()}<server>__<tool>").`,
|
|
1258
1539
|
`First call ${MCP_LIST_TOOL_NAME} to discover tool names and expand the exact input schema,`,
|
|
1259
1540
|
'then pass { "tool": "<full name>", "arguments": { ... } }.',
|
|
1260
|
-
`Only tools named with the "${
|
|
1541
|
+
`Only tools named with the "${prefixNow()}" prefix are dispatchable — anything else is refused.`,
|
|
1261
1542
|
'The result is the invoked tool\'s own return value, or a structured { "error": ... } you can correct and retry.',
|
|
1262
1543
|
].join(' '),
|
|
1263
1544
|
parameters: {
|
|
@@ -1277,7 +1558,7 @@ export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
|
|
|
1277
1558
|
schema: {},
|
|
1278
1559
|
render: (args, value) => renderDispatched(args, value, name => resolve(name)),
|
|
1279
1560
|
},
|
|
1280
|
-
execute: (args, exec) => dispatchMcpCall(args,
|
|
1561
|
+
execute: (args, exec) => dispatchMcpCall(args, prefixNow(), name => resolve(name, exec.agent), exec, serversNow(), getGate?.()),
|
|
1281
1562
|
// Restore child-owned projections (image attachments): the registry
|
|
1282
1563
|
// invokes THIS definition's finalizer, so it must forward to the child's
|
|
1283
1564
|
// with the same exec object dispatch passed to child.execute.
|
|
@@ -1292,79 +1573,57 @@ export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
|
|
|
1292
1573
|
* both registrations are rolled back, a warning is logged, and NO listener
|
|
1293
1574
|
* is installed — the official full-schema passthrough stays intact.
|
|
1294
1575
|
*
|
|
1295
|
-
* Server gating:
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1576
|
+
* Server gating: the gate mirror loads synchronously once from the plugin's
|
|
1577
|
+
* gate file (`resolveGateDir(config.storageDir.get())`), and every gate
|
|
1578
|
+
* consumer reads the CURRENT mirror through {@link getGate} — the plugin
|
|
1579
|
+
* keeps no stale copy that could drift from /mcp writes. A corrupt or
|
|
1580
|
+
* unreadable document degrades to the empty gate (everything enabled) at
|
|
1581
|
+
* load time, keeping the assemble waterfall alive.
|
|
1582
|
+
*
|
|
1583
|
+
* Config volatility: every knob (prefix/keep/servers/descriptionLimit) is a
|
|
1584
|
+
* volatile reference — 0.1.7 settings-page writes swap values in place
|
|
1585
|
+
* WITHOUT remounting the plugin, so nothing captured at apply time may go
|
|
1586
|
+
* stale: the waterfall, both meta-tools and the /mcp command all re-read
|
|
1587
|
+
* through {@link cfgNow} per use.
|
|
1301
1588
|
*
|
|
1302
|
-
* Commands: `/mcp` mounts through a runtime `ctx.inject(['commands'], ...)
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1589
|
+
* Commands: `/mcp` mounts through a runtime `ctx.inject(['commands'], ...)`,
|
|
1590
|
+
* so hosts without the commands service keep this plugin fully functional —
|
|
1591
|
+
* one warning, no command. A registration name conflict degrades to the same
|
|
1592
|
+
* warning independently.
|
|
1306
1593
|
*
|
|
1307
1594
|
* @param ctx - plugin context carrying the tool registry.
|
|
1308
|
-
* @param config - resolved adapter configuration.
|
|
1595
|
+
* @param config - resolved adapter configuration (volatile live references).
|
|
1309
1596
|
*/
|
|
1310
1597
|
export function apply(ctx, config) {
|
|
1311
|
-
const
|
|
1312
|
-
prefix: config.prefix,
|
|
1313
|
-
keep: config.keep,
|
|
1314
|
-
servers: config.servers,
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
//
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
if (scope === undefined)
|
|
1341
|
-
return undefined;
|
|
1342
|
-
try {
|
|
1343
|
-
return normalizeServerGate(scope.get());
|
|
1344
|
-
}
|
|
1345
|
-
catch (error) {
|
|
1346
|
-
// Fail-open, hard requirement: a throwing read must never blow up the
|
|
1347
|
-
// system-prompt/assemble waterfall or a /mcp invocation. Gate-less
|
|
1348
|
-
// means every server counts as enabled — visibility only ever grows.
|
|
1349
|
-
if (!gateReadWarned) {
|
|
1350
|
-
gateReadWarned = true;
|
|
1351
|
-
ctx.logger.warn(`mcp-adapter: reading the persisted enable/disable state failed `
|
|
1352
|
-
+ `(${error instanceof Error ? error.message : String(error)}) `
|
|
1353
|
-
+ '— treating every server as enabled');
|
|
1354
|
-
}
|
|
1355
|
-
return undefined;
|
|
1356
|
-
}
|
|
1357
|
-
};
|
|
1358
|
-
const writeGate = async (next) => {
|
|
1359
|
-
const scope = settingsScope;
|
|
1360
|
-
if (scope === undefined)
|
|
1361
|
-
throw new Error('settings service went away before the write');
|
|
1362
|
-
// Wholesale replace (not update): we always hold the complete next
|
|
1363
|
-
// section, and replace cannot merge stale keys back under us.
|
|
1364
|
-
await scope.replace({ serverIds: { ...next.serverIds }, disabled: [...next.disabled] });
|
|
1365
|
-
};
|
|
1366
|
-
const listDefinition = createMcpListTool({ ...listOptions, getGate }, scope => ctx.tools.schemas(scope));
|
|
1367
|
-
const callDefinition = createMcpCallTool(config.prefix, (name, scope) => ctx.tools.get(name, scope), config.servers, getGate);
|
|
1598
|
+
const cfgNow = () => ({
|
|
1599
|
+
prefix: config.prefix.get(),
|
|
1600
|
+
keep: config.keep.get(),
|
|
1601
|
+
servers: config.servers.get(),
|
|
1602
|
+
descriptionLimit: config.descriptionLimit.get(),
|
|
1603
|
+
});
|
|
1604
|
+
// Persistent enable/disable state: file-backed gate store (the 0.1.5
|
|
1605
|
+
// settings namespace is gone; volatile fields are for user-facing values,
|
|
1606
|
+
// which a machine id registry must never be). Reads come from the mirror,
|
|
1607
|
+
// writes go through atomically; the storage dir is read once — moving it
|
|
1608
|
+
// takes effect on the next plugin restart. On a fresh gate (no gate.json)
|
|
1609
|
+
// the store absorbs the pre-0.1.7 `mcp-adapter:` settings state once —
|
|
1610
|
+
// fully contained, audit marker under storages/mcp-adapter.
|
|
1611
|
+
const store = createFileGateStore(resolveGateDir(config.storageDir.get()), message => ctx.logger.warn(message), {
|
|
1612
|
+
info: message => ctx.logger.info?.(message),
|
|
1613
|
+
home: resolveDshHome(),
|
|
1614
|
+
});
|
|
1615
|
+
const getGate = () => store.get();
|
|
1616
|
+
const writeGate = (next) => store.write(next);
|
|
1617
|
+
// Live options object: getters re-resolve volatile config per execution, so
|
|
1618
|
+
// catalog/dispatch immediately follow settings-page edits (the list tool's
|
|
1619
|
+
// description text is the only factory-time freeze, and it is cosmetic).
|
|
1620
|
+
const listDefinition = createMcpListTool({
|
|
1621
|
+
get prefix() { return cfgNow().prefix; },
|
|
1622
|
+
get servers() { return cfgNow().servers; },
|
|
1623
|
+
get descriptionLimit() { return cfgNow().descriptionLimit; },
|
|
1624
|
+
getGate,
|
|
1625
|
+
}, scope => ctx.tools.schemas(scope));
|
|
1626
|
+
const callDefinition = createMcpCallTool(() => cfgNow().prefix, (name, scope) => ctx.tools.get(name, scope), () => cfgNow().servers, getGate);
|
|
1368
1627
|
const disposers = [];
|
|
1369
1628
|
try {
|
|
1370
1629
|
disposers.push(ctx.tools.register(listDefinition));
|
|
@@ -1392,7 +1651,10 @@ export function apply(ctx, config) {
|
|
|
1392
1651
|
// missing, shadowed, or foreign `mcp_list`/`mcp_call` means passthrough.
|
|
1393
1652
|
const metaToolsLive = ctx.tools.get(MCP_LIST_TOOL_NAME, context.scope) === listDefinition
|
|
1394
1653
|
&& ctx.tools.get(MCP_CALL_TOOL_NAME, context.scope) === callDefinition;
|
|
1395
|
-
|
|
1654
|
+
// Volatile knobs are re-read per assembly: settings-page edits reach the
|
|
1655
|
+
// next prompt without a plugin remount.
|
|
1656
|
+
const { prefix, keep, servers } = cfgNow();
|
|
1657
|
+
return foldPromptAssembly(assembled, { prefix, keep, servers }, metaToolsLive, getGate());
|
|
1396
1658
|
});
|
|
1397
1659
|
// Status + server toggles on the platform command service (visible in TUI
|
|
1398
1660
|
// completion and web slash panels). The service is mounted SOFTLY: unlike
|
|
@@ -1423,18 +1685,13 @@ export function apply(ctx, config) {
|
|
|
1423
1685
|
const schemas = ctx.tools.schemas(scope);
|
|
1424
1686
|
const metaToolsLive = ctx.tools.get(MCP_LIST_TOOL_NAME, scope) === listDefinition
|
|
1425
1687
|
&& ctx.tools.get(MCP_CALL_TOOL_NAME, scope) === callDefinition;
|
|
1426
|
-
// ONE snapshot per execution keeps config.gate and view.gate identical
|
|
1688
|
+
// ONE snapshot per execution keeps config.gate and view.gate identical;
|
|
1689
|
+
// volatile knobs are re-read per invocation (live settings-page edits).
|
|
1427
1690
|
const gate = getGate();
|
|
1428
1691
|
return executeMcpCommand({
|
|
1429
1692
|
rawInput: invocation.rawInput,
|
|
1430
1693
|
schemas,
|
|
1431
|
-
config: {
|
|
1432
|
-
prefix: config.prefix,
|
|
1433
|
-
keep: config.keep,
|
|
1434
|
-
servers: config.servers,
|
|
1435
|
-
descriptionLimit: config.descriptionLimit,
|
|
1436
|
-
gate,
|
|
1437
|
-
},
|
|
1694
|
+
config: { ...cfgNow(), gate },
|
|
1438
1695
|
metaToolsLive,
|
|
1439
1696
|
gate,
|
|
1440
1697
|
signal: invocation.signal,
|