@aiwayds/dsh-mcp-adapter 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -25,6 +25,17 @@
25
25
  * child tool's name never fire for folded calls (the registry only sees the
26
26
  * outer `mcp_call`) — gate MCP usage by guarding `mcp_call` itself.
27
27
  *
28
+ * A read-only `/mcp` slash command (platform `commands` service) surfaces the
29
+ * live state human-side: the server/tool tree, per-server/per-tool details,
30
+ * the effective configuration, and whether folding is active, idle, or in
31
+ * fail-open passthrough. The same command carries the one mutating surface:
32
+ * `/mcp disable <id>` / `/mcp enable <id>` gate a whole server behind all
33
+ * three latches at once — its tools force-fold out of every prompt (keep and
34
+ * `servers` whitelist exemptions included), it disappears from the catalog,
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.
38
+ *
28
39
  * Child-owned projections are preserved: `mcp_call` delegates its
29
40
  * `output.render` to the dispatched child and forwards the child's
30
41
  * `finalizeContent` with the exact same run-execution object, so
@@ -42,10 +53,22 @@
42
53
  * @module @aiwayds/dsh-mcp-adapter
43
54
  */
44
55
  import z from '@deepseek-ai/schemastery';
56
+ // `settingsNamespace` is the one RUNTIME import from dsh-settings: cheap
57
+ // kebab-case validation of our namespace string at module load.
58
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
45
59
  /** Cordis plugin name used by loader diagnostics. */
46
60
  export const name = 'mcp-adapter';
47
- /** Services required by this plugin. */
61
+ /**
62
+ * Services required by this plugin — deliberately just `tools`. The platform
63
+ * `commands` service is consumed SOFTLY at runtime ({@link apply} mounts it
64
+ * via `ctx.inject`): listing it here would refuse to load the whole plugin on
65
+ * hosts that do not compose commands, taking folding down with the command.
66
+ */
48
67
  export const inject = ['tools'];
68
+ /** Registered slash-command name (read-only status surface). */
69
+ export const MCP_COMMAND_NAME = 'mcp';
70
+ /** Max rendered lines of any /mcp output before truncation kicks in. */
71
+ export const MCP_OUTPUT_LINE_LIMIT = 400;
49
72
  /** Registered name of the catalog meta-tool. */
50
73
  export const MCP_LIST_TOOL_NAME = 'mcp_list';
51
74
  /** Registered name of the dispatch meta-tool. */
@@ -56,6 +79,130 @@ const DEFAULT_DESCRIPTION_LIMIT = 200;
56
79
  const DEFAULT_PREFIX = 'mcp__';
57
80
  /** Valid fold prefix: non-empty, tool-name characters only. */
58
81
  const PREFIX_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
82
+ // ---- Server enable/disable (pure) ----
83
+ /** Upper bound of the stable per-server id space. */
84
+ export const MCP_SERVER_ID_LIMIT = 99;
85
+ /** dsh settings namespace persisting the stable ids and the disabled set. */
86
+ export const MCP_ADAPTER_SETTINGS_NAMESPACE = settingsNamespace('mcp-adapter');
87
+ /** Gate snapshot meaning "nothing observed, nothing disabled". */
88
+ export const EMPTY_SERVER_GATE = { serverIds: {}, disabled: [] };
89
+ /** Schema of the persisted `mcp-adapter` settings section. */
90
+ export const SERVER_ID_REGISTRY_SCHEMA = z.object({
91
+ serverIds: z.dict(z.number().step(1).min(1).max(MCP_SERVER_ID_LIMIT)).default({}),
92
+ disabled: z.array(z.number().step(1).min(1).max(MCP_SERVER_ID_LIMIT)).default([]),
93
+ });
94
+ /** Whether one number is an allocatable server id. */
95
+ function isValidServerId(id) {
96
+ return typeof id === 'number' && Number.isInteger(id) && id >= 1 && id <= MCP_SERVER_ID_LIMIT;
97
+ }
98
+ /**
99
+ * Coerce any resolved settings value into a trustworthy gate snapshot. The
100
+ * schema already validates at resolve time, but every latch judges from THIS
101
+ * function's output, so corrupt or hand-edited documents degrade to "some
102
+ * entries ignored" instead of wedging folding or dispatch.
103
+ */
104
+ export function normalizeServerGate(raw) {
105
+ if (typeof raw !== 'object' || raw === null)
106
+ return EMPTY_SERVER_GATE;
107
+ const rawIds = raw.serverIds;
108
+ const rawDisabled = raw.disabled;
109
+ // One id must name exactly one server: on duplicate ids first mapping wins.
110
+ const taken = new Set();
111
+ const serverIds = {};
112
+ if (typeof rawIds === 'object' && rawIds !== null && !Array.isArray(rawIds)) {
113
+ for (const [name, id] of Object.entries(rawIds)) {
114
+ if (name !== '' && isValidServerId(id) && !taken.has(id)) {
115
+ taken.add(id);
116
+ serverIds[name] = id;
117
+ }
118
+ }
119
+ }
120
+ const seen = new Set();
121
+ const disabled = [];
122
+ if (Array.isArray(rawDisabled)) {
123
+ for (const entry of rawDisabled) {
124
+ if (isValidServerId(entry) && !seen.has(entry)) {
125
+ seen.add(entry);
126
+ disabled.push(entry);
127
+ }
128
+ }
129
+ }
130
+ return { serverIds, disabled };
131
+ }
132
+ /**
133
+ * THE shared disabled verdict — all three latches (waterfall fold, catalog,
134
+ * mcp_call dispatch) and every /mcp renderer judge through this one function,
135
+ * so the gating can never disagree with itself. An unmapped server (or an
136
+ * absent snapshot, or the degenerate empty server segment) is ENABLED:
137
+ * gating requires positive evidence, keeping fail-open direction.
138
+ */
139
+ export function isServerDisabled(server, gate) {
140
+ if (gate === undefined || server === '')
141
+ return false;
142
+ const id = gate.serverIds[server];
143
+ return id !== undefined && gate.disabled.includes(id);
144
+ }
145
+ /**
146
+ * Assign the smallest free id (1..99) to every listed server not yet mapped.
147
+ * Existing mappings are kept verbatim (id stability across restarts); the
148
+ * allocation never mutates its input. Call this whenever /mcp observes the
149
+ * live server set, then persist `{@link ServerIdAllocation.registry}` when
150
+ * `added` is non-empty.
151
+ */
152
+ export function allocateMissingServerIds(state, servers) {
153
+ const source = normalizeServerGate(state);
154
+ const serverIds = { ...source.serverIds };
155
+ // Disabled ids stay burned even when their server vanished from the map
156
+ // (hand-edited documents): a fresh assignment must never inherit a stale
157
+ // disabled flag it did not ask for.
158
+ const used = new Set([...Object.values(serverIds), ...source.disabled]);
159
+ const added = [];
160
+ const unassigned = [];
161
+ for (const server of servers) {
162
+ if (server === '' || serverIds[server] !== undefined)
163
+ continue;
164
+ let id = 1;
165
+ while (used.has(id))
166
+ id += 1;
167
+ if (id > MCP_SERVER_ID_LIMIT) {
168
+ unassigned.push(server);
169
+ continue;
170
+ }
171
+ used.add(id);
172
+ serverIds[server] = id;
173
+ added.push(server);
174
+ }
175
+ return { registry: { serverIds, disabled: [...source.disabled] }, unassigned, added };
176
+ }
177
+ /** Reverse lookup: which server (if any) carries this id today. */
178
+ export function serverNameById(registry, id) {
179
+ if (registry === undefined)
180
+ return undefined;
181
+ for (const [name, mapped] of Object.entries(registry.serverIds)) {
182
+ if (mapped === id)
183
+ return name;
184
+ }
185
+ return undefined;
186
+ }
187
+ /**
188
+ * Apply one enable/disable toggle for a MAPPED server onto a copy of the
189
+ * registry. Unmapped servers are no-ops (`changed: false`). Enabling never
190
+ * removes the id mapping — re-disabling later reuses the same number.
191
+ */
192
+ export function setServerDisabledState(state, server, disable) {
193
+ const source = normalizeServerGate(state);
194
+ const id = source.serverIds[server];
195
+ const has = id !== undefined && source.disabled.includes(id);
196
+ // Disable without a mapping (or already disabled) is inert; enable just
197
+ // filters — both keep the id mapping untouched.
198
+ const nextDisabled = disable
199
+ ? id === undefined || has ? [...source.disabled] : [...source.disabled, id].sort((a, b) => a - b)
200
+ : source.disabled.filter(entry => entry !== id);
201
+ return {
202
+ registry: { serverIds: { ...source.serverIds }, disabled: nextDisabled },
203
+ changed: disable ? id !== undefined && !has : has,
204
+ };
205
+ }
59
206
  export const Config = z.object({
60
207
  prefix: z.string().pattern(PREFIX_PATTERN).default(DEFAULT_PREFIX),
61
208
  keep: z.array(String).default([]),
@@ -112,17 +259,26 @@ export function isAllowedServer(name, prefix, servers) {
112
259
  * keep list — and it must never be one of this plugin's own meta-tools,
113
260
  * whatever the prefix is configured to (folding them would make every folded
114
261
  * MCP tool undiscoverable).
262
+ *
263
+ * Gate override: when the tool's server is DISABLED
264
+ * ({@link isServerDisabled}, shared with catalog/dispatch), its schema folds
265
+ * OUT regardless of keep patterns or the servers whitelist — disablement is
266
+ * strictly stronger than any config exemption.
115
267
  * @param name - the registered tool name.
116
268
  * @param prefix - tool-name prefix to fold.
117
269
  * @param keep - keep patterns from config.
118
270
  * @param servers - server-name whitelist from config (default: no filtering).
271
+ * @param gate - live disabled-server snapshot (default/undefined: none).
119
272
  * @returns whether the schema leaves the prompt (the tool stays callable).
120
273
  */
121
- export function shouldFold(name, prefix, keep, servers = []) {
274
+ export function shouldFold(name, prefix, keep, servers = [], gate) {
122
275
  if (name === MCP_LIST_TOOL_NAME || name === MCP_CALL_TOOL_NAME)
123
276
  return false;
124
- return prefix !== '' && name.startsWith(prefix) && !matchesKeep(name, keep)
125
- && isAllowedServer(name, prefix, servers);
277
+ if (prefix === '' || !name.startsWith(prefix))
278
+ return false;
279
+ if (isServerDisabled(serverOfToolName(name, prefix), gate))
280
+ return true;
281
+ return !matchesKeep(name, keep) && isAllowedServer(name, prefix, servers);
126
282
  }
127
283
  /**
128
284
  * Rewrite one assembled prompt: drop the schemas of foldable tools, keep
@@ -137,13 +293,15 @@ export function shouldFold(name, prefix, keep, servers = []) {
137
293
  * @param options - folding knobs.
138
294
  * @param metaToolsLive - whether both meta-tools currently resolve to this
139
295
  * plugin's own definitions in the assembling scope.
296
+ * @param gate - live disabled-server snapshot, fresh per assembly (default:
297
+ * none disabled).
140
298
  * @returns the folded assembly, or the input unchanged when nothing folds.
141
299
  */
142
- export function foldPromptAssembly(assembly, options, metaToolsLive) {
300
+ export function foldPromptAssembly(assembly, options, metaToolsLive, gate) {
143
301
  if (!metaToolsLive)
144
302
  return assembly;
145
303
  const servers = options.servers ?? [];
146
- const tools = assembly.tools.filter(tool => !shouldFold(tool.name, options.prefix, options.keep, servers));
304
+ const tools = assembly.tools.filter(tool => !shouldFold(tool.name, options.prefix, options.keep, servers, gate));
147
305
  if (tools.length === assembly.tools.length)
148
306
  return assembly;
149
307
  return { ...assembly, tools };
@@ -207,9 +365,10 @@ export function truncateDescription(text, limit) {
207
365
  * @param args - the normalized catalog query.
208
366
  * @param schemas - schemas visible to the calling agent.
209
367
  * @param options - catalog knobs.
368
+ * @param gate - live disabled-server snapshot (default: none disabled).
210
369
  * @returns the canonical result value.
211
370
  */
212
- export function buildMcpListResult(args, schemas, options) {
371
+ export function buildMcpListResult(args, schemas, options, gate) {
213
372
  const { prefix, descriptionLimit } = options;
214
373
  const servers = options.servers ?? [];
215
374
  if (args.tool !== undefined) {
@@ -225,6 +384,11 @@ export function buildMcpListResult(args, schemas, options) {
225
384
  if (!isAllowedServer(args.tool, prefix, servers)) {
226
385
  return { error: `"${args.tool}" belongs to server "${serverOfToolName(args.tool, prefix)}", which is not in the configured servers list` };
227
386
  }
387
+ const gatedServer = serverOfToolName(args.tool, prefix);
388
+ if (isServerDisabled(gatedServer, gate)) {
389
+ const id = gate?.serverIds[gatedServer];
390
+ return { error: `refusing to expand "${args.tool}": server "${gatedServer}" is disabled — it can be restored with "/mcp enable ${String(id)}"` };
391
+ }
228
392
  const schema = schemas.find(candidate => candidate.name === args.tool);
229
393
  if (schema === undefined) {
230
394
  return { error: `unknown MCP tool "${args.tool}" — the server may have re-synced; call ${MCP_LIST_TOOL_NAME} to refresh` };
@@ -233,6 +397,8 @@ export function buildMcpListResult(args, schemas, options) {
233
397
  return { tool: { name: schema.name, description: schema.description, parameters: schema.parameters } };
234
398
  }
235
399
  const serversOut = [];
400
+ // Servers that passed prefix/whitelist checks but were hidden by the gate.
401
+ const gatedAway = [];
236
402
  for (const schema of schemas) {
237
403
  // Never catalog the meta-tools themselves, even under a prefix that
238
404
  // matches their names.
@@ -243,6 +409,13 @@ export function buildMcpListResult(args, schemas, options) {
243
409
  if (!isAllowedServer(schema.name, prefix, servers))
244
410
  continue;
245
411
  const server = serverOfToolName(schema.name, prefix);
412
+ // Disabled servers vanish from the directory entirely; point-name
413
+ // expansion above remains the structured way to learn why.
414
+ if (isServerDisabled(server, gate)) {
415
+ if (!gatedAway.includes(server))
416
+ gatedAway.push(server);
417
+ continue;
418
+ }
246
419
  if (args.server !== undefined && server !== args.server)
247
420
  continue;
248
421
  let group = serversOut.find(entry => entry.server === server);
@@ -257,6 +430,17 @@ export function buildMcpListResult(args, schemas, options) {
257
430
  });
258
431
  }
259
432
  if (serversOut.length === 0) {
433
+ if (args.server !== undefined && isServerDisabled(args.server, gate)) {
434
+ const id = gate?.serverIds[args.server];
435
+ return { error: `server "${args.server}" is disabled and hidden from the catalog — restore it with "/mcp enable ${String(id)}"` };
436
+ }
437
+ // An unfiltered catalog that found prefix tools but lost ALL of them to
438
+ // the gate must not claim "nothing is registered" (same facts as the
439
+ // filtered path above): name each hidden server with its restore hint.
440
+ if (args.server === undefined && gatedAway.length > 0) {
441
+ const named = gatedAway.map(name => `"${name}" ("/mcp enable ${String(gate?.serverIds[name])}")`).join(', ');
442
+ return { error: `every MCP tool under the "${prefix}" prefix belongs to a disabled server and is hidden from the catalog: ${named}` };
443
+ }
260
444
  return args.server === undefined
261
445
  ? { error: `no MCP tools are registered under the "${prefix}" prefix` }
262
446
  : { error: `no MCP tools match server "${args.server}"` };
@@ -294,9 +478,13 @@ export function buildMcpListResult(args, schemas, options) {
294
478
  * @param resolve - scope-aware tool resolver (production: `ctx.tools.get`).
295
479
  * @param exec - the meta-tool's run context, forwarded to the child.
296
480
  * @param servers - server-name whitelist (default: no filtering).
481
+ * @param gate - live disabled-server snapshot (default: none disabled). The
482
+ * disabled branch sits after the prefix/whitelist validation: a disabled
483
+ * server's tools are refused with an explicit `/mcp enable <id>` hint
484
+ * before the child is ever resolved.
297
485
  * @returns the child's resolved value verbatim, or a structured error.
298
486
  */
299
- export async function dispatchMcpCall(args, prefix, resolve, exec, servers = []) {
487
+ export async function dispatchMcpCall(args, prefix, resolve, exec, servers = [], gate) {
300
488
  if (typeof args !== 'object' || args === null || Array.isArray(args)) {
301
489
  return { error: `${MCP_CALL_TOOL_NAME} expects an object { "tool": string, "arguments"?: object }` };
302
490
  }
@@ -314,6 +502,11 @@ export async function dispatchMcpCall(args, prefix, resolve, exec, servers = [])
314
502
  if (!isAllowedServer(tool, prefix, servers)) {
315
503
  return { error: `refusing to call "${tool}": server "${serverOfToolName(tool, prefix)}" is not in the configured servers list` };
316
504
  }
505
+ const gatedServer = serverOfToolName(tool, prefix);
506
+ if (isServerDisabled(gatedServer, gate)) {
507
+ const id = gate?.serverIds[gatedServer];
508
+ return { error: `refusing to call "${tool}": server "${gatedServer}" is disabled — ask the operator to run "/mcp enable ${String(id)}" to restore it` };
509
+ }
317
510
  const definition = resolve(tool);
318
511
  if (definition === undefined) {
319
512
  return { error: `tool "${tool}" is not registered or not visible in this scope — call ${MCP_LIST_TOOL_NAME} to see the catalog` };
@@ -351,7 +544,513 @@ async function raceToolTimeout(definition, timeoutMs, args, exec) {
351
544
  clearTimeout(timer);
352
545
  }
353
546
  }
354
- // ---- Meta-tool definitions (factories; Cordis-free) ----
547
+ /** Usage text returned for unparseable /mcp invocations. */
548
+ export const MCP_COMMAND_USAGE = [
549
+ 'Usage: /mcp [list [server|tool] | config | disable <id> | enable <id>]',
550
+ '',
551
+ '/mcp tree of MCP servers and tools, plus folding health',
552
+ '/mcp list same as /mcp',
553
+ '/mcp list <name> details for one server (its full tool list), or for one',
554
+ ' tool: full description + complete input schema (<name> is a',
555
+ ' server name or a full registered tool name)',
556
+ '/mcp config effective adapter configuration with per-pattern and',
557
+ ' per-server tool matches (read-only)',
558
+ '/mcp disable <id> fold one server\'s tools out of every prompt until it is',
559
+ ' enabled again (<id> is the stable number shown by "/mcp";',
560
+ ' keep and servers exemptions included)',
561
+ '/mcp enable <id> restore a disabled server under the same stable id',
562
+ ].join('\n');
563
+ /** Parse one positive integer token (`1`..); anything else is undefined. */
564
+ function parsePositiveInt(token) {
565
+ return /^[1-9][0-9]*$/.test(token) ? Number(token) : undefined;
566
+ }
567
+ /**
568
+ * Parse one /mcp raw input into its form. `''` and `'list'` are the tree
569
+ * overview; `'list <name>'` is a server/tool detail; `'config'` shows the
570
+ * effective configuration; `'disable <n>'` / `'enable <n>'` are whole-server
571
+ * toggles (n is the stable id shown by `/mcp`, so a bare `0` or non-numeric
572
+ * token is already usage). Everything else parses to the usage error.
573
+ * @param rawInput - exact text following the command name.
574
+ * @returns the normalized command form.
575
+ */
576
+ export function parseMcpCommandInput(rawInput) {
577
+ const parts = rawInput.trim().split(/\s+/);
578
+ switch (parts[0]) {
579
+ case '':
580
+ return { form: 'overview' };
581
+ case 'list':
582
+ if (parts.length === 1)
583
+ return { form: 'overview' };
584
+ if (parts.length === 2)
585
+ return { form: 'detail', name: parts[1] };
586
+ return { form: 'usage' };
587
+ case 'config':
588
+ return parts.length === 1 ? { form: 'config' } : { form: 'usage' };
589
+ case 'disable':
590
+ case 'enable': {
591
+ const id = parts.length === 2 ? parsePositiveInt(parts[1]) : undefined;
592
+ return id === undefined ? { form: 'usage' } : { form: parts[0], id };
593
+ }
594
+ default:
595
+ return { form: 'usage' };
596
+ }
597
+ }
598
+ /** The visible-schemas candidate set every /mcp view draws from: this view's
599
+ * prefix-matching MCP tools — never the meta-tools themselves, and never
600
+ * tools outside the servers whitelist (consistent with fold/catalog/dispatch).
601
+ */
602
+ function mcpCommandCandidates(schemas, options) {
603
+ const servers = options.servers ?? [];
604
+ return schemas.filter(schema => schema.name !== MCP_LIST_TOOL_NAME
605
+ && schema.name !== MCP_CALL_TOOL_NAME
606
+ && schema.name.startsWith(options.prefix)
607
+ && isAllowedServer(schema.name, options.prefix, servers));
608
+ }
609
+ /**
610
+ * Distinct server names among one view's candidates, in first-seen order —
611
+ * the live set stable ids are allocated against.
612
+ */
613
+ export function liveServerNames(schemas, options) {
614
+ const seen = new Set();
615
+ const names = [];
616
+ for (const schema of mcpCommandCandidates(schemas, options)) {
617
+ const server = serverOfToolName(schema.name, options.prefix);
618
+ if (server === '' || seen.has(server))
619
+ continue;
620
+ seen.add(server);
621
+ names.push(server);
622
+ }
623
+ return names;
624
+ }
625
+ /**
626
+ * Partition one scope's schemas into folded vs kept MCP tools and measure
627
+ * how many schema characters leave the prompt. Mirrors {@link shouldFold}
628
+ * exactly; the meta-tools themselves are never counted.
629
+ *
630
+ * Health semantics follow the waterfall: folding counts as ACTIVE only when
631
+ * the meta-tools are live AND at least one tool actually folds. Live but
632
+ * nothing to fold is the idle state; not-live is fail-open.
633
+ *
634
+ * @param schemas - schemas visible to the receiving agent.
635
+ * @param options - adapter knobs (prefix/keep/servers) plus the live gate
636
+ * snapshot (`gate`) when the settings service is composed.
637
+ * @param metaToolsLive - whether both meta-tools resolve to this plugin's
638
+ * definitions in the receiving agent's scope.
639
+ * @returns the folding health plus the partition behind it.
640
+ */
641
+ export function collectMcpHealth(schemas, options, metaToolsLive) {
642
+ if (!metaToolsLive)
643
+ return { stats: { folded: [], kept: [] }, health: { state: 'fail-open' } };
644
+ const servers = options.servers ?? [];
645
+ const folded = [];
646
+ const kept = [];
647
+ let matched = 0;
648
+ for (const schema of schemas) {
649
+ if (schema.name === MCP_LIST_TOOL_NAME || schema.name === MCP_CALL_TOOL_NAME)
650
+ continue;
651
+ if (!schema.name.startsWith(options.prefix))
652
+ continue;
653
+ matched += 1;
654
+ if (shouldFold(schema.name, options.prefix, options.keep, servers, options.gate))
655
+ folded.push(schema);
656
+ else
657
+ kept.push(schema);
658
+ }
659
+ if (folded.length === 0)
660
+ return { stats: { folded, kept }, health: { state: 'idle', matched } };
661
+ // Schema weight removed from the standing prompt: JSON size of every folded
662
+ // tool's parameters object. Chars, deliberately NOT tokens.
663
+ const schemaChars = folded.reduce((sum, schema) => sum + (schema.parameters === undefined ? 0 : JSON.stringify(schema.parameters).length), 0);
664
+ return { stats: { folded, kept }, health: { state: 'folding', folded: folded.length, kept: kept.length, schemaChars } };
665
+ }
666
+ /**
667
+ * Render the overview footer line for one health reading. Exactly one of:
668
+ * `meta-tools: ... live · folding ACTIVE — folded N, kept M · ~X chars of
669
+ * schema out of prompt`, the fail-open notice, or the nothing-to-fold notice.
670
+ * @param health - the collected health reading.
671
+ * @returns the single footer line.
672
+ */
673
+ export function renderMcpHealthLine(health) {
674
+ switch (health.state) {
675
+ case 'folding':
676
+ return `meta-tools: ${MCP_LIST_TOOL_NAME}/${MCP_CALL_TOOL_NAME} live · folding ACTIVE`
677
+ + ` — folded ${health.folded}, kept ${health.kept}`
678
+ + ` · ~${health.schemaChars} chars of schema out of prompt`;
679
+ case 'fail-open':
680
+ return 'folding INACTIVE (fail-open) — full schemas are passing through';
681
+ case 'idle':
682
+ return health.matched > 0
683
+ ? `folding INACTIVE (nothing folded) — ${health.matched} matching tool(s) stay native via keep/servers exemptions`
684
+ : `folding INACTIVE (nothing folded) — no MCP tools match the configured prefix`;
685
+ }
686
+ }
687
+ /**
688
+ * Header line of one server group in the tree views. Without a gate snapshot
689
+ * the line is the plain legacy form (`name (n)`); with one, every group is
690
+ * prefixed by its stable id — `[1] fs (4)` — and a DISABLED group renders as
691
+ * `[2] gh ⏸ disabled — hides 3 tool(s)` and lists no tools at all.
692
+ */
693
+ export function renderServerGroupHeader(group, gate) {
694
+ if (gate === undefined)
695
+ return `${group.server} (${group.schemas.length})`;
696
+ const id = gate.serverIds[group.server];
697
+ const label = `[${id === undefined ? '-' : String(id)}] ${group.server}`;
698
+ if (isServerDisabled(group.server, gate)) {
699
+ return `${label} ⏸ disabled — hides ${group.schemas.length} tool(s)`;
700
+ }
701
+ return `${label} (${group.schemas.length})`;
702
+ }
703
+ /**
704
+ * Group one scope's candidate schemas by server, first-seen order — the same
705
+ * filtering contract as the `mcp_list` catalog loop.
706
+ * @param schemas - schemas visible to the receiving agent.
707
+ * @param options - adapter knobs (prefix/servers).
708
+ * @returns server groups in first-seen order.
709
+ */
710
+ export function groupSchemasByServer(schemas, options) {
711
+ const groups = [];
712
+ for (const schema of mcpCommandCandidates(schemas, options)) {
713
+ const server = serverOfToolName(schema.name, options.prefix);
714
+ let group = groups.find(entry => entry.server === server);
715
+ if (group === undefined) {
716
+ group = { server, schemas: [] };
717
+ groups.push(group);
718
+ }
719
+ group.schemas.push(schema);
720
+ }
721
+ return groups;
722
+ }
723
+ /**
724
+ * Cap rendered /mcp output so a giant deployment cannot flood the transcript.
725
+ * At most `limit` lines survive; the last becomes a truncation notice naming
726
+ * the narrowing subcommand.
727
+ * @param text - the rendered multi-line output.
728
+ * @param limit - max output lines (default {@link MCP_OUTPUT_LINE_LIMIT}).
729
+ * @returns the capped text (identical when already within the limit).
730
+ */
731
+ export function capRenderedLines(text, limit = MCP_OUTPUT_LINE_LIMIT) {
732
+ const lines = text.split('\n');
733
+ if (lines.length <= limit)
734
+ return text;
735
+ const dropped = lines.length - limit + 1;
736
+ return `${lines.slice(0, limit - 1).join('\n')}\n… output truncated at ${limit} lines (${dropped} dropped) — narrow with "/mcp list <server>"`;
737
+ }
738
+ /**
739
+ * The one-line id-exhaustion notice for /mcp views. `undefined` while the
740
+ * stable id space has room. This is where the 99-cap promise lands: a server
741
+ * beyond the cap never gets an id, so no toggle could ever target it — the
742
+ * views say so explicitly instead of pretending otherwise.
743
+ * @param unassigned - live servers observed without an assignable id.
744
+ * @returns the notice line, or nothing when the space is not exhausted.
745
+ */
746
+ export function renderIdCapNotice(unassigned) {
747
+ if (unassigned === undefined || unassigned.length === 0)
748
+ return undefined;
749
+ return `id space exhausted (${MCP_SERVER_ID_LIMIT}/${MCP_SERVER_ID_LIMIT}): `
750
+ + `${unassigned.length} server(s) beyond the cap cannot be gated`;
751
+ }
752
+ /**
753
+ * Render the `/mcp` / `/mcp list` tree overview: one block per server with
754
+ * each tool's name and truncated description (disabled servers show only a
755
+ * `⏸ disabled` header line — their tools are hidden), closed by the
756
+ * folding-health footer line. Servers beyond the id cap carry a marker on
757
+ * their group header, and full exhaustion appends an explicit notice after
758
+ * the footer. Output is line-capped defensively.
759
+ * @param schemas - schemas visible to the receiving agent.
760
+ * @param options - adapter knobs (prefix/keep/servers/descriptionLimit/gate/
761
+ * unassignedServers).
762
+ * @param metaToolsLive - whether both meta-tools are live in that scope.
763
+ * @returns the complete overview text.
764
+ */
765
+ export function renderMcpOverview(schemas, options, metaToolsLive) {
766
+ const groups = groupSchemasByServer(schemas, options);
767
+ const total = groups.reduce((sum, group) => sum + group.schemas.length, 0);
768
+ const lines = [`MCP servers/tools — ${total} tool(s) across ${groups.length} server(s)`];
769
+ for (const group of groups) {
770
+ const header = renderServerGroupHeader(group, options.gate);
771
+ lines.push('', options.unassignedServers?.includes(group.server)
772
+ ? `${header} — beyond the id cap`
773
+ : header);
774
+ if (isServerDisabled(group.server, options.gate))
775
+ continue;
776
+ group.schemas.forEach((schema, index) => {
777
+ const connector = index === group.schemas.length - 1 ? '└─' : '├─';
778
+ const description = truncateDescription(schema.description, options.descriptionLimit);
779
+ lines.push(`${connector} ${schema.name}${description === '' ? '' : ` — ${description}`}`);
780
+ });
781
+ }
782
+ lines.push('', renderMcpHealthLine(collectMcpHealth(schemas, options, metaToolsLive).health));
783
+ const idCapNotice = renderIdCapNotice(options.unassignedServers);
784
+ if (idCapNotice !== undefined)
785
+ lines.push(idCapNotice);
786
+ return capRenderedLines(lines.join('\n'));
787
+ }
788
+ /**
789
+ * Resolve `<name>` against one scope's candidates: a server segment first
790
+ * (all of its tools), then an exact registered tool name.
791
+ * @param name - server name or full registered tool name.
792
+ * @param schemas - schemas visible to the receiving agent.
793
+ * @param options - adapter knobs (prefix/servers).
794
+ * @returns the resolved target, or undefined when nothing matches.
795
+ */
796
+ export function resolveMcpDetailTarget(name, schemas, options) {
797
+ const candidates = mcpCommandCandidates(schemas, options);
798
+ const serverSchemas = candidates.filter(schema => serverOfToolName(schema.name, options.prefix) === name);
799
+ if (serverSchemas.length > 0)
800
+ return { kind: 'server', server: name, schemas: serverSchemas };
801
+ const exact = candidates.find(schema => schema.name === name);
802
+ return exact === undefined ? undefined : { kind: 'tool', schema: exact };
803
+ }
804
+ /**
805
+ * Render `/mcp list <name>`: for a server, its full tool list (names +
806
+ * truncated descriptions); for a single tool, the FULL untruncated
807
+ * description plus the complete input schema. Unknown names produce a
808
+ * descriptive miss rather than generic usage. Human inspection is never
809
+ * gated, but a target belonging to a disabled server says so — with its
810
+ * `/mcp enable <id>` hint.
811
+ * @param name - the requested server or tool name.
812
+ * @param schemas - schemas visible to the receiving agent.
813
+ * @param options - adapter knobs (prefix/keep/servers/descriptionLimit/gate).
814
+ * @returns the rendered view.
815
+ */
816
+ export function renderMcpDetail(name, schemas, options) {
817
+ const target = resolveMcpDetailTarget(name, schemas, options);
818
+ if (target === undefined) {
819
+ return {
820
+ outcome: 'unknown',
821
+ text: `no MCP server or tool matches "${name}" in this scope — run "/mcp" for the current tree`,
822
+ };
823
+ }
824
+ if (target.kind === 'server') {
825
+ const lines = [`server "${target.server}" — ${target.schemas.length} tool(s)`];
826
+ if (isServerDisabled(target.server, options.gate)) {
827
+ const id = options.gate?.serverIds[target.server];
828
+ lines.push(`⏸ this server is disabled — its tools are folded out of every prompt; restore with "/mcp enable ${String(id)}"`);
829
+ }
830
+ target.schemas.forEach((schema, index) => {
831
+ const connector = index === target.schemas.length - 1 ? '└─' : '├─';
832
+ const description = truncateDescription(schema.description, options.descriptionLimit);
833
+ lines.push(`${connector} ${schema.name}${description === '' ? '' : ` — ${description}`}`);
834
+ });
835
+ return { outcome: 'server', text: capRenderedLines(lines.join('\n')) };
836
+ }
837
+ const server = serverOfToolName(target.schema.name, options.prefix);
838
+ const parameters = target.schema.parameters === undefined
839
+ ? '(none)'
840
+ : JSON.stringify(target.schema.parameters, null, 2);
841
+ let text = `${target.schema.name} (server: ${server})\n\n${target.schema.description}\n\ninputSchema:\n${parameters}`;
842
+ if (isServerDisabled(server, options.gate)) {
843
+ const id = options.gate?.serverIds[server];
844
+ text += `\n\nnote: server "${server}" is disabled — restore it with "/mcp enable ${String(id)}"`;
845
+ }
846
+ return { outcome: 'tool', text: capRenderedLines(text) };
847
+ }
848
+ /**
849
+ * Render the effective configuration with each knob's hitting tools:
850
+ * which names match the prefix, what each keep pattern holds back, and what
851
+ * each whitelisted server contributes. Read-only inventory, no mutations.
852
+ * Tools of disabled servers stay LISTED but carry a `⏸` marker — views
853
+ * inform, they never hide.
854
+ * @param schemas - schemas visible to the receiving agent.
855
+ * @param options - adapter knobs (prefix/keep/servers/descriptionLimit/gate/
856
+ * unassignedServers).
857
+ * @returns the rendered configuration view.
858
+ */
859
+ export function renderMcpConfig(schemas, options) {
860
+ const candidates = mcpCommandCandidates(schemas, options);
861
+ const servers = options.servers ?? [];
862
+ // Inventory completeness over gating: mark gate-held tools instead of
863
+ // dropping them (the human inspection surface is never gated).
864
+ const annotateDisabled = (name) => options.gate !== undefined && isServerDisabled(serverOfToolName(name, options.prefix), options.gate)
865
+ ? `${name} ⏸`
866
+ : name;
867
+ const lines = [
868
+ `mcp-adapter configuration (read-only)`,
869
+ '',
870
+ `prefix ${JSON.stringify(options.prefix)} — matches ${candidates.length} tool(s) in this scope`,
871
+ `descriptionLimit ${options.descriptionLimit}`,
872
+ '',
873
+ `keep patterns (kept native in the prompt): ${options.keep.length}`,
874
+ ];
875
+ if (options.keep.length === 0) {
876
+ lines.push(' (empty — no tool is held back by name)');
877
+ }
878
+ for (const pattern of options.keep) {
879
+ const hits = candidates.filter(schema => matchesKeep(schema.name, [pattern]));
880
+ lines.push(` - ${pattern}`, hits.length === 0 ? ' (no matching tools)' : ` matches: ${hits.map(schema => annotateDisabled(schema.name)).join(', ')}`);
881
+ }
882
+ lines.push('', `servers whitelist (fold/catalog/dispatch boundary): ${servers.length}`);
883
+ if (servers.length === 0) {
884
+ lines.push(' (empty — no filtering; every prefix-matching server folds)');
885
+ }
886
+ for (const server of servers) {
887
+ const hits = candidates.filter(schema => serverOfToolName(schema.name, options.prefix) === server);
888
+ lines.push(` - ${server}`, hits.length === 0 ? ' (no matching tools)' : ` tools: ${hits.map(schema => annotateDisabled(schema.name)).join(', ')}`);
889
+ }
890
+ // Persistent gate inventory — only meaningful with a live settings-backed
891
+ // snapshot, so absent snapshots keep the legacy output untouched.
892
+ if (options.gate !== undefined) {
893
+ const mapped = Object.entries(options.gate.serverIds).sort((left, right) => left[1] - right[1]);
894
+ lines.push('', 'persistent enable/disable (stable ids; survives restarts):');
895
+ if (mapped.length === 0) {
896
+ lines.push(' (no servers observed yet — ids appear the first time "/mcp" sees them)');
897
+ }
898
+ for (const [server, id] of mapped) {
899
+ lines.push(isServerDisabled(server, options.gate)
900
+ ? ` - [${id}] ${server} ⏸ disabled`
901
+ : ` - [${id}] ${server}`);
902
+ }
903
+ const idCapNotice = renderIdCapNotice(options.unassignedServers);
904
+ if (idCapNotice !== undefined)
905
+ lines.push('', idCapNotice);
906
+ }
907
+ return capRenderedLines(lines.join('\n'));
908
+ }
909
+ /**
910
+ * Execute one /mcp invocation over plain data. Read forms stay side-effect-
911
+ * free except for stable-id bookkeeping: any form that displays ids ensures
912
+ * every newly observed server has one and persists fresh assignments through
913
+ * `writeGate` (best-effort — a failed persistence still renders, ids merely
914
+ * reallocate identically next time). The mutating forms validate their target
915
+ * against the LIVE server set, then persist one combined write (allocations +
916
+ * toggle); every failure surfaces as a structured `{ kind: 'error', text }`
917
+ * outcome, never a throw.
918
+ *
919
+ * @param view - the invocation's resolved inputs.
920
+ * @returns a platform-shaped command outcome.
921
+ */
922
+ export async function executeMcpCommand(view) {
923
+ const parsed = parseMcpCommandInput(view.rawInput);
924
+ const { config } = view;
925
+ // Toggle-free fast path: usage needs neither ids nor persistence.
926
+ if (parsed.form === 'usage')
927
+ return { kind: 'error', text: MCP_COMMAND_USAGE };
928
+ // Stable-id observation point: whatever this invocation can see gets its
929
+ // persistent id now (only when gating is actually wired). Failures keep the
930
+ // local assignment for this render and simply re-run deterministically.
931
+ // Servers that fit no free id travel with the view instead — the views
932
+ // label the exhausted cap, since a toggle cannot target an idless server.
933
+ let effectiveView = view;
934
+ let registry;
935
+ if (view.gate !== undefined && view.writeGate !== undefined) {
936
+ const allocation = allocateMissingServerIds(view.gate, liveServerNames(view.schemas, config));
937
+ registry = allocation.registry;
938
+ if (allocation.added.length > 0) {
939
+ try {
940
+ // The allocation write races the invocation signal like a toggle
941
+ // write: an aborted caller stops waiting while the background write
942
+ // still lands (re-allocation is deterministic anyway).
943
+ await raceWriteAck(view.writeGate(registry), view.signal);
944
+ }
945
+ catch {
946
+ // Persistence failures must not block a status query.
947
+ }
948
+ }
949
+ effectiveView = {
950
+ ...view,
951
+ config: { ...config, gate: registry, unassignedServers: allocation.unassigned },
952
+ };
953
+ }
954
+ switch (parsed.form) {
955
+ case 'overview':
956
+ return { kind: 'success', text: renderMcpOverview(effectiveView.schemas, effectiveView.config, effectiveView.metaToolsLive) };
957
+ case 'detail': {
958
+ const rendered = renderMcpDetail(parsed.name, effectiveView.schemas, effectiveView.config);
959
+ return { kind: rendered.outcome === 'unknown' ? 'error' : 'success', text: rendered.text };
960
+ }
961
+ case 'config':
962
+ return { kind: 'success', text: renderMcpConfig(effectiveView.schemas, effectiveView.config) };
963
+ case 'disable':
964
+ case 'enable':
965
+ return runServerToggle(parsed.form, parsed.id, registry, effectiveView);
966
+ }
967
+ }
968
+ /** Sentinel returned by {@link raceWriteAck} when the caller's WAIT was cut
969
+ * short by the invocation signal (the write itself keeps running). */
970
+ const WRITE_ABANDONED = Symbol('mcp-adapter.write-abandoned');
971
+ /**
972
+ * Await one persistence write, giving up the wait as soon as `signal` aborts.
973
+ * The losing write keeps running in the background — `Promise.race` only
974
+ * cancels the waiting, never the work — and the raced promise is subscribed
975
+ * either way, so a late background failure cannot surface as unhandled.
976
+ */
977
+ async function raceWriteAck(write, signal) {
978
+ if (signal === undefined)
979
+ return write;
980
+ // An already-aborted caller must not see a raced write win the settlement
981
+ // race merely by subscription order: report abandonment outright and keep
982
+ // the background write's failure non-fatal.
983
+ if (signal.aborted) {
984
+ write.catch(() => { });
985
+ return WRITE_ABANDONED;
986
+ }
987
+ let onAbort;
988
+ const abandoned = new Promise(resolve => {
989
+ if (signal.aborted)
990
+ resolve(WRITE_ABANDONED);
991
+ else {
992
+ onAbort = () => resolve(WRITE_ABANDONED);
993
+ signal.addEventListener('abort', onAbort, { once: true });
994
+ }
995
+ });
996
+ try {
997
+ return await Promise.race([write, abandoned]);
998
+ }
999
+ finally {
1000
+ if (onAbort !== undefined)
1001
+ signal.removeEventListener('abort', onAbort);
1002
+ }
1003
+ }
1004
+ /** Execute one validated /mcp disable|enable against the ensured registry. */
1005
+ async function runServerToggle(form, id, registry, view) {
1006
+ const wantDisable = form === 'disable';
1007
+ if (registry === undefined || view.writeGate === undefined) {
1008
+ return {
1009
+ kind: 'error',
1010
+ text: `"/mcp ${form}" needs the dsh settings service to persist server state, `
1011
+ + 'and it is not composed in this process',
1012
+ };
1013
+ }
1014
+ const liveServers = new Set(liveServerNames(view.schemas, view.config));
1015
+ const server = serverNameById(registry, id);
1016
+ if (server === undefined || !liveServers.has(server)) {
1017
+ const why = server === undefined
1018
+ ? `no live MCP server carries id ${id}`
1019
+ : `"${server}" carries id ${id} but has no visible tools in this scope right now`;
1020
+ return { kind: 'error', text: `${why} — run "/mcp" for the assigned ids\n\n${MCP_COMMAND_USAGE}` };
1021
+ }
1022
+ const toggle = setServerDisabledState(registry, server, wantDisable);
1023
+ if (!toggle.changed) {
1024
+ return {
1025
+ kind: 'success',
1026
+ text: wantDisable
1027
+ ? `server "${server}" (id ${id}) is already disabled — nothing changed ("/mcp enable ${id}" restores it)`
1028
+ : `server "${server}" (id ${id}) is not disabled — nothing changed`,
1029
+ };
1030
+ }
1031
+ try {
1032
+ const settled = await raceWriteAck(view.writeGate(toggle.registry), view.signal);
1033
+ if (settled === WRITE_ABANDONED) {
1034
+ // The signal cut only the WAIT; the write keeps running to completion.
1035
+ return {
1036
+ kind: 'error',
1037
+ text: `"/mcp ${form} ${id}" — persist interrupted before confirming; rerun \`/mcp config\` to check the actual state`,
1038
+ };
1039
+ }
1040
+ }
1041
+ catch (error) {
1042
+ return {
1043
+ kind: 'error',
1044
+ text: `"/mcp ${form} ${id}" could not be persisted: ${error instanceof Error ? error.message : String(error)} — state unchanged`,
1045
+ };
1046
+ }
1047
+ return {
1048
+ kind: 'success',
1049
+ text: wantDisable
1050
+ ? `server "${server}" (id ${id}) disabled — its tools fold out of every prompt until "/mcp enable ${id}"`
1051
+ : `server "${server}" (id ${id}) enabled — back to normal folding/catalog/dispatch under the same id`,
1052
+ };
1053
+ }
355
1054
  /**
356
1055
  * Whether a value is this plugin's structured-error wrap (single `error`
357
1056
  * string key) — rendered as plain text, never delegated to a child.
@@ -520,7 +1219,7 @@ export function createMcpListTool(options, listSchemas) {
520
1219
  const query = normalizeMcpListArgs(args);
521
1220
  // The calling agent's view: restricted-away tools stay out of the catalog.
522
1221
  const schemas = listSchemas(exec.agent);
523
- return Promise.resolve(buildMcpListResult(query, schemas, options));
1222
+ return Promise.resolve(buildMcpListResult(query, schemas, options, options.getGate?.()));
524
1223
  },
525
1224
  };
526
1225
  }
@@ -530,9 +1229,13 @@ export function createMcpListTool(options, listSchemas) {
530
1229
  * @param resolve - scope-aware definition resolver (production:
531
1230
  * `ctx.tools.get`, called with the calling agent).
532
1231
  * @param servers - server-name whitelist (default: no filtering).
1232
+ * @param getGate - live disabled-server reader (production: fresh
1233
+ * `settingsScope.get()` snapshot per dispatch; default/omitted: nothing is
1234
+ * disabled). Deliberately a callback — the definition object outlives any
1235
+ * one settings snapshot and must observe later /mcp enable/disable writes.
533
1236
  * @returns the complete tool definition.
534
1237
  */
535
- export function createMcpCallTool(prefix, resolve, servers = []) {
1238
+ export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
536
1239
  return {
537
1240
  name: MCP_CALL_TOOL_NAME,
538
1241
  description: [
@@ -559,7 +1262,7 @@ export function createMcpCallTool(prefix, resolve, servers = []) {
559
1262
  schema: {},
560
1263
  render: (args, value) => renderDispatched(args, value, name => resolve(name)),
561
1264
  },
562
- execute: (args, exec) => dispatchMcpCall(args, prefix, name => resolve(name, exec.agent), exec, servers),
1265
+ execute: (args, exec) => dispatchMcpCall(args, prefix, name => resolve(name, exec.agent), exec, servers, getGate?.()),
563
1266
  // Restore child-owned projections (image attachments): the registry
564
1267
  // invokes THIS definition's finalizer, so it must forward to the child's
565
1268
  // with the same exec object dispatch passed to child.execute.
@@ -574,6 +1277,18 @@ export function createMcpCallTool(prefix, resolve, servers = []) {
574
1277
  * both registrations are rolled back, a warning is logged, and NO listener
575
1278
  * is installed — the official full-schema passthrough stays intact.
576
1279
  *
1280
+ * Server gating: when a dsh settings service is composed, the `mcp-adapter`
1281
+ * namespace is registered on it and every gate consumer reads FRESH resolved
1282
+ * values through small callbacks — the plugin keeps no in-memory copy that
1283
+ * could drift from settings. The read itself is defended too: if the
1284
+ * resolved-value lookup throws, consumers see an absent gate (everything
1285
+ * enabled) instead of the error, keeping the assemble waterfall alive.
1286
+ *
1287
+ * Commands: `/mcp` mounts through a runtime `ctx.inject(['commands'], ...)`
1288
+ * (same seam as settings), so hosts without the commands service keep this
1289
+ * plugin fully functional — one warning, no command. A registration name
1290
+ * conflict degrades to the same warning independently.
1291
+ *
577
1292
  * @param ctx - plugin context carrying the tool registry.
578
1293
  * @param config - resolved adapter configuration.
579
1294
  */
@@ -588,8 +1303,53 @@ export function apply(ctx, config) {
588
1303
  descriptionLimit: config.descriptionLimit,
589
1304
  servers: config.servers,
590
1305
  };
591
- const listDefinition = createMcpListTool(listOptions, scope => ctx.tools.schemas(scope));
592
- const callDefinition = createMcpCallTool(config.prefix, (name, scope) => ctx.tools.get(name, scope), config.servers);
1306
+ // Optional settings consumption (the canonical inject-scoped pattern): the
1307
+ // registration rides its own child fiber, so disposing it removes exactly
1308
+ // the namespace. Late binding over `settingsScope` means the meta-tools,
1309
+ // waterfall, and command created below observe the mount whenever it lands.
1310
+ let settingsScope;
1311
+ if (typeof ctx.inject === 'function') {
1312
+ ctx.inject(['settings'], (sctx) => {
1313
+ settingsScope = sctx.settings.register(MCP_ADAPTER_SETTINGS_NAMESPACE, SERVER_ID_REGISTRY_SCHEMA);
1314
+ return () => { settingsScope = undefined; };
1315
+ });
1316
+ }
1317
+ // One-shot latch: a persistently broken settings provider would otherwise
1318
+ // re-warn on EVERY assembled prompt (getGate runs per waterfall execution).
1319
+ let gateReadWarned = false;
1320
+ // Fresh snapshot per read; normalize guards hand-edited documents. The
1321
+ // scope being absent is distinguishable from an empty section: undefined
1322
+ // propagates so toggle commands can name their missing dependency.
1323
+ const getGate = () => {
1324
+ const scope = settingsScope;
1325
+ if (scope === undefined)
1326
+ return undefined;
1327
+ try {
1328
+ return normalizeServerGate(scope.get());
1329
+ }
1330
+ catch (error) {
1331
+ // Fail-open, hard requirement: a throwing read must never blow up the
1332
+ // system-prompt/assemble waterfall or a /mcp invocation. Gate-less
1333
+ // means every server counts as enabled — visibility only ever grows.
1334
+ if (!gateReadWarned) {
1335
+ gateReadWarned = true;
1336
+ ctx.logger.warn(`mcp-adapter: reading the persisted enable/disable state failed `
1337
+ + `(${error instanceof Error ? error.message : String(error)}) `
1338
+ + '— treating every server as enabled');
1339
+ }
1340
+ return undefined;
1341
+ }
1342
+ };
1343
+ const writeGate = async (next) => {
1344
+ const scope = settingsScope;
1345
+ if (scope === undefined)
1346
+ throw new Error('settings service went away before the write');
1347
+ // Wholesale replace (not update): we always hold the complete next
1348
+ // section, and replace cannot merge stale keys back under us.
1349
+ await scope.replace({ serverIds: { ...next.serverIds }, disabled: [...next.disabled] });
1350
+ };
1351
+ const listDefinition = createMcpListTool({ ...listOptions, getGate }, scope => ctx.tools.schemas(scope));
1352
+ const callDefinition = createMcpCallTool(config.prefix, (name, scope) => ctx.tools.get(name, scope), config.servers, getGate);
593
1353
  const disposers = [];
594
1354
  try {
595
1355
  disposers.push(ctx.tools.register(listDefinition));
@@ -617,7 +1377,74 @@ export function apply(ctx, config) {
617
1377
  // missing, shadowed, or foreign `mcp_list`/`mcp_call` means passthrough.
618
1378
  const metaToolsLive = ctx.tools.get(MCP_LIST_TOOL_NAME, context.scope) === listDefinition
619
1379
  && ctx.tools.get(MCP_CALL_TOOL_NAME, context.scope) === callDefinition;
620
- return foldPromptAssembly(assembled, foldOptions, metaToolsLive);
1380
+ return foldPromptAssembly(assembled, foldOptions, metaToolsLive, getGate());
621
1381
  });
1382
+ // Status + server toggles on the platform command service (visible in TUI
1383
+ // completion and web slash panels). The service is mounted SOFTLY: unlike
1384
+ // a static `inject` entry, an absent commands service only costs the
1385
+ // command — the meta-tools, waterfall, and gating load regardless.
1386
+ if (typeof ctx.inject !== 'function') {
1387
+ ctx.logger.warn(`mcp-adapter: context cannot inject services — "/${MCP_COMMAND_NAME}" is unavailable; `
1388
+ + 'folding and meta-tools keep working');
1389
+ }
1390
+ else {
1391
+ let mounted = false;
1392
+ // Deferred so a callback that fires synchronously (services already
1393
+ // present) does not trip over the not-yet-assigned handle.
1394
+ let mountCheck;
1395
+ ctx.inject(['commands'], (ccmds) => {
1396
+ mounted = true;
1397
+ if (mountCheck !== undefined)
1398
+ clearTimeout(mountCheck);
1399
+ try {
1400
+ const commandDisposer = ccmds.commands.register({
1401
+ name: MCP_COMMAND_NAME,
1402
+ description: 'Show MCP status; disable/enable MCP servers',
1403
+ input: { hint: '[list [server|tool] | config | disable <id> | enable <id>]' },
1404
+ handler: invocation => {
1405
+ // The receiving agent's restricted view; an absent agent degrades to
1406
+ // the global registry view (`schemas()` without scope).
1407
+ const scope = invocation.agent ?? undefined;
1408
+ const schemas = ctx.tools.schemas(scope);
1409
+ const metaToolsLive = ctx.tools.get(MCP_LIST_TOOL_NAME, scope) === listDefinition
1410
+ && ctx.tools.get(MCP_CALL_TOOL_NAME, scope) === callDefinition;
1411
+ // ONE snapshot per execution keeps config.gate and view.gate identical.
1412
+ const gate = getGate();
1413
+ return executeMcpCommand({
1414
+ rawInput: invocation.rawInput,
1415
+ schemas,
1416
+ config: {
1417
+ prefix: config.prefix,
1418
+ keep: config.keep,
1419
+ servers: config.servers,
1420
+ descriptionLimit: config.descriptionLimit,
1421
+ gate,
1422
+ },
1423
+ metaToolsLive,
1424
+ gate,
1425
+ signal: invocation.signal,
1426
+ writeGate,
1427
+ });
1428
+ },
1429
+ });
1430
+ // Disposing the injected fiber disposes exactly this registration;
1431
+ // if the service restarts, cordis re-runs this callback and a fresh
1432
+ // registration takes its place.
1433
+ return () => commandDisposer();
1434
+ }
1435
+ catch (error) {
1436
+ ccmds.logger.warn(`mcp-adapter: "/${MCP_COMMAND_NAME}" command registration failed `
1437
+ + `(${error instanceof Error ? error.message : String(error)}) — meta-tools keep working`);
1438
+ }
1439
+ });
1440
+ // One heads-up for hosts that genuinely never compose the commands
1441
+ // service; cancelled by the mount above whenever it lands.
1442
+ mountCheck = setTimeout(() => {
1443
+ if (!mounted) {
1444
+ ctx.logger.warn(`mcp-adapter: no platform commands service — "/${MCP_COMMAND_NAME}" is unavailable; `
1445
+ + 'folding and meta-tools keep working');
1446
+ }
1447
+ }, 0);
1448
+ }
622
1449
  }
623
1450
  //# sourceMappingURL=index.js.map