@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/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`). Unknown or non-MCP `tool`,
441
- * and any query that selects nothing, return a structured error — never a
442
- * throw — so the model can correct itself.
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, descriptionLimit } = options;
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 serversOut = [];
479
- // Servers that passed prefix/whitelist checks but were hidden by the gate.
480
- const gatedAway = [];
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
- const server = serverOfToolName(schema.name, prefix);
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
- let group = serversOut.find(entry => entry.server === server);
501
- if (group === undefined) {
502
- group = { server, tools: [] };
503
- serversOut.push(group);
504
- }
505
- group.tools.push({
506
- name: schema.name,
507
- description: truncateDescription(schema.description, descriptionLimit),
508
- ...args.verbose === true ? { parameters: schema.parameters } : {},
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
- if (serversOut.length === 0) {
512
- if (args.server !== undefined && isServerDisabled(args.server, gate)) {
513
- const id = gate?.serverIds[args.server];
514
- return { error: `server "${args.server}" is disabled and hidden from the catalog — restore it with "/mcp enable ${String(id)}"` };
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
- // An unfiltered catalog that found prefix tools but lost ALL of them to
517
- // the gate must not claim "nothing is registered" (same facts as the
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 { servers: serversOut };
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 { error: `tool "${tool}" is not registered or not visible in this scope — call ${MCP_LIST_TOOL_NAME} to see the catalog` };
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; `'disable <n>'` / `'enable <n>'` are whole-server
650
- * toggles (n is the stable id shown by `/mcp`, so a bare `0` or non-numeric
651
- * token is already usage). Everything else parses to the usage error.
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
- * @param options - catalog knobs (prefix, description limit). MAY be a live
1259
- * options object (plain getters): the factory reads `prefix`, `servers`,
1260
- * `descriptionLimit` and `getGate` through the object on EVERY execution,
1261
- * so volatile config can be wired by reference — only the description text
1262
- * freezes at factory time.
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
- return Promise.resolve(buildMcpListResult(query, schemas, options, options.getGate?.()));
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) => dispatchMcpCall(args, prefixNow(), name => resolve(name, exec.agent), exec, serversNow(), getGate?.()),
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) is a
1376
- * volatile reference — 0.1.7 settings-page writes swap values in place
1377
- * WITHOUT remounting the plugin, so nothing captured at apply time may go
1378
- * stale: the waterfall, both meta-tools and the /mcp command all re-read
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
- const store = createFileGateStore(resolveGateDir(config.storageDir.get()), message => ctx.logger.warn(message));
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
  });