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