@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/README.md +26 -0
- package/README.zh.md +26 -0
- package/lib/index.d.ts +401 -6
- package/lib/index.js +842 -15
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|