@aiwayds/dsh-mcp-adapter 0.5.0 → 0.6.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 +50 -4
- package/README.zh.md +51 -5
- package/lib/index.d.ts +340 -23
- package/lib/index.js +829 -74
- package/lib/index.js.map +1 -1
- package/lib/jev/backends.d.ts +35 -0
- package/lib/jev/backends.js +48 -0
- package/lib/jev/backends.js.map +1 -0
- package/lib/jev/client.d.ts +133 -0
- package/lib/jev/client.js +288 -0
- package/lib/jev/client.js.map +1 -0
- package/lib/jev/config.d.ts +82 -0
- package/lib/jev/config.js +55 -0
- package/lib/jev/config.js.map +1 -0
- package/lib/jev/dual.d.ts +42 -0
- package/lib/jev/dual.js +42 -0
- package/lib/jev/dual.js.map +1 -0
- package/lib/jev/gate.d.ts +51 -0
- package/lib/jev/gate.js +141 -0
- package/lib/jev/gate.js.map +1 -0
- package/lib/jev/log.d.ts +93 -0
- package/lib/jev/log.js +138 -0
- package/lib/jev/log.js.map +1 -0
- package/lib/jev/seams.d.ts +118 -0
- package/lib/jev/seams.js +364 -0
- package/lib/jev/seams.js.map +1 -0
- package/lib/jev/thresholds.d.ts +37 -0
- package/lib/jev/thresholds.js +44 -0
- package/lib/jev/thresholds.js.map +1 -0
- package/lib/rank.d.ts +157 -0
- package/lib/rank.js +327 -0
- package/lib/rank.js.map +1 -0
- package/lib/usage.d.ts +232 -0
- package/lib/usage.js +362 -0
- package/lib/usage.js.map +1 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -54,10 +54,19 @@
|
|
|
54
54
|
* @module @aiwayds/dsh-mcp-adapter
|
|
55
55
|
*/
|
|
56
56
|
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
57
|
-
import { readFileSync } from 'node:fs';
|
|
57
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
58
58
|
import { homedir } from 'node:os';
|
|
59
|
-
import { join, resolve as resolvePath } from 'node:path';
|
|
59
|
+
import { basename, join, resolve as resolvePath } from 'node:path';
|
|
60
60
|
import z from '@deepseek-ai/schemastery';
|
|
61
|
+
// System One (jev) decision wiring: the seven volatile settings keys, the
|
|
62
|
+
// decisions.jsonl telemetry sink, the three business seams, and the pure
|
|
63
|
+
// lexical rankers every seam degrades to.
|
|
64
|
+
import { JEV_CONFIG_FIELDS } from "./jev/config.js";
|
|
65
|
+
import { createDecisionLog } from "./jev/log.js";
|
|
66
|
+
import { askKeepSuggestions, rankDidYouMean, rankListQuery } from "./jev/seams.js";
|
|
67
|
+
import { didYouMeanCandidates, didYouMeanMessage, mergeNameOrder, rankByQuery, tokenize } from "./rank.js";
|
|
68
|
+
import { DID_YOU_MEAN_LIMIT, LIST_RESULT_LIMIT, RANK_CANDIDATE_LIMIT } from "./rank.js";
|
|
69
|
+
import { createUsageStore, keepSuggestionFragment, readSuggestions, shouldRunSuggestBatch, usageSuggestCandidates, usageToolCount, writeSuggestions, SUGGEST_BATCH_EVERY, } from "./usage.js";
|
|
61
70
|
/** Cordis plugin name used by loader diagnostics. */
|
|
62
71
|
export const name = 'mcp-adapter';
|
|
63
72
|
/**
|
|
@@ -257,10 +266,23 @@ function loadInitialGate(file, warn) {
|
|
|
257
266
|
* synchronous (boot-time, once); writes land atomically (tmp file + rename)
|
|
258
267
|
* so a crash mid-write can never leave a torn document, and the in-memory
|
|
259
268
|
* mirror only advances after the file is durably renamed into place.
|
|
269
|
+
*
|
|
270
|
+
* Legacy absorption (one-time, 0.1.5 → 0.1.7): on a FRESH gate — no
|
|
271
|
+
* gate.json yet — the machine state from the removed settings registry is
|
|
272
|
+
* absorbed once from the host's legacy settings documents (see
|
|
273
|
+
* {@link absorbLegacyGate}). Opt-in through `options.home`: the legacy
|
|
274
|
+
* documents are a HOME-level concern (not this store's `dir`), and stores
|
|
275
|
+
* built without a home (bare test harnesses) stay exactly as isolated as
|
|
276
|
+
* before.
|
|
260
277
|
*/
|
|
261
|
-
export function createFileGateStore(dir, warn) {
|
|
278
|
+
export function createFileGateStore(dir, warn, options = {}) {
|
|
262
279
|
const file = join(dir, GATE_FILE_NAME);
|
|
280
|
+
const hadGateFile = existsSync(file);
|
|
263
281
|
let mirror = loadInitialGate(file, warn);
|
|
282
|
+
if (!hadGateFile) {
|
|
283
|
+
// Fresh install: one chance to take over the pre-0.1.7 gate state.
|
|
284
|
+
mirror = absorbLegacyGate(mirror, dir, warn, options);
|
|
285
|
+
}
|
|
264
286
|
return {
|
|
265
287
|
get: () => mirror,
|
|
266
288
|
async write(next) {
|
|
@@ -275,7 +297,205 @@ export function createFileGateStore(dir, warn) {
|
|
|
275
297
|
},
|
|
276
298
|
};
|
|
277
299
|
}
|
|
300
|
+
// ---- Legacy settings absorption (one-time, 0.1.5 → 0.1.7) ----
|
|
301
|
+
/**
|
|
302
|
+
* dsh 0.1.7 renamed the old `<home>/settings.yaml` to
|
|
303
|
+
* `settings.yaml.imported` after a one-shot import keyed by "section name =
|
|
304
|
+
* entry id". This plugin's old `mcp-adapter:` section (the machine-maintained
|
|
305
|
+
* id registry / disabled set) does not match the entry id `dsh-mcp-adapter`
|
|
306
|
+
* and was silently dropped by the host — the state lives on only in the
|
|
307
|
+
* renamed document. {@link absorbLegacyGate} recovers it once into the
|
|
308
|
+
* plugin's own gate.json.
|
|
309
|
+
*/
|
|
310
|
+
/** Legacy host settings documents, in read priority order. */
|
|
311
|
+
export const LEGACY_SETTINGS_FILES = ['settings.yaml.imported', 'settings.yaml'];
|
|
312
|
+
/** Legacy top-level section name in the old settings document. */
|
|
313
|
+
export const LEGACY_GATE_SECTION = 'mcp-adapter';
|
|
314
|
+
/** Audit marker file name: `<home>/storages/mcp-adapter/<name>`. */
|
|
315
|
+
export const LEGACY_IMPORT_MARKER = 'legacy-import.json';
|
|
316
|
+
/**
|
|
317
|
+
* Decode one YAML scalar of the old settings document — the shapes the 0.1.5
|
|
318
|
+
* writer actually produced (plain scalars, booleans, and quoted single-line
|
|
319
|
+
* flow-JSON payloads). A value that opens a quote it does not close on the
|
|
320
|
+
* same line is a FOLDED long scalar (dumpers wrap at 80 columns):
|
|
321
|
+
* unparseable here by design, so the key is skipped — never a half-read
|
|
322
|
+
* value.
|
|
323
|
+
*/
|
|
324
|
+
function decodeLegacyScalar(raw) {
|
|
325
|
+
const value = raw.trim();
|
|
326
|
+
if (value === '' || value === 'null' || value === '~')
|
|
327
|
+
return undefined;
|
|
328
|
+
if (value === 'true')
|
|
329
|
+
return true;
|
|
330
|
+
if (value === 'false')
|
|
331
|
+
return false;
|
|
332
|
+
const quote = value[0];
|
|
333
|
+
const closed = value.length >= 2 && value[value.length - 1] === quote;
|
|
334
|
+
if (quote === "'" || quote === '"') {
|
|
335
|
+
if (!closed || value.length < 2)
|
|
336
|
+
return undefined; // folded / unterminated — skip
|
|
337
|
+
if (quote === '"') {
|
|
338
|
+
// Double-quoted payloads are JSON-compatible as-is.
|
|
339
|
+
try {
|
|
340
|
+
return JSON.parse(value);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
347
|
+
}
|
|
348
|
+
const hash = value.indexOf(' #');
|
|
349
|
+
return hash >= 0 ? value.slice(0, hash).trim() : value;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Extract the top-level `mcp-adapter:` section of the old settings document.
|
|
353
|
+
* Handles both shapes the 0.1.5 writer produced:
|
|
354
|
+
* - single-line values (`serverIds: '{"fs": 1}'`, `disabled: '[1]'`);
|
|
355
|
+
* - block collections (`serverIds:` followed by deeper-indented
|
|
356
|
+
* `name: <int>` lines; `disabled:` followed by `- <int>` lines).
|
|
357
|
+
* Everything else (comments, folded scalars, nested junk) is skipped.
|
|
358
|
+
* undefined = no section found. Pure function — tests run it against
|
|
359
|
+
* literal strings.
|
|
360
|
+
*/
|
|
361
|
+
export function legacyGateSection(text) {
|
|
362
|
+
const lines = text.split('\n');
|
|
363
|
+
const start = lines.findIndex(line => /^mcp-adapter:\s*(#.*)?$/.test(line));
|
|
364
|
+
if (start < 0)
|
|
365
|
+
return undefined;
|
|
366
|
+
const section = {};
|
|
367
|
+
const ids = {};
|
|
368
|
+
const seq = [];
|
|
369
|
+
let block;
|
|
370
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
371
|
+
const line = lines[index];
|
|
372
|
+
if (line.trim() === '' || /^\s*#/.test(line))
|
|
373
|
+
continue;
|
|
374
|
+
if (/^\S/.test(line))
|
|
375
|
+
break; // the next top-level key ends the section
|
|
376
|
+
const indent = line.length - line.trimStart().length;
|
|
377
|
+
if (block !== undefined && indent > block.indent) {
|
|
378
|
+
// Children of an open block collection carry the machine state.
|
|
379
|
+
if (block.key === 'serverIds') {
|
|
380
|
+
const entry = /^([^\s:#]+):\s*(\d+)\s*(?:#.*)?$/.exec(line.trim());
|
|
381
|
+
if (entry !== null)
|
|
382
|
+
ids[entry[1]] = Number(entry[2]);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const entry = /^-\s*(\d+)\s*(?:#.*)?$/.exec(line.trim());
|
|
386
|
+
if (entry !== null)
|
|
387
|
+
seq.push(Number(entry[1]));
|
|
388
|
+
}
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
block = undefined;
|
|
392
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*):[ \t]?(.*)$/.exec(line.trim());
|
|
393
|
+
if (match === null)
|
|
394
|
+
continue; // nested shape we do not model — skip the line
|
|
395
|
+
const [, key, rawValue] = match;
|
|
396
|
+
if (key !== 'serverIds' && key !== 'disabled')
|
|
397
|
+
continue;
|
|
398
|
+
const value = rawValue.trim();
|
|
399
|
+
if (value === '') {
|
|
400
|
+
// Opens a block collection (or is null — the children decide).
|
|
401
|
+
block = { key, indent };
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
section[key] = decodeLegacyScalar(rawValue);
|
|
405
|
+
}
|
|
406
|
+
if (Object.keys(ids).length > 0)
|
|
407
|
+
section.serverIds = ids;
|
|
408
|
+
if (seq.length > 0)
|
|
409
|
+
section.disabled = seq;
|
|
410
|
+
return section;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Turn one decoded section into the raw candidate {@link normalizeServerGate}
|
|
414
|
+
* sanitizes: flow-JSON strings are JSON-parsed (malformed → undefined, the
|
|
415
|
+
* key degrades to "absent"), already-decoded block collections pass through.
|
|
416
|
+
*/
|
|
417
|
+
export function legacyGateCandidate(section) {
|
|
418
|
+
const fromFlow = (value) => {
|
|
419
|
+
if (typeof value !== 'string')
|
|
420
|
+
return value;
|
|
421
|
+
try {
|
|
422
|
+
return JSON.parse(value);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
return { serverIds: fromFlow(section.serverIds), disabled: fromFlow(section.disabled) };
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The one-time absorption pass over the legacy documents. Marker semantics
|
|
432
|
+
* match the sibling plugins: written for EVERY settled outcome EXCEPT the
|
|
433
|
+
* retry-worthy failure (an unexpected error — no marker, the next boot
|
|
434
|
+
* retries); a present marker short-circuits the whole pass (idempotent — a
|
|
435
|
+
* later manual edit of the imported file must not resurrect old values).
|
|
436
|
+
* Returns the mirror to run on (the absorbed state, or the input unchanged).
|
|
437
|
+
*/
|
|
438
|
+
function absorbLegacyGate(current, dir, warn, options) {
|
|
439
|
+
try {
|
|
440
|
+
const home = options.home;
|
|
441
|
+
if (home === undefined)
|
|
442
|
+
return current;
|
|
443
|
+
const markerPath = join(home, 'storages', MCP_ADAPTER_STORAGE_DIRNAME, LEGACY_IMPORT_MARKER);
|
|
444
|
+
if (existsSync(markerPath))
|
|
445
|
+
return current;
|
|
446
|
+
const writeMarker = (marker) => {
|
|
447
|
+
try {
|
|
448
|
+
mkdirSync(join(markerPath, '..'), { recursive: true });
|
|
449
|
+
writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// contained — a missing audit file only costs a re-scan next boot
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
const source = LEGACY_SETTINGS_FILES
|
|
456
|
+
.map(name => join(home, name))
|
|
457
|
+
.find(path => existsSync(path));
|
|
458
|
+
if (source === undefined) {
|
|
459
|
+
writeMarker({ at: new Date().toISOString(), outcome: 'no-legacy', source: undefined });
|
|
460
|
+
return current;
|
|
461
|
+
}
|
|
462
|
+
const section = legacyGateSection(readFileSync(source, 'utf8'));
|
|
463
|
+
const absorbed = normalizeServerGate(section === undefined ? {} : legacyGateCandidate(section));
|
|
464
|
+
const serverIdCount = Object.keys(absorbed.serverIds).length;
|
|
465
|
+
if (serverIdCount === 0 && absorbed.disabled.length === 0) {
|
|
466
|
+
writeMarker({ at: new Date().toISOString(), outcome: section === undefined ? 'no-section' : 'no-op', source });
|
|
467
|
+
return current;
|
|
468
|
+
}
|
|
469
|
+
// Persist IMMEDIATELY (sync atomic tmp+rename, the write-through pose):
|
|
470
|
+
// gate.json must exist the moment the state was taken over, so ids and
|
|
471
|
+
// toggles visibly survive the upgrade without waiting for a first write.
|
|
472
|
+
const file = join(dir, GATE_FILE_NAME);
|
|
473
|
+
const payload = JSON.stringify({ serverIds: { ...absorbed.serverIds }, disabled: [...absorbed.disabled] }, null, 2) + '\n';
|
|
474
|
+
mkdirSync(dir, { recursive: true });
|
|
475
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
476
|
+
writeFileSync(tmp, payload, 'utf8');
|
|
477
|
+
renameSync(tmp, file);
|
|
478
|
+
writeMarker({
|
|
479
|
+
at: new Date().toISOString(),
|
|
480
|
+
outcome: 'imported',
|
|
481
|
+
source,
|
|
482
|
+
serverIds: serverIdCount,
|
|
483
|
+
disabled: absorbed.disabled.length,
|
|
484
|
+
});
|
|
485
|
+
options.info?.(`mcp-adapter: legacy settings import (${basename(source)} → ${MCP_ADAPTER_STORAGE_DIRNAME}/${GATE_FILE_NAME}): `
|
|
486
|
+
+ `serverIds=${serverIdCount}, disabled=${absorbed.disabled.length}`);
|
|
487
|
+
return absorbed;
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
warn(`mcp-adapter: legacy settings absorption failed (ignored — starting from the current gate, retried next boot): `
|
|
491
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
492
|
+
return current;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
278
495
|
export const Config = z.object({
|
|
496
|
+
// System One decision settings (master switch defaults to false: a fresh
|
|
497
|
+
// install makes ZERO jev calls and behaves exactly like the pre-jev plugin).
|
|
498
|
+
...JEV_CONFIG_FIELDS,
|
|
279
499
|
prefix: z.string().pattern(PREFIX_PATTERN).default(DEFAULT_PREFIX).volatile(),
|
|
280
500
|
keep: z.array(String).default([]).volatile(),
|
|
281
501
|
servers: z.array(String).default([]).volatile(),
|
|
@@ -382,6 +602,11 @@ export function foldPromptAssembly(assembly, options, metaToolsLive, gate) {
|
|
|
382
602
|
/**
|
|
383
603
|
* Narrow arbitrary model arguments to the `mcp_list` shape, dropping
|
|
384
604
|
* unknown or mistyped keys instead of throwing (the model misbehaves).
|
|
605
|
+
*
|
|
606
|
+
* A blank `query` is dropped rather than kept: an empty search is not a
|
|
607
|
+
* search, and honoring it would put the catalog on a different code path (the
|
|
608
|
+
* ranked one) for a request that means "show me everything".
|
|
609
|
+
*
|
|
385
610
|
* @param args - raw model arguments.
|
|
386
611
|
* @returns the normalized catalog query.
|
|
387
612
|
*/
|
|
@@ -393,6 +618,7 @@ export function normalizeMcpListArgs(args) {
|
|
|
393
618
|
...typeof raw.tool === 'string' ? { tool: raw.tool } : {},
|
|
394
619
|
...typeof raw.server === 'string' ? { server: raw.server } : {},
|
|
395
620
|
...raw.verbose === true ? { verbose: true } : {},
|
|
621
|
+
...typeof raw.query === 'string' && raw.query.trim() !== '' ? { query: raw.query } : {},
|
|
396
622
|
};
|
|
397
623
|
}
|
|
398
624
|
/**
|
|
@@ -433,22 +659,240 @@ export function truncateDescription(text, limit) {
|
|
|
433
659
|
end -= 1;
|
|
434
660
|
return `${flat.slice(0, end)}…`;
|
|
435
661
|
}
|
|
662
|
+
/**
|
|
663
|
+
* Collect the visible catalog candidates in registry order, applying the
|
|
664
|
+
* shared filter contract (never the meta-tools, prefix match, servers
|
|
665
|
+
* whitelist, gate) and an optional server filter. Disabled servers are
|
|
666
|
+
* reported separately instead of vanishing silently.
|
|
667
|
+
*/
|
|
668
|
+
function collectCatalogCandidates(schemas, options, args, gate) {
|
|
669
|
+
const { prefix, descriptionLimit } = options;
|
|
670
|
+
const servers = options.servers ?? [];
|
|
671
|
+
const candidates = [];
|
|
672
|
+
// Servers that passed prefix/whitelist checks but were hidden by the gate.
|
|
673
|
+
const gatedAway = [];
|
|
674
|
+
for (const schema of schemas) {
|
|
675
|
+
// Never catalog the meta-tools themselves, even under a prefix that
|
|
676
|
+
// matches their names.
|
|
677
|
+
if (schema.name === MCP_LIST_TOOL_NAME || schema.name === MCP_CALL_TOOL_NAME)
|
|
678
|
+
continue;
|
|
679
|
+
if (!schema.name.startsWith(prefix))
|
|
680
|
+
continue;
|
|
681
|
+
if (!isAllowedServer(schema.name, prefix, servers))
|
|
682
|
+
continue;
|
|
683
|
+
const server = serverOfToolName(schema.name, prefix);
|
|
684
|
+
// Disabled servers vanish from the directory entirely; point-name
|
|
685
|
+
// expansion above remains the structured way to learn why.
|
|
686
|
+
if (isServerDisabled(server, gate)) {
|
|
687
|
+
if (!gatedAway.includes(server))
|
|
688
|
+
gatedAway.push(server);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (args.server !== undefined && server !== args.server)
|
|
692
|
+
continue;
|
|
693
|
+
candidates.push({
|
|
694
|
+
name: schema.name,
|
|
695
|
+
description: schema.description,
|
|
696
|
+
shown: truncateDescription(schema.description, descriptionLimit),
|
|
697
|
+
...args.verbose === true ? { parameters: schema.parameters } : {},
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
return { candidates, gatedAway };
|
|
701
|
+
}
|
|
702
|
+
/** Group already-ordered candidates by server, first-seen order preserved. */
|
|
703
|
+
function groupCatalogEntries(candidates, prefix) {
|
|
704
|
+
const serversOut = [];
|
|
705
|
+
for (const candidate of candidates) {
|
|
706
|
+
const server = serverOfToolName(candidate.name, prefix);
|
|
707
|
+
let group = serversOut.find(entry => entry.server === server);
|
|
708
|
+
if (group === undefined) {
|
|
709
|
+
group = { server, tools: [] };
|
|
710
|
+
serversOut.push(group);
|
|
711
|
+
}
|
|
712
|
+
group.tools.push({
|
|
713
|
+
name: candidate.name,
|
|
714
|
+
description: candidate.shown,
|
|
715
|
+
...candidate.parameters === undefined ? {} : { parameters: candidate.parameters },
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return serversOut;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The structured error for a catalog that selected nothing — the gate-aware
|
|
722
|
+
* variants come first, so a fully-disabled deployment is never told "nothing
|
|
723
|
+
* is registered".
|
|
724
|
+
*/
|
|
725
|
+
function emptyCatalogError(args, options, gate, gatedAway) {
|
|
726
|
+
if (args.server !== undefined && isServerDisabled(args.server, gate)) {
|
|
727
|
+
const id = gate?.serverIds[args.server];
|
|
728
|
+
return { error: `server "${args.server}" is disabled and hidden from the catalog — restore it with "/mcp enable ${String(id)}"` };
|
|
729
|
+
}
|
|
730
|
+
// An unfiltered catalog that found prefix tools but lost ALL of them to
|
|
731
|
+
// the gate must not claim "nothing is registered" (same facts as the
|
|
732
|
+
// filtered path above): name each hidden server with its restore hint.
|
|
733
|
+
if (args.server === undefined && gatedAway.length > 0) {
|
|
734
|
+
const named = gatedAway.map(name => `"${name}" ("/mcp enable ${String(gate?.serverIds[name])}")`).join(', ');
|
|
735
|
+
return { error: `every MCP tool under the "${options.prefix}" prefix belongs to a disabled server and is hidden from the catalog: ${named}` };
|
|
736
|
+
}
|
|
737
|
+
return args.server === undefined
|
|
738
|
+
? { error: `no MCP tools are registered under the "${options.prefix}" prefix` }
|
|
739
|
+
: { error: `no MCP tools match server "${args.server}"` };
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* The SEMANTIC-FALLBACK pool: the whole visible catalog, capped at the jev
|
|
743
|
+
* batch size, in registry order.
|
|
744
|
+
*
|
|
745
|
+
* WHY IT EXISTS: an empty lexical pool does not mean "the catalog is empty",
|
|
746
|
+
* it means "no word of the query appeared in any name or description" — and
|
|
747
|
+
* that is the NORMAL outcome of a Chinese query against English MCP
|
|
748
|
+
* descriptions (context7/deepwiki/github all ship English blurbs), so the old
|
|
749
|
+
* `no MCP tool matches` error was a claim about a search that never ran.
|
|
750
|
+
* Handing the directory over instead lets a semantic reranker answer it, and
|
|
751
|
+
* lets a caller with no reranker show the catalog rather than assert a lie.
|
|
752
|
+
*
|
|
753
|
+
* One definition, shared by the exported prefilter and the ranked builder, so
|
|
754
|
+
* the two can never disagree about what the fallback set is.
|
|
755
|
+
*/
|
|
756
|
+
function semanticFallbackPool(candidates) {
|
|
757
|
+
return candidates.slice(0, RANK_CANDIDATE_LIMIT);
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Lexical shortlist over an ALREADY collected candidate set — the shared core
|
|
761
|
+
* of {@link listQueryRanking} and the ranked branch of
|
|
762
|
+
* {@link buildMcpListResult}, so the exported prefilter and the builder can
|
|
763
|
+
* never disagree about what "the query matches" means.
|
|
764
|
+
*
|
|
765
|
+
* An EMPTY lexical pool is a semantic fallback, not an error: the pool becomes
|
|
766
|
+
* {@link semanticFallbackPool} and `semantic` says so. The only error left
|
|
767
|
+
* here is a catalog that is genuinely empty (or fully gated) — the one case
|
|
768
|
+
* where "there is nothing to show" is a fact rather than a guess.
|
|
769
|
+
*/
|
|
770
|
+
function queryRankingFrom(query, args, options, gate, collected) {
|
|
771
|
+
const { candidates, gatedAway } = collected;
|
|
772
|
+
if (candidates.length === 0) {
|
|
773
|
+
return { pool: [], total: 0, error: emptyCatalogError(args, options, gate, gatedAway).error };
|
|
774
|
+
}
|
|
775
|
+
const matched = rankByQuery(query, candidates);
|
|
776
|
+
if (matched.length === 0) {
|
|
777
|
+
return { pool: semanticFallbackPool(candidates), total: candidates.length, semantic: true };
|
|
778
|
+
}
|
|
779
|
+
return { pool: matched.slice(0, RANK_CANDIDATE_LIMIT), total: matched.length };
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* The lexical relevance shortlist for one `query` catalog call — the pure,
|
|
783
|
+
* synchronous half of seam B. This is both what the jev reranker is asked
|
|
784
|
+
* about and the order that stands when jev is disabled or fails.
|
|
785
|
+
*
|
|
786
|
+
* @param args - the normalized catalog query (must carry a `query`).
|
|
787
|
+
* @param schemas - schemas visible to the calling agent.
|
|
788
|
+
* @param options - catalog knobs.
|
|
789
|
+
* @param gate - live disabled-server snapshot (default: none disabled).
|
|
790
|
+
* @returns the shortlist (name + full description per entry, so a reranker
|
|
791
|
+
* can ask about them without re-reading the schemas) plus the uncapped
|
|
792
|
+
* match count. A query the prefilter cannot lexically match comes back
|
|
793
|
+
* `semantic` with the whole visible catalog as its pool. A structured error
|
|
794
|
+
* means the catalog itself is empty or fully gated.
|
|
795
|
+
*/
|
|
796
|
+
export function listQueryRanking(args, schemas, options, gate) {
|
|
797
|
+
return queryRankingFrom(args.query ?? '', args, options, gate, collectCatalogCandidates(schemas, options, args, gate));
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* The `note` a `{ query }` answer carries — the one line that tells the model
|
|
801
|
+
* what it is looking at.
|
|
802
|
+
*
|
|
803
|
+
* Two shapes, deliberately distinguishable: the LEXICAL one (frozen: existing
|
|
804
|
+
* sessions and the model's habits read it) and the SEMANTIC-FALLBACK one,
|
|
805
|
+
* which states out loud that nothing matched lexically and what the order it
|
|
806
|
+
* does have came from — so a plain directory can never be read as a ranked
|
|
807
|
+
* shortlist. Both name the no-argument catalog, the discovery path that is
|
|
808
|
+
* always there.
|
|
809
|
+
*/
|
|
810
|
+
function listQueryNote(query, shown, total, semantic, reordered) {
|
|
811
|
+
const catalog = `call ${MCP_LIST_TOOL_NAME} with no arguments for the full catalog`;
|
|
812
|
+
if (!semantic) {
|
|
813
|
+
return `ranked view for query "${query}": ${shown} of ${total} matching tool(s) — ${catalog}.`;
|
|
814
|
+
}
|
|
815
|
+
const source = reordered
|
|
816
|
+
? 'no lexical match, so the order is semantic relevance'
|
|
817
|
+
: 'no lexical match and no semantic rerank, so the order is the catalog registry order';
|
|
818
|
+
return `semantic fallback for query "${query}": ${shown} of ${total} visible tool(s) — ${source}; ${catalog}.`;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* The candidate set a `{ query }` answer is built from, resolved identically
|
|
822
|
+
* whether the async layer supplied a ranking or the builder recomputed it (a
|
|
823
|
+
* direct call in tests, or a ranking for a different query): the lexical
|
|
824
|
+
* shortlist when the prefilter matched anything, else the semantic-fallback
|
|
825
|
+
* set. Both branches read the same two functions `queryRankingFrom` does, so
|
|
826
|
+
* the two paths agree by construction.
|
|
827
|
+
*/
|
|
828
|
+
function queryView(query, candidates, effective) {
|
|
829
|
+
if (effective !== undefined) {
|
|
830
|
+
return { pool: effective.pool, total: effective.total, semantic: effective.semantic === true };
|
|
831
|
+
}
|
|
832
|
+
const matched = rankByQuery(query, candidates);
|
|
833
|
+
return matched.length > 0
|
|
834
|
+
? { pool: matched.slice(0, RANK_CANDIDATE_LIMIT).map(candidate => candidate.name), total: matched.length, semantic: false }
|
|
835
|
+
: { pool: semanticFallbackPool(candidates).map(candidate => candidate.name), total: candidates.length, semantic: true };
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* The ranked catalog answer for one `query` call: a self-describing shortlist.
|
|
839
|
+
* `ranked`/`total`/`note` are the contract with the model — it must be
|
|
840
|
+
* impossible to mistake this for the whole catalog.
|
|
841
|
+
*
|
|
842
|
+
* `ranked` is the DISCRIMINATOR, not decoration: `true` means a lexical
|
|
843
|
+
* prefilter actually matched and this is a subset of the matches; `false`
|
|
844
|
+
* means the pool is the semantic fallback (no lexical match at all) and what
|
|
845
|
+
* is shown is a slice of the visible directory, in whatever order survived.
|
|
846
|
+
*/
|
|
847
|
+
function buildRankedCatalog(query, candidates, options, ranking) {
|
|
848
|
+
const effective = ranking !== undefined && ranking.query === query ? ranking : undefined;
|
|
849
|
+
const view = queryView(query, candidates, effective);
|
|
850
|
+
const order = effective?.order;
|
|
851
|
+
const byName = new Map(candidates.map(candidate => [candidate.name, candidate]));
|
|
852
|
+
const ordered = order === undefined ? view.pool : mergeNameOrder(view.pool, order);
|
|
853
|
+
const shown = ordered
|
|
854
|
+
.map(name => byName.get(name))
|
|
855
|
+
.filter((candidate) => candidate !== undefined)
|
|
856
|
+
.slice(0, LIST_RESULT_LIMIT);
|
|
857
|
+
return {
|
|
858
|
+
servers: groupCatalogEntries(shown, options.prefix),
|
|
859
|
+
query,
|
|
860
|
+
ranked: !view.semantic,
|
|
861
|
+
total: view.total,
|
|
862
|
+
// An EMPTY order is "no opinion", not an order that arrived — the note
|
|
863
|
+
// must not credit a rerank that returned nothing.
|
|
864
|
+
note: listQueryNote(query, shown.length, view.total, view.semantic, (order?.length ?? 0) > 0),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
436
867
|
/**
|
|
437
868
|
* Build the `mcp_list` result over the currently visible schemas.
|
|
438
869
|
*
|
|
439
870
|
* Priority: `tool` (full single-tool expansion) > catalog (optionally
|
|
440
|
-
* filtered by `server`, optionally `verbose
|
|
441
|
-
* and any query that selects nothing
|
|
442
|
-
*
|
|
871
|
+
* filtered by `server`, optionally `verbose`, optionally ranked by `query`).
|
|
872
|
+
* Unknown or non-MCP `tool`, and any query that selects nothing out of an
|
|
873
|
+
* empty or fully gated catalog, return a structured error — never a throw —
|
|
874
|
+
* so the model can correct itself.
|
|
875
|
+
*
|
|
876
|
+
* A `query` that the LEXICAL prefilter cannot match is NOT an error: it
|
|
877
|
+
* degrades to the semantic fallback (the whole visible catalog, capped, and
|
|
878
|
+
* `ranked: false`), because "no word of the query appeared anywhere" is not
|
|
879
|
+
* "no tool matches" — see {@link semanticFallbackPool}.
|
|
880
|
+
*
|
|
881
|
+
* `query` is a MODEL-EXPLICIT narrowing: it is the only catalog path that
|
|
882
|
+
* consults `ranking` (production: the System One seam), and a missing/failed
|
|
883
|
+
* rerank degrades to the lexical order. With no `query`, this function is the
|
|
884
|
+
* pre-seim builder unchanged.
|
|
443
885
|
*
|
|
444
886
|
* @param args - the normalized catalog query.
|
|
445
887
|
* @param schemas - schemas visible to the calling agent.
|
|
446
888
|
* @param options - catalog knobs.
|
|
447
889
|
* @param gate - live disabled-server snapshot (default: none disabled).
|
|
890
|
+
* @param ranking - precomputed relevance ranking for `args.query`
|
|
891
|
+
* (production: the async layer's jev answer).
|
|
448
892
|
* @returns the canonical result value.
|
|
449
893
|
*/
|
|
450
|
-
export function buildMcpListResult(args, schemas, options, gate) {
|
|
451
|
-
const { prefix
|
|
894
|
+
export function buildMcpListResult(args, schemas, options, gate, ranking) {
|
|
895
|
+
const { prefix } = options;
|
|
452
896
|
const servers = options.servers ?? [];
|
|
453
897
|
if (args.tool !== undefined) {
|
|
454
898
|
// The meta-tools are visible schemas like any other and can match a
|
|
@@ -475,56 +919,105 @@ export function buildMcpListResult(args, schemas, options, gate) {
|
|
|
475
919
|
// On-demand expansion: the FULL description and schema, not truncated.
|
|
476
920
|
return { tool: { name: schema.name, description: schema.description, parameters: schema.parameters } };
|
|
477
921
|
}
|
|
478
|
-
const
|
|
479
|
-
//
|
|
480
|
-
|
|
922
|
+
const { candidates, gatedAway } = collectCatalogCandidates(schemas, options, args, gate);
|
|
923
|
+
// Model-explicit relevance search: a ranked SHORTLIST, self-describing
|
|
924
|
+
// through ranked/total/note. The only path that consults a ranking.
|
|
925
|
+
if (args.query !== undefined) {
|
|
926
|
+
const pre = queryRankingFrom(args.query, args, options, gate, { candidates, gatedAway });
|
|
927
|
+
if (pre.error !== undefined)
|
|
928
|
+
return { error: pre.error };
|
|
929
|
+
return buildRankedCatalog(args.query, candidates, options, ranking);
|
|
930
|
+
}
|
|
931
|
+
const serversOut = groupCatalogEntries(candidates, prefix);
|
|
932
|
+
if (serversOut.length === 0)
|
|
933
|
+
return emptyCatalogError(args, options, gate, gatedAway);
|
|
934
|
+
return { servers: serversOut };
|
|
935
|
+
}
|
|
936
|
+
/** The near-miss pool cap — the same batch ceiling the jev seams use. */
|
|
937
|
+
export const DID_YOU_MEAN_POOL_LIMIT = RANK_CANDIDATE_LIMIT;
|
|
938
|
+
/**
|
|
939
|
+
* The visible tools a near-miss may point at: this scope's prefix-matching
|
|
940
|
+
* MCP tools under the servers whitelist, minus the meta-tools and minus
|
|
941
|
+
* disabled servers (dispatch would refuse those anyway, so naming one would
|
|
942
|
+
* be advice the model cannot take).
|
|
943
|
+
*
|
|
944
|
+
* @param schemas - schemas visible to the calling agent.
|
|
945
|
+
* @param options - adapter knobs (prefix/servers).
|
|
946
|
+
* @param gate - live disabled-server snapshot (default: none disabled).
|
|
947
|
+
* @returns the pool, in registry order.
|
|
948
|
+
*/
|
|
949
|
+
export function didYouMeanPool(schemas, options, gate) {
|
|
950
|
+
const servers = options.servers ?? [];
|
|
951
|
+
const pool = [];
|
|
481
952
|
for (const schema of schemas) {
|
|
482
|
-
// Never catalog the meta-tools themselves, even under a prefix that
|
|
483
|
-
// matches their names.
|
|
484
953
|
if (schema.name === MCP_LIST_TOOL_NAME || schema.name === MCP_CALL_TOOL_NAME)
|
|
485
954
|
continue;
|
|
486
|
-
if (!schema.name.startsWith(prefix))
|
|
487
|
-
continue;
|
|
488
|
-
if (!isAllowedServer(schema.name, prefix, servers))
|
|
955
|
+
if (!schema.name.startsWith(options.prefix))
|
|
489
956
|
continue;
|
|
490
|
-
|
|
491
|
-
// Disabled servers vanish from the directory entirely; point-name
|
|
492
|
-
// expansion above remains the structured way to learn why.
|
|
493
|
-
if (isServerDisabled(server, gate)) {
|
|
494
|
-
if (!gatedAway.includes(server))
|
|
495
|
-
gatedAway.push(server);
|
|
957
|
+
if (!isAllowedServer(schema.name, options.prefix, servers))
|
|
496
958
|
continue;
|
|
497
|
-
|
|
498
|
-
if (args.server !== undefined && server !== args.server)
|
|
959
|
+
if (isServerDisabled(serverOfToolName(schema.name, options.prefix), gate))
|
|
499
960
|
continue;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
961
|
+
pool.push({ name: schema.name, description: schema.description });
|
|
962
|
+
}
|
|
963
|
+
return pool;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* The unknown-tool error value, with a did-you-mean suffix when the pool can
|
|
967
|
+
* offer one.
|
|
968
|
+
*
|
|
969
|
+
* The base sentence is the pre-seam message, byte for byte — the suggestion is
|
|
970
|
+
* strictly an addition, and it always names its own provenance (an unranked
|
|
971
|
+
* lexical guess must not read like a ranked one). Every seam failure path
|
|
972
|
+
* ends on that base sentence.
|
|
973
|
+
*
|
|
974
|
+
* @param tool - the unresolved tool name.
|
|
975
|
+
* @param prefix - the configured MCP prefix (excluded from shared-token
|
|
976
|
+
* matching: every pool member carries it).
|
|
977
|
+
* @param hook - the near-miss seam, if the caller wired one.
|
|
978
|
+
* @returns the structured error value.
|
|
979
|
+
*/
|
|
980
|
+
async function unknownToolError(tool, prefix, hook) {
|
|
981
|
+
const base = `tool "${tool}" is not registered or not visible in this scope — call ${MCP_LIST_TOOL_NAME} to see the catalog`;
|
|
982
|
+
if (hook === undefined)
|
|
983
|
+
return { error: base };
|
|
984
|
+
let pool;
|
|
985
|
+
try {
|
|
986
|
+
pool = hook.candidates();
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
return { error: base };
|
|
510
990
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
991
|
+
let candidates;
|
|
992
|
+
try {
|
|
993
|
+
// The fold prefix is excluded from the shared-token test: every candidate
|
|
994
|
+
// carries it, so it is not evidence of a near-miss.
|
|
995
|
+
candidates = didYouMeanCandidates(tool, pool.map(entry => entry.name), DID_YOU_MEAN_POOL_LIMIT, tokenize(prefix)).map(entry => entry.name);
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
return { error: base };
|
|
999
|
+
}
|
|
1000
|
+
if (candidates.length === 0)
|
|
1001
|
+
return { error: base };
|
|
1002
|
+
let source = 'lexical';
|
|
1003
|
+
// More than one near-miss is the only case where ranking can add anything;
|
|
1004
|
+
// a single lexical hit needs no remote call at all.
|
|
1005
|
+
if (candidates.length > 1 && hook.rerank !== undefined) {
|
|
1006
|
+
try {
|
|
1007
|
+
const order = await hook.rerank(tool, pool.filter(entry => candidates.includes(entry.name)));
|
|
1008
|
+
// An answer that names nothing we asked about is not an opinion: the
|
|
1009
|
+
// lexical order and its label stand.
|
|
1010
|
+
const known = order === undefined ? [] : order.filter(name => candidates.includes(name));
|
|
1011
|
+
if (known.length > 0) {
|
|
1012
|
+
candidates = mergeNameOrder(candidates, known);
|
|
1013
|
+
source = 'jev';
|
|
1014
|
+
}
|
|
515
1015
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
// filtered path above): name each hidden server with its restore hint.
|
|
519
|
-
if (args.server === undefined && gatedAway.length > 0) {
|
|
520
|
-
const named = gatedAway.map(name => `"${name}" ("/mcp enable ${String(gate?.serverIds[name])}")`).join(', ');
|
|
521
|
-
return { error: `every MCP tool under the "${prefix}" prefix belongs to a disabled server and is hidden from the catalog: ${named}` };
|
|
1016
|
+
catch {
|
|
1017
|
+
// fail-open: the lexical order stands
|
|
522
1018
|
}
|
|
523
|
-
return args.server === undefined
|
|
524
|
-
? { error: `no MCP tools are registered under the "${prefix}" prefix` }
|
|
525
|
-
: { error: `no MCP tools match server "${args.server}"` };
|
|
526
1019
|
}
|
|
527
|
-
return {
|
|
1020
|
+
return { error: base + didYouMeanMessage(candidates.slice(0, DID_YOU_MEAN_LIMIT), source) };
|
|
528
1021
|
}
|
|
529
1022
|
/**
|
|
530
1023
|
* Dispatch one `mcp_call` to the resolved child tool.
|
|
@@ -552,6 +1045,11 @@ export function buildMcpListResult(args, schemas, options, gate) {
|
|
|
552
1045
|
* Errors never throw out of dispatch: a child rejection (or raced timeout)
|
|
553
1046
|
* is wrapped into a structured `{ error }` the model can correct and retry.
|
|
554
1047
|
*
|
|
1048
|
+
* The unknown-tool branch additionally offers near-misses (seam C): a lexical
|
|
1049
|
+
* prefilter over the scope's visible tools always answers, and an optional
|
|
1050
|
+
* async rerank (production: System One) can reorder it. The base error text
|
|
1051
|
+
* is unchanged, so this stays a pure addition to the message.
|
|
1052
|
+
*
|
|
555
1053
|
* @param args - raw model arguments (`{ tool, arguments? }`, validated here).
|
|
556
1054
|
* @param prefix - the configured MCP prefix.
|
|
557
1055
|
* @param resolve - scope-aware tool resolver (production: `ctx.tools.get`).
|
|
@@ -561,9 +1059,11 @@ export function buildMcpListResult(args, schemas, options, gate) {
|
|
|
561
1059
|
* disabled branch sits after the prefix/whitelist validation: a disabled
|
|
562
1060
|
* server's tools are refused with an explicit `/mcp enable <id>` hint
|
|
563
1061
|
* before the child is ever resolved.
|
|
1062
|
+
* @param didYouMean - the near-miss seam for the unknown-tool branch
|
|
1063
|
+
* (default: none — the pre-seam message, unchanged).
|
|
564
1064
|
* @returns the child's resolved value verbatim, or a structured error.
|
|
565
1065
|
*/
|
|
566
|
-
export async function dispatchMcpCall(args, prefix, resolve, exec, servers = [], gate) {
|
|
1066
|
+
export async function dispatchMcpCall(args, prefix, resolve, exec, servers = [], gate, didYouMean) {
|
|
567
1067
|
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
|
|
568
1068
|
return { error: `${MCP_CALL_TOOL_NAME} expects an object { "tool": string, "arguments"?: object }` };
|
|
569
1069
|
}
|
|
@@ -588,7 +1088,7 @@ export async function dispatchMcpCall(args, prefix, resolve, exec, servers = [],
|
|
|
588
1088
|
}
|
|
589
1089
|
const definition = resolve(tool);
|
|
590
1090
|
if (definition === undefined) {
|
|
591
|
-
return
|
|
1091
|
+
return unknownToolError(tool, prefix, didYouMean);
|
|
592
1092
|
}
|
|
593
1093
|
try {
|
|
594
1094
|
// `?? {}` lets a missing/null arguments object reach the server, which
|
|
@@ -625,7 +1125,7 @@ async function raceToolTimeout(definition, timeoutMs, args, exec) {
|
|
|
625
1125
|
}
|
|
626
1126
|
/** Usage text returned for unparseable /mcp invocations. */
|
|
627
1127
|
export const MCP_COMMAND_USAGE = [
|
|
628
|
-
'Usage: /mcp [list [server|tool] | config | disable <id> | enable <id>]',
|
|
1128
|
+
'Usage: /mcp [list [server|tool] | config | suggest | disable <id> | enable <id>]',
|
|
629
1129
|
'',
|
|
630
1130
|
'/mcp tree of MCP servers and tools, plus folding health',
|
|
631
1131
|
'/mcp list same as /mcp',
|
|
@@ -634,6 +1134,8 @@ export const MCP_COMMAND_USAGE = [
|
|
|
634
1134
|
' server name or a full registered tool name)',
|
|
635
1135
|
'/mcp config effective adapter configuration with per-pattern and',
|
|
636
1136
|
' per-server tool matches (read-only)',
|
|
1137
|
+
'/mcp suggest which folded tools recorded usage says are worth keeping',
|
|
1138
|
+
' resident in every prompt (read-only; changes nothing)',
|
|
637
1139
|
'/mcp disable <id> fold one server\'s tools out of every prompt until it is',
|
|
638
1140
|
' enabled again (<id> is the stable number shown by "/mcp";',
|
|
639
1141
|
' keep and servers exemptions included)',
|
|
@@ -646,9 +1148,10 @@ function parsePositiveInt(token) {
|
|
|
646
1148
|
/**
|
|
647
1149
|
* Parse one /mcp raw input into its form. `''` and `'list'` are the tree
|
|
648
1150
|
* overview; `'list <name>'` is a server/tool detail; `'config'` shows the
|
|
649
|
-
* effective configuration; `'
|
|
650
|
-
*
|
|
651
|
-
*
|
|
1151
|
+
* effective configuration; `'suggest'` shows the current keep suggestions;
|
|
1152
|
+
* `'disable <n>'` / `'enable <n>'` are whole-server toggles (n is the stable id
|
|
1153
|
+
* shown by `/mcp`, so a bare `0` or non-numeric token is already usage).
|
|
1154
|
+
* Everything else parses to the usage error.
|
|
652
1155
|
* @param rawInput - exact text following the command name.
|
|
653
1156
|
* @returns the normalized command form.
|
|
654
1157
|
*/
|
|
@@ -665,6 +1168,8 @@ export function parseMcpCommandInput(rawInput) {
|
|
|
665
1168
|
return { form: 'usage' };
|
|
666
1169
|
case 'config':
|
|
667
1170
|
return parts.length === 1 ? { form: 'config' } : { form: 'usage' };
|
|
1171
|
+
case 'suggest':
|
|
1172
|
+
return parts.length === 1 ? { form: 'suggest' } : { form: 'usage' };
|
|
668
1173
|
case 'disable':
|
|
669
1174
|
case 'enable': {
|
|
670
1175
|
const id = parts.length === 2 ? parsePositiveInt(parts[1]) : undefined;
|
|
@@ -924,6 +1429,48 @@ export function renderMcpDetail(name, schemas, options) {
|
|
|
924
1429
|
}
|
|
925
1430
|
return { outcome: 'tool', text: capRenderedLines(text) };
|
|
926
1431
|
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Render the keep-suggestion view (`/mcp suggest`): the current batch of
|
|
1434
|
+
* System One verdicts about which FOLDED tools earned a permanent place in
|
|
1435
|
+
* every prompt, each with its probability, the usage evidence behind it, and
|
|
1436
|
+
* the exact `keep` pattern the operator would write.
|
|
1437
|
+
*
|
|
1438
|
+
* Strictly a DISPLAY (red line ③): nothing here writes keep, prefix or the
|
|
1439
|
+
* gate — the operator decides, one settings-page edit, with the same manual
|
|
1440
|
+
* authority they have over every other knob.
|
|
1441
|
+
*
|
|
1442
|
+
* Every state is explained rather than blank: jev off, jev on with no batch
|
|
1443
|
+
* yet, and jev on with rows. Output is line-capped defensively.
|
|
1444
|
+
*
|
|
1445
|
+
* @param suggestions - the current batch (production: suggestions.json).
|
|
1446
|
+
* @param options - adapter knobs; `jev.jevEnabled` and `usage` are read.
|
|
1447
|
+
* @returns the rendered view.
|
|
1448
|
+
*/
|
|
1449
|
+
export function renderMcpSuggest(suggestions, options) {
|
|
1450
|
+
const totalCalls = options.usage?.totalCalls ?? 0;
|
|
1451
|
+
const toolCount = options.usage?.toolCount ?? 0;
|
|
1452
|
+
const enabled = options.jev?.jevEnabled === true;
|
|
1453
|
+
const lines = ['keep suggestions — System One verdicts on folded tools (read-only)'];
|
|
1454
|
+
if (!enabled) {
|
|
1455
|
+
lines.push('', 'decision support is OFF — set jevEnabled = true in the mcp-adapter settings to collect suggestions.', 'usage is still being recorded, so the first batch can run as soon as it is turned on.');
|
|
1456
|
+
}
|
|
1457
|
+
if (suggestions.length === 0) {
|
|
1458
|
+
lines.push('', `no suggestions yet — ${totalCalls} call(s) recorded, threshold is ${SUGGEST_BATCH_EVERY}`, enabled
|
|
1459
|
+
? `a batch runs every ${SUGGEST_BATCH_EVERY} calls over the folded tools that were actually used`
|
|
1460
|
+
: 'usage keeps accumulating while the switch is off');
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
const rows = [...suggestions].sort((left, right) => right.probability - left.probability || left.tool.localeCompare(right.tool));
|
|
1464
|
+
const newest = rows.reduce((latest, row) => (row._at > latest ? row._at : latest), '');
|
|
1465
|
+
lines.push('', `${rows.length} suggestion(s) from the last batch${newest === '' ? '' : ` (${newest})`}`);
|
|
1466
|
+
for (const row of rows) {
|
|
1467
|
+
lines.push('', row.tool, ` p=${row.probability.toFixed(2)} (${row.band})${row.basis === '' ? '' : ` — ${row.basis}`}`, ` to keep it resident: ${keepSuggestionFragment(row.tool)}`);
|
|
1468
|
+
}
|
|
1469
|
+
lines.push('', 'bands: adopt = worth keeping resident, record = marginal, fallback = keep it folded.', 'display only — nothing was written to keep, prefix, or the server gate.');
|
|
1470
|
+
}
|
|
1471
|
+
lines.push('', `usage: ${totalCalls} call(s) across ${toolCount} tool(s); batch cadence ${SUGGEST_BATCH_EVERY} calls`);
|
|
1472
|
+
return capRenderedLines(lines.join('\n'));
|
|
1473
|
+
}
|
|
927
1474
|
/**
|
|
928
1475
|
* Render the effective configuration with each knob's hitting tools:
|
|
929
1476
|
* which names match the prefix, what each keep pattern holds back, and what
|
|
@@ -983,6 +1530,15 @@ export function renderMcpConfig(schemas, options) {
|
|
|
983
1530
|
if (idCapNotice !== undefined)
|
|
984
1531
|
lines.push('', idCapNotice);
|
|
985
1532
|
}
|
|
1533
|
+
// System One decision support — only rendered when the caller wired it, so
|
|
1534
|
+
// a pre-jev view stays byte-identical.
|
|
1535
|
+
if (options.jev !== undefined) {
|
|
1536
|
+
const jev = options.jev;
|
|
1537
|
+
lines.push('', 'System One decision support (hot-reloads from the settings page; every value is read per use):', ` jevEnabled ${jev.jevEnabled}${jev.jevEnabled ? '' : ' (off = no decision calls at all)'}`, ` jevBackend ${jev.jevBackend}`, ` jevModel ${jev.jevModel === '' ? '(backend default)' : jev.jevModel}`, ` jevTimeoutMs ${jev.jevTimeoutMs}`, ` jevSecretFile ${jev.jevSecretFile === '' ? '(none)' : jev.jevSecretFile}`, ` jevLayaFallback ${jev.jevLayaFallback}`, ` jevLayaUrl ${jev.jevLayaUrl === '' ? '(default)' : jev.jevLayaUrl}`);
|
|
1538
|
+
if (options.usage !== undefined) {
|
|
1539
|
+
lines.push(` recorded usage ${options.usage.totalCalls} call(s) across ${options.usage.toolCount} tool(s)`, ` (keep-suggestion batches run every ${SUGGEST_BATCH_EVERY} calls — "/mcp suggest")`);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
986
1542
|
return capRenderedLines(lines.join('\n'));
|
|
987
1543
|
}
|
|
988
1544
|
/**
|
|
@@ -1039,6 +1595,8 @@ export async function executeMcpCommand(view) {
|
|
|
1039
1595
|
}
|
|
1040
1596
|
case 'config':
|
|
1041
1597
|
return { kind: 'success', text: renderMcpConfig(effectiveView.schemas, effectiveView.config) };
|
|
1598
|
+
case 'suggest':
|
|
1599
|
+
return { kind: 'success', text: renderMcpSuggest(effectiveView.suggestions ?? [], effectiveView.config) };
|
|
1042
1600
|
case 'disable':
|
|
1043
1601
|
case 'enable':
|
|
1044
1602
|
return runServerToggle(parsed.form, parsed.id, registry, effectiveView);
|
|
@@ -1255,11 +1813,18 @@ export function delegateFinalizeContent(metaArgs, result, exec, resolveChild) {
|
|
|
1255
1813
|
}
|
|
1256
1814
|
/**
|
|
1257
1815
|
* Build the `mcp_list` meta-tool definition.
|
|
1258
|
-
*
|
|
1259
|
-
*
|
|
1260
|
-
*
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1816
|
+
*
|
|
1817
|
+
* The execute layer is where the async seam lives: a no-argument call is the
|
|
1818
|
+
* pre-seim builder verbatim (no promise, no remote), and only a `query` call
|
|
1819
|
+
* consults `options.rankQuery` — and even then only AFTER a candidate pool
|
|
1820
|
+
* exists, so a disabled/failed rerank changes the ORDER and nothing else.
|
|
1821
|
+
*
|
|
1822
|
+
* @param options - catalog knobs (prefix, description limit, optional
|
|
1823
|
+
* rankQuery). MAY be a live options object (plain getters): the factory
|
|
1824
|
+
* reads `prefix`, `servers`, `descriptionLimit`, `getGate` and
|
|
1825
|
+
* `rankQuery` through the object on EVERY execution, so volatile config can
|
|
1826
|
+
* be wired by reference — only the description text freezes at factory
|
|
1827
|
+
* time.
|
|
1263
1828
|
* @param listSchemas - visible-schemas source (production: `ctx.tools.schemas`,
|
|
1264
1829
|
* called with the calling agent so restrictions are respected).
|
|
1265
1830
|
* @returns the complete tool definition.
|
|
@@ -1273,6 +1838,7 @@ export function createMcpListTool(options, listSchemas) {
|
|
|
1273
1838
|
`With no arguments: a compact catalog grouped by server — full tool names (like "${prefix}<server>__<tool>") plus short descriptions.`,
|
|
1274
1839
|
`Pass { "tool": "<full name>" } to expand that tool's complete input schema on demand.`,
|
|
1275
1840
|
'Pass { "server": "<name>" } to filter the catalog to one server.',
|
|
1841
|
+
'Pass { "query": "<what you want to do>" } to get a relevance-ranked shortlist of the tools that match a task — the answer says how many matched in total, and the unfiltered catalog is always one no-argument call away.',
|
|
1276
1842
|
'Pass { "verbose": true } to inline every tool\'s full schema (expensive — request it only when you actually need many schemas).',
|
|
1277
1843
|
`Workflow: call ${MCP_LIST_TOOL_NAME} to discover a tool and its schema, then invoke it with ${MCP_CALL_TOOL_NAME}.`,
|
|
1278
1844
|
].join(' '),
|
|
@@ -1281,33 +1847,70 @@ export function createMcpListTool(options, listSchemas) {
|
|
|
1281
1847
|
properties: {
|
|
1282
1848
|
tool: { type: 'string', description: 'Full registered tool name whose schema to expand' },
|
|
1283
1849
|
server: { type: 'string', description: 'Only list tools of this server' },
|
|
1850
|
+
query: { type: 'string', description: 'Optional relevance search over names and descriptions; returns a ranked shortlist instead of the whole catalog' },
|
|
1284
1851
|
verbose: { type: 'boolean', description: 'Inline every tool\'s full schema' },
|
|
1285
1852
|
},
|
|
1286
1853
|
additionalProperties: false,
|
|
1287
1854
|
},
|
|
1288
1855
|
output: {
|
|
1289
1856
|
// Accepts every canonical result shape: { servers }, { tool }, { error }.
|
|
1857
|
+
// The ranked subset adds query/ranked/total/note; they are absent from
|
|
1858
|
+
// an unranked catalog, so `additionalProperties: false` still holds.
|
|
1290
1859
|
schema: {
|
|
1291
1860
|
type: 'object',
|
|
1292
1861
|
properties: {
|
|
1293
1862
|
servers: { type: 'array', items: {} },
|
|
1294
1863
|
tool: {},
|
|
1864
|
+
query: { type: 'string' },
|
|
1865
|
+
ranked: { type: 'boolean' },
|
|
1866
|
+
total: { type: 'number' },
|
|
1867
|
+
note: { type: 'string' },
|
|
1295
1868
|
error: { type: 'string' },
|
|
1296
1869
|
},
|
|
1297
1870
|
additionalProperties: false,
|
|
1298
1871
|
},
|
|
1299
1872
|
render: (_args, value) => renderValue(value),
|
|
1300
1873
|
},
|
|
1301
|
-
execute: (args, exec) => {
|
|
1874
|
+
execute: async (args, exec) => {
|
|
1302
1875
|
const query = normalizeMcpListArgs(args);
|
|
1303
1876
|
// The calling agent's view: restricted-away tools stay out of the catalog.
|
|
1304
1877
|
const schemas = listSchemas(exec.agent);
|
|
1305
|
-
|
|
1878
|
+
const gate = options.getGate?.();
|
|
1879
|
+
// No query: the pre-seim path, byte-identical, and no jev call at all.
|
|
1880
|
+
if (query.query === undefined)
|
|
1881
|
+
return buildMcpListResult(query, schemas, options, gate);
|
|
1882
|
+
const pre = listQueryRanking(query, schemas, options, gate);
|
|
1883
|
+
if (pre.error !== undefined)
|
|
1884
|
+
return { error: pre.error };
|
|
1885
|
+
// Reranking is advisory: a missing hook, a disabled switch, a timeout, a
|
|
1886
|
+
// bad answer — or a hook that throws outright — all leave `order`
|
|
1887
|
+
// undefined, and the lexical order stands.
|
|
1888
|
+
//
|
|
1889
|
+
// The pool handed over is the LEXICAL shortlist when the prefilter found
|
|
1890
|
+
// anything and the whole visible catalog when it found nothing (the
|
|
1891
|
+
// `semantic` fallback) — one call either way, because a semantic
|
|
1892
|
+
// reranker is the only thing that can bridge a Chinese query and an
|
|
1893
|
+
// English description, and it is free to say "no opinion".
|
|
1894
|
+
let order;
|
|
1895
|
+
try {
|
|
1896
|
+
order = await options.rankQuery?.(query.query, pre.pool);
|
|
1897
|
+
}
|
|
1898
|
+
catch {
|
|
1899
|
+
order = undefined;
|
|
1900
|
+
}
|
|
1901
|
+
return buildMcpListResult(query, schemas, options, gate, {
|
|
1902
|
+
query: query.query,
|
|
1903
|
+
pool: pre.pool.map(candidate => candidate.name),
|
|
1904
|
+
total: pre.total,
|
|
1905
|
+
...pre.semantic === true ? { semantic: true } : {},
|
|
1906
|
+
...order === undefined ? {} : { order },
|
|
1907
|
+
});
|
|
1306
1908
|
},
|
|
1307
1909
|
};
|
|
1308
1910
|
}
|
|
1309
1911
|
/**
|
|
1310
1912
|
* Build the `mcp_call` meta-tool definition.
|
|
1913
|
+
*
|
|
1311
1914
|
* @param prefix - the configured MCP prefix (dispatch boundary). A thunk is
|
|
1312
1915
|
* accepted so volatile config is re-read on every dispatch; a plain string
|
|
1313
1916
|
* freezes the boundary at factory time.
|
|
@@ -1319,9 +1922,10 @@ export function createMcpListTool(options, listSchemas) {
|
|
|
1319
1922
|
* gate store's current mirror; default/omitted: nothing is disabled).
|
|
1320
1923
|
* Deliberately a callback — the definition object outlives any one gate
|
|
1321
1924
|
* snapshot and must observe later /mcp enable/disable writes.
|
|
1925
|
+
* @param hooks - the usage/did-you-mean seams (default: none wired).
|
|
1322
1926
|
* @returns the complete tool definition.
|
|
1323
1927
|
*/
|
|
1324
|
-
export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
|
|
1928
|
+
export function createMcpCallTool(prefix, resolve, servers = [], getGate, hooks) {
|
|
1325
1929
|
const prefixNow = typeof prefix === 'function' ? prefix : () => prefix;
|
|
1326
1930
|
const serversNow = typeof servers === 'function' ? servers : () => servers;
|
|
1327
1931
|
return {
|
|
@@ -1350,7 +1954,39 @@ export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
|
|
|
1350
1954
|
schema: {},
|
|
1351
1955
|
render: (args, value) => renderDispatched(args, value, name => resolve(name)),
|
|
1352
1956
|
},
|
|
1353
|
-
execute: (args, exec) =>
|
|
1957
|
+
execute: async (args, exec) => {
|
|
1958
|
+
// The near-miss hook is bound to the CALLING scope here, so dispatch
|
|
1959
|
+
// stays scope-agnostic and the pool it ranks is exactly what the model
|
|
1960
|
+
// could have called.
|
|
1961
|
+
const pool = hooks?.didYouMean === undefined
|
|
1962
|
+
? undefined
|
|
1963
|
+
: () => {
|
|
1964
|
+
try {
|
|
1965
|
+
return hooks.didYouMean?.(exec.agent) ?? [];
|
|
1966
|
+
}
|
|
1967
|
+
catch {
|
|
1968
|
+
return [];
|
|
1969
|
+
}
|
|
1970
|
+
};
|
|
1971
|
+
const value = await dispatchMcpCall(args, prefixNow(), name => resolve(name, exec.agent),
|
|
1972
|
+
// exec is forwarded VERBATIM — same object identity, which is what the
|
|
1973
|
+
// child's WeakMap projection lookup below depends on.
|
|
1974
|
+
exec, serversNow(), getGate?.(), pool === undefined ? undefined : {
|
|
1975
|
+
candidates: pool,
|
|
1976
|
+
...hooks?.didYouMeanRank === undefined
|
|
1977
|
+
? {}
|
|
1978
|
+
: { rerank: (tool, candidates) => hooks.didYouMeanRank?.(exec.agent, tool, candidates) ?? Promise.resolve(undefined) },
|
|
1979
|
+
});
|
|
1980
|
+
if (hooks?.onDispatched !== undefined) {
|
|
1981
|
+
try {
|
|
1982
|
+
hooks.onDispatched({ tool: childToolName(args), error: isStructuredError(value) });
|
|
1983
|
+
}
|
|
1984
|
+
catch {
|
|
1985
|
+
// contained — telemetry never breaks a dispatch
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
return value;
|
|
1989
|
+
},
|
|
1354
1990
|
// Restore child-owned projections (image attachments): the registry
|
|
1355
1991
|
// invokes THIS definition's finalizer, so it must forward to the child's
|
|
1356
1992
|
// with the same exec object dispatch passed to child.execute.
|
|
@@ -1372,11 +2008,19 @@ export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
|
|
|
1372
2008
|
* unreadable document degrades to the empty gate (everything enabled) at
|
|
1373
2009
|
* load time, keeping the assemble waterfall alive.
|
|
1374
2010
|
*
|
|
1375
|
-
* Config volatility: every knob (prefix/keep/servers/descriptionLimit
|
|
1376
|
-
* volatile reference — 0.1.7 settings-page writes swap
|
|
1377
|
-
* WITHOUT remounting the plugin, so nothing captured at apply
|
|
1378
|
-
* stale: the waterfall, both meta-tools and the /mcp command all
|
|
1379
|
-
* through {@link cfgNow} per use.
|
|
2011
|
+
* Config volatility: every knob (prefix/keep/servers/descriptionLimit AND the
|
|
2012
|
+
* seven jev* keys) is a volatile reference — 0.1.7 settings-page writes swap
|
|
2013
|
+
* values in place WITHOUT remounting the plugin, so nothing captured at apply
|
|
2014
|
+
* time may go stale: the waterfall, both meta-tools and the /mcp command all
|
|
2015
|
+
* re-read through {@link cfgNow} / {@link jevEnv} per use.
|
|
2016
|
+
*
|
|
2017
|
+
* System One wiring (the only remote calls in this plugin): the decision log,
|
|
2018
|
+
* the usage mirror and the suggestion document all live in the SAME gate
|
|
2019
|
+
* directory as gate.json. The seams are wired to exactly three places — the
|
|
2020
|
+
* background keep-suggestion batch ({@link maybeSuggestBatch}, fired by the
|
|
2021
|
+
* usage counter and awaited by nobody), `mcp_list`'s model-explicit `query`,
|
|
2022
|
+
* and `mcp_call`'s unknown-tool branch. Folding, an unqueried catalog, and a
|
|
2023
|
+
* successful dispatch touch none of them.
|
|
1380
2024
|
*
|
|
1381
2025
|
* Commands: `/mcp` mounts through a runtime `ctx.inject(['commands'], ...)`,
|
|
1382
2026
|
* so hosts without the commands service keep this plugin fully functional —
|
|
@@ -1397,10 +2041,100 @@ export function apply(ctx, config) {
|
|
|
1397
2041
|
// settings namespace is gone; volatile fields are for user-facing values,
|
|
1398
2042
|
// which a machine id registry must never be). Reads come from the mirror,
|
|
1399
2043
|
// writes go through atomically; the storage dir is read once — moving it
|
|
1400
|
-
// takes effect on the next plugin restart.
|
|
1401
|
-
|
|
2044
|
+
// takes effect on the next plugin restart. On a fresh gate (no gate.json)
|
|
2045
|
+
// the store absorbs the pre-0.1.7 `mcp-adapter:` settings state once —
|
|
2046
|
+
// fully contained, audit marker under storages/mcp-adapter.
|
|
2047
|
+
const gateDir = resolveGateDir(config.storageDir.get());
|
|
2048
|
+
const warn = (message) => ctx.logger.warn(message);
|
|
2049
|
+
const store = createFileGateStore(gateDir, warn, {
|
|
2050
|
+
info: message => ctx.logger.info?.(message),
|
|
2051
|
+
home: resolveDshHome(),
|
|
2052
|
+
});
|
|
1402
2053
|
const getGate = () => store.get();
|
|
1403
2054
|
const writeGate = (next) => store.write(next);
|
|
2055
|
+
// ---- System One (jev) ----
|
|
2056
|
+
//
|
|
2057
|
+
// Three durable artifacts, one directory: decisions.jsonl (telemetry, one
|
|
2058
|
+
// row per call INCLUDING failures), usage.json (per-tool counters, the
|
|
2059
|
+
// input to the suggestion batch) and suggestions.json (the last batch, the
|
|
2060
|
+
// thing /mcp suggest renders). All three degrade to "no data" rather than
|
|
2061
|
+
// to an error.
|
|
2062
|
+
const usage = createUsageStore(gateDir, warn);
|
|
2063
|
+
const decisionLog = createDecisionLog(gateDir);
|
|
2064
|
+
// Every value is read per call: turning jevEnabled on in the settings page
|
|
2065
|
+
// takes effect on the very next dispatch, with no restart.
|
|
2066
|
+
const jevEnv = {
|
|
2067
|
+
enabled: () => config.jevEnabled.get() === true,
|
|
2068
|
+
config: () => ({
|
|
2069
|
+
jevBackend: config.jevBackend.get(),
|
|
2070
|
+
jevModel: config.jevModel.get(),
|
|
2071
|
+
jevTimeoutMs: config.jevTimeoutMs.get(),
|
|
2072
|
+
jevSecretFile: config.jevSecretFile.get(),
|
|
2073
|
+
jevLayaFallback: config.jevLayaFallback.get() === true,
|
|
2074
|
+
jevLayaUrl: config.jevLayaUrl.get(),
|
|
2075
|
+
}),
|
|
2076
|
+
log: () => decisionLog,
|
|
2077
|
+
};
|
|
2078
|
+
const jevNow = () => ({
|
|
2079
|
+
jevEnabled: jevEnv.enabled(),
|
|
2080
|
+
jevBackend: config.jevBackend.get(),
|
|
2081
|
+
jevModel: config.jevModel.get(),
|
|
2082
|
+
jevTimeoutMs: config.jevTimeoutMs.get(),
|
|
2083
|
+
jevSecretFile: config.jevSecretFile.get(),
|
|
2084
|
+
jevLayaFallback: config.jevLayaFallback.get() === true,
|
|
2085
|
+
jevLayaUrl: config.jevLayaUrl.get(),
|
|
2086
|
+
});
|
|
2087
|
+
const usageNow = () => ({
|
|
2088
|
+
totalCalls: usage.total(),
|
|
2089
|
+
toolCount: usageToolCount(usage.get()),
|
|
2090
|
+
});
|
|
2091
|
+
/**
|
|
2092
|
+
* The lazy keep-suggestion batch (seam A). Fired from the usage hook on
|
|
2093
|
+
* every multiple of {@link SUGGEST_BATCH_EVERY} total calls, with jev
|
|
2094
|
+
* enabled. Strictly fire-and-forget: the dispatch that crossed the
|
|
2095
|
+
* threshold returns before any of this runs, and nothing here can fail it.
|
|
2096
|
+
*
|
|
2097
|
+
* The in-flight guard drops a crossing that lands while a batch is still
|
|
2098
|
+
* running — deliberately: the next crossing re-asks with fresher stats, and
|
|
2099
|
+
* overlapping batches would spend the same remote budget twice for the same
|
|
2100
|
+
* question.
|
|
2101
|
+
*/
|
|
2102
|
+
let suggestInFlight = false;
|
|
2103
|
+
let suggestWarned = false;
|
|
2104
|
+
function maybeSuggestBatch(totalCalls) {
|
|
2105
|
+
try {
|
|
2106
|
+
if (suggestInFlight || !shouldRunSuggestBatch(totalCalls) || !jevEnv.enabled())
|
|
2107
|
+
return;
|
|
2108
|
+
const { prefix, keep, servers } = cfgNow();
|
|
2109
|
+
const candidates = usageSuggestCandidates(usage.get(), ctx.tools.schemas(), name => shouldFold(name, prefix, keep, servers, getGate()));
|
|
2110
|
+
if (candidates.length === 0)
|
|
2111
|
+
return;
|
|
2112
|
+
suggestInFlight = true;
|
|
2113
|
+
const run = (async () => {
|
|
2114
|
+
try {
|
|
2115
|
+
const records = await askKeepSuggestions(candidates, jevEnv);
|
|
2116
|
+
// A failed round (jev down, bad answers) leaves the previous batch
|
|
2117
|
+
// in place — the usage record is unaffected either way.
|
|
2118
|
+
if (records !== null)
|
|
2119
|
+
await writeSuggestions(gateDir, records);
|
|
2120
|
+
}
|
|
2121
|
+
catch (error) {
|
|
2122
|
+
if (!suggestWarned) {
|
|
2123
|
+
suggestWarned = true;
|
|
2124
|
+
warn(`mcp-adapter: the keep-suggestion batch could not be stored `
|
|
2125
|
+
+ `(${error instanceof Error ? error.message : String(error)}) — usage keeps recording`);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
finally {
|
|
2129
|
+
suggestInFlight = false;
|
|
2130
|
+
}
|
|
2131
|
+
})();
|
|
2132
|
+
void run.catch(() => undefined);
|
|
2133
|
+
}
|
|
2134
|
+
catch {
|
|
2135
|
+
// contained — the batch is a background nicety, never a dispatch concern
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
1404
2138
|
// Live options object: getters re-resolve volatile config per execution, so
|
|
1405
2139
|
// catalog/dispatch immediately follow settings-page edits (the list tool's
|
|
1406
2140
|
// description text is the only factory-time freeze, and it is cosmetic).
|
|
@@ -1409,8 +2143,21 @@ export function apply(ctx, config) {
|
|
|
1409
2143
|
get servers() { return cfgNow().servers; },
|
|
1410
2144
|
get descriptionLimit() { return cfgNow().descriptionLimit; },
|
|
1411
2145
|
getGate,
|
|
2146
|
+
// Seam B: only ever reached for an explicit `query` call.
|
|
2147
|
+
rankQuery: async (query, candidates) => rankListQuery(query, candidates, jevEnv),
|
|
1412
2148
|
}, scope => ctx.tools.schemas(scope));
|
|
1413
|
-
const callDefinition = createMcpCallTool(() => cfgNow().prefix, (name, scope) => ctx.tools.get(name, scope), () => cfgNow().servers, getGate
|
|
2149
|
+
const callDefinition = createMcpCallTool(() => cfgNow().prefix, (name, scope) => ctx.tools.get(name, scope), () => cfgNow().servers, getGate, {
|
|
2150
|
+
// Seam C: the pool is the CALLING scope's visible tools, built per
|
|
2151
|
+
// dispatch (restrictions and re-syncs are reflected immediately).
|
|
2152
|
+
didYouMean: scope => didYouMeanPool(ctx.tools.schemas(scope), cfgNow(), getGate()),
|
|
2153
|
+
didYouMeanRank: (_scope, tool, candidates) => rankDidYouMean(tool, candidates, jevEnv),
|
|
2154
|
+
// Usage accounting: every finished dispatch, success and failure alike.
|
|
2155
|
+
onDispatched: event => {
|
|
2156
|
+
if (event.tool === undefined)
|
|
2157
|
+
return;
|
|
2158
|
+
maybeSuggestBatch(usage.record(event.tool, event.error));
|
|
2159
|
+
},
|
|
2160
|
+
});
|
|
1414
2161
|
const disposers = [];
|
|
1415
2162
|
try {
|
|
1416
2163
|
disposers.push(ctx.tools.register(listDefinition));
|
|
@@ -1464,8 +2211,8 @@ export function apply(ctx, config) {
|
|
|
1464
2211
|
const commandDisposer = ccmds.commands.register({
|
|
1465
2212
|
name: MCP_COMMAND_NAME,
|
|
1466
2213
|
description: 'Show MCP status; disable/enable MCP servers',
|
|
1467
|
-
input: { hint: '[list [server|tool] | config | disable <id> | enable <id>]' },
|
|
1468
|
-
handler: invocation => {
|
|
2214
|
+
input: { hint: '[list [server|tool] | config | suggest | disable <id> | enable <id>]' },
|
|
2215
|
+
handler: async (invocation) => {
|
|
1469
2216
|
// The receiving agent's restricted view; an absent agent degrades to
|
|
1470
2217
|
// the global registry view (`schemas()` without scope).
|
|
1471
2218
|
const scope = invocation.agent ?? undefined;
|
|
@@ -1475,14 +2222,22 @@ export function apply(ctx, config) {
|
|
|
1475
2222
|
// ONE snapshot per execution keeps config.gate and view.gate identical;
|
|
1476
2223
|
// volatile knobs are re-read per invocation (live settings-page edits).
|
|
1477
2224
|
const gate = getGate();
|
|
2225
|
+
// The suggestion batch is read per invocation and ONLY for
|
|
2226
|
+
// `/mcp suggest` (it is rewritten in the background). A failed
|
|
2227
|
+
// read degrades to "no suggestions" — this is a display, it must
|
|
2228
|
+
// never fail the command.
|
|
2229
|
+
const suggestions = parseMcpCommandInput(invocation.rawInput).form === 'suggest'
|
|
2230
|
+
? await readSuggestions(gateDir, warn).catch(() => [])
|
|
2231
|
+
: [];
|
|
1478
2232
|
return executeMcpCommand({
|
|
1479
2233
|
rawInput: invocation.rawInput,
|
|
1480
2234
|
schemas,
|
|
1481
|
-
config: { ...cfgNow(), gate },
|
|
2235
|
+
config: { ...cfgNow(), gate, jev: jevNow(), usage: usageNow() },
|
|
1482
2236
|
metaToolsLive,
|
|
1483
2237
|
gate,
|
|
1484
2238
|
signal: invocation.signal,
|
|
1485
2239
|
writeGate,
|
|
2240
|
+
suggestions,
|
|
1486
2241
|
});
|
|
1487
2242
|
},
|
|
1488
2243
|
});
|