@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/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 dsh settings
37
- * service (`mcp-adapter` namespace), so ids survive restarts and re-syncs.
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 settings namespace persisting the stable ids and the disabled set.
91
- * Plain literal: dsh-settings 0.1.2-alpha.3 removed the runtime
92
- * settingsNamespace() helper this constant used to call (same adaptation as
93
- * dsh-model-sync / dsh-cron / dsh-vault).
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 MCP_ADAPTER_SETTINGS_NAMESPACE = 'mcp-adapter';
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 the settings service is composed.
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 settings-backed
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 dsh settings service to persist server state, `
1026
- + 'and it is not composed in this process',
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
- * @param getGate - live disabled-server reader (production: fresh
1248
- * `settingsScope.get()` snapshot per dispatch; default/omitted: nothing is
1249
- * disabled). Deliberately a callback — the definition object outlives any
1250
- * one settings snapshot and must observe later /mcp enable/disable writes.
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 "${prefix}<server>__<tool>").`,
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 "${prefix}" prefix are dispatchable — anything else is refused.`,
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, prefix, name => resolve(name, exec.agent), exec, servers, getGate?.()),
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: when a dsh settings service is composed, the `mcp-adapter`
1296
- * namespace is registered on it and every gate consumer reads FRESH resolved
1297
- * values through small callbacks — the plugin keeps no in-memory copy that
1298
- * could drift from settings. The read itself is defended too: if the
1299
- * resolved-value lookup throws, consumers see an absent gate (everything
1300
- * enabled) instead of the error, keeping the assemble waterfall alive.
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
- * (same seam as settings), so hosts without the commands service keep this
1304
- * plugin fully functional — one warning, no command. A registration name
1305
- * conflict degrades to the same warning independently.
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 foldOptions = {
1312
- prefix: config.prefix,
1313
- keep: config.keep,
1314
- servers: config.servers,
1315
- };
1316
- const listOptions = {
1317
- prefix: config.prefix,
1318
- descriptionLimit: config.descriptionLimit,
1319
- servers: config.servers,
1320
- };
1321
- // Optional settings consumption (the canonical inject-scoped pattern): the
1322
- // registration rides its own child fiber, so disposing it removes exactly
1323
- // the namespace. Late binding over `settingsScope` means the meta-tools,
1324
- // waterfall, and command created below observe the mount whenever it lands.
1325
- let settingsScope;
1326
- if (typeof ctx.inject === 'function') {
1327
- ctx.inject(['settings'], (sctx) => {
1328
- settingsScope = sctx.settings.register(MCP_ADAPTER_SETTINGS_NAMESPACE, SERVER_ID_REGISTRY_SCHEMA);
1329
- return () => { settingsScope = undefined; };
1330
- });
1331
- }
1332
- // One-shot latch: a persistently broken settings provider would otherwise
1333
- // re-warn on EVERY assembled prompt (getGate runs per waterfall execution).
1334
- let gateReadWarned = false;
1335
- // Fresh snapshot per read; normalize guards hand-edited documents. The
1336
- // scope being absent is distinguishable from an empty section: undefined
1337
- // propagates so toggle commands can name their missing dependency.
1338
- const getGate = () => {
1339
- const scope = settingsScope;
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
- return foldPromptAssembly(assembled, foldOptions, metaToolsLive, getGate());
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,