@aiwayds/dsh-mcp-adapter 0.5.1 → 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
@@ -58,6 +58,15 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
58
58
  import { homedir } from 'node:os';
59
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
  /**
@@ -484,6 +493,9 @@ function absorbLegacyGate(current, dir, warn, options) {
484
493
  }
485
494
  }
486
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,
487
499
  prefix: z.string().pattern(PREFIX_PATTERN).default(DEFAULT_PREFIX).volatile(),
488
500
  keep: z.array(String).default([]).volatile(),
489
501
  servers: z.array(String).default([]).volatile(),
@@ -590,6 +602,11 @@ export function foldPromptAssembly(assembly, options, metaToolsLive, gate) {
590
602
  /**
591
603
  * Narrow arbitrary model arguments to the `mcp_list` shape, dropping
592
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
+ *
593
610
  * @param args - raw model arguments.
594
611
  * @returns the normalized catalog query.
595
612
  */
@@ -601,6 +618,7 @@ export function normalizeMcpListArgs(args) {
601
618
  ...typeof raw.tool === 'string' ? { tool: raw.tool } : {},
602
619
  ...typeof raw.server === 'string' ? { server: raw.server } : {},
603
620
  ...raw.verbose === true ? { verbose: true } : {},
621
+ ...typeof raw.query === 'string' && raw.query.trim() !== '' ? { query: raw.query } : {},
604
622
  };
605
623
  }
606
624
  /**
@@ -641,22 +659,240 @@ export function truncateDescription(text, limit) {
641
659
  end -= 1;
642
660
  return `${flat.slice(0, end)}…`;
643
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
+ }
644
867
  /**
645
868
  * Build the `mcp_list` result over the currently visible schemas.
646
869
  *
647
870
  * Priority: `tool` (full single-tool expansion) > catalog (optionally
648
- * filtered by `server`, optionally `verbose`). Unknown or non-MCP `tool`,
649
- * and any query that selects nothing, return a structured error — never a
650
- * 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.
651
885
  *
652
886
  * @param args - the normalized catalog query.
653
887
  * @param schemas - schemas visible to the calling agent.
654
888
  * @param options - catalog knobs.
655
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).
656
892
  * @returns the canonical result value.
657
893
  */
658
- export function buildMcpListResult(args, schemas, options, gate) {
659
- const { prefix, descriptionLimit } = options;
894
+ export function buildMcpListResult(args, schemas, options, gate, ranking) {
895
+ const { prefix } = options;
660
896
  const servers = options.servers ?? [];
661
897
  if (args.tool !== undefined) {
662
898
  // The meta-tools are visible schemas like any other and can match a
@@ -683,56 +919,105 @@ export function buildMcpListResult(args, schemas, options, gate) {
683
919
  // On-demand expansion: the FULL description and schema, not truncated.
684
920
  return { tool: { name: schema.name, description: schema.description, parameters: schema.parameters } };
685
921
  }
686
- const serversOut = [];
687
- // Servers that passed prefix/whitelist checks but were hidden by the gate.
688
- 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 = [];
689
952
  for (const schema of schemas) {
690
- // Never catalog the meta-tools themselves, even under a prefix that
691
- // matches their names.
692
953
  if (schema.name === MCP_LIST_TOOL_NAME || schema.name === MCP_CALL_TOOL_NAME)
693
954
  continue;
694
- if (!schema.name.startsWith(prefix))
695
- continue;
696
- if (!isAllowedServer(schema.name, prefix, servers))
955
+ if (!schema.name.startsWith(options.prefix))
697
956
  continue;
698
- const server = serverOfToolName(schema.name, prefix);
699
- // Disabled servers vanish from the directory entirely; point-name
700
- // expansion above remains the structured way to learn why.
701
- if (isServerDisabled(server, gate)) {
702
- if (!gatedAway.includes(server))
703
- gatedAway.push(server);
957
+ if (!isAllowedServer(schema.name, options.prefix, servers))
704
958
  continue;
705
- }
706
- if (args.server !== undefined && server !== args.server)
959
+ if (isServerDisabled(serverOfToolName(schema.name, options.prefix), gate))
707
960
  continue;
708
- let group = serversOut.find(entry => entry.server === server);
709
- if (group === undefined) {
710
- group = { server, tools: [] };
711
- serversOut.push(group);
712
- }
713
- group.tools.push({
714
- name: schema.name,
715
- description: truncateDescription(schema.description, descriptionLimit),
716
- ...args.verbose === true ? { parameters: schema.parameters } : {},
717
- });
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 };
990
+ }
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);
718
996
  }
719
- if (serversOut.length === 0) {
720
- if (args.server !== undefined && isServerDisabled(args.server, gate)) {
721
- const id = gate?.serverIds[args.server];
722
- return { error: `server "${args.server}" is disabled and hidden from the catalog — restore it with "/mcp enable ${String(id)}"` };
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
+ }
723
1015
  }
724
- // An unfiltered catalog that found prefix tools but lost ALL of them to
725
- // the gate must not claim "nothing is registered" (same facts as the
726
- // filtered path above): name each hidden server with its restore hint.
727
- if (args.server === undefined && gatedAway.length > 0) {
728
- const named = gatedAway.map(name => `"${name}" ("/mcp enable ${String(gate?.serverIds[name])}")`).join(', ');
729
- 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
730
1018
  }
731
- return args.server === undefined
732
- ? { error: `no MCP tools are registered under the "${prefix}" prefix` }
733
- : { error: `no MCP tools match server "${args.server}"` };
734
1019
  }
735
- return { servers: serversOut };
1020
+ return { error: base + didYouMeanMessage(candidates.slice(0, DID_YOU_MEAN_LIMIT), source) };
736
1021
  }
737
1022
  /**
738
1023
  * Dispatch one `mcp_call` to the resolved child tool.
@@ -760,6 +1045,11 @@ export function buildMcpListResult(args, schemas, options, gate) {
760
1045
  * Errors never throw out of dispatch: a child rejection (or raced timeout)
761
1046
  * is wrapped into a structured `{ error }` the model can correct and retry.
762
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
+ *
763
1053
  * @param args - raw model arguments (`{ tool, arguments? }`, validated here).
764
1054
  * @param prefix - the configured MCP prefix.
765
1055
  * @param resolve - scope-aware tool resolver (production: `ctx.tools.get`).
@@ -769,9 +1059,11 @@ export function buildMcpListResult(args, schemas, options, gate) {
769
1059
  * disabled branch sits after the prefix/whitelist validation: a disabled
770
1060
  * server's tools are refused with an explicit `/mcp enable <id>` hint
771
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).
772
1064
  * @returns the child's resolved value verbatim, or a structured error.
773
1065
  */
774
- export async function dispatchMcpCall(args, prefix, resolve, exec, servers = [], gate) {
1066
+ export async function dispatchMcpCall(args, prefix, resolve, exec, servers = [], gate, didYouMean) {
775
1067
  if (typeof args !== 'object' || args === null || Array.isArray(args)) {
776
1068
  return { error: `${MCP_CALL_TOOL_NAME} expects an object { "tool": string, "arguments"?: object }` };
777
1069
  }
@@ -796,7 +1088,7 @@ export async function dispatchMcpCall(args, prefix, resolve, exec, servers = [],
796
1088
  }
797
1089
  const definition = resolve(tool);
798
1090
  if (definition === undefined) {
799
- 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);
800
1092
  }
801
1093
  try {
802
1094
  // `?? {}` lets a missing/null arguments object reach the server, which
@@ -833,7 +1125,7 @@ async function raceToolTimeout(definition, timeoutMs, args, exec) {
833
1125
  }
834
1126
  /** Usage text returned for unparseable /mcp invocations. */
835
1127
  export const MCP_COMMAND_USAGE = [
836
- 'Usage: /mcp [list [server|tool] | config | disable <id> | enable <id>]',
1128
+ 'Usage: /mcp [list [server|tool] | config | suggest | disable <id> | enable <id>]',
837
1129
  '',
838
1130
  '/mcp tree of MCP servers and tools, plus folding health',
839
1131
  '/mcp list same as /mcp',
@@ -842,6 +1134,8 @@ export const MCP_COMMAND_USAGE = [
842
1134
  ' server name or a full registered tool name)',
843
1135
  '/mcp config effective adapter configuration with per-pattern and',
844
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)',
845
1139
  '/mcp disable <id> fold one server\'s tools out of every prompt until it is',
846
1140
  ' enabled again (<id> is the stable number shown by "/mcp";',
847
1141
  ' keep and servers exemptions included)',
@@ -854,9 +1148,10 @@ function parsePositiveInt(token) {
854
1148
  /**
855
1149
  * Parse one /mcp raw input into its form. `''` and `'list'` are the tree
856
1150
  * overview; `'list <name>'` is a server/tool detail; `'config'` shows the
857
- * effective configuration; `'disable <n>'` / `'enable <n>'` are whole-server
858
- * toggles (n is the stable id shown by `/mcp`, so a bare `0` or non-numeric
859
- * 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.
860
1155
  * @param rawInput - exact text following the command name.
861
1156
  * @returns the normalized command form.
862
1157
  */
@@ -873,6 +1168,8 @@ export function parseMcpCommandInput(rawInput) {
873
1168
  return { form: 'usage' };
874
1169
  case 'config':
875
1170
  return parts.length === 1 ? { form: 'config' } : { form: 'usage' };
1171
+ case 'suggest':
1172
+ return parts.length === 1 ? { form: 'suggest' } : { form: 'usage' };
876
1173
  case 'disable':
877
1174
  case 'enable': {
878
1175
  const id = parts.length === 2 ? parsePositiveInt(parts[1]) : undefined;
@@ -1132,6 +1429,48 @@ export function renderMcpDetail(name, schemas, options) {
1132
1429
  }
1133
1430
  return { outcome: 'tool', text: capRenderedLines(text) };
1134
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
+ }
1135
1474
  /**
1136
1475
  * Render the effective configuration with each knob's hitting tools:
1137
1476
  * which names match the prefix, what each keep pattern holds back, and what
@@ -1191,6 +1530,15 @@ export function renderMcpConfig(schemas, options) {
1191
1530
  if (idCapNotice !== undefined)
1192
1531
  lines.push('', idCapNotice);
1193
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
+ }
1194
1542
  return capRenderedLines(lines.join('\n'));
1195
1543
  }
1196
1544
  /**
@@ -1247,6 +1595,8 @@ export async function executeMcpCommand(view) {
1247
1595
  }
1248
1596
  case 'config':
1249
1597
  return { kind: 'success', text: renderMcpConfig(effectiveView.schemas, effectiveView.config) };
1598
+ case 'suggest':
1599
+ return { kind: 'success', text: renderMcpSuggest(effectiveView.suggestions ?? [], effectiveView.config) };
1250
1600
  case 'disable':
1251
1601
  case 'enable':
1252
1602
  return runServerToggle(parsed.form, parsed.id, registry, effectiveView);
@@ -1463,11 +1813,18 @@ export function delegateFinalizeContent(metaArgs, result, exec, resolveChild) {
1463
1813
  }
1464
1814
  /**
1465
1815
  * Build the `mcp_list` meta-tool definition.
1466
- * @param options - catalog knobs (prefix, description limit). MAY be a live
1467
- * options object (plain getters): the factory reads `prefix`, `servers`,
1468
- * `descriptionLimit` and `getGate` through the object on EVERY execution,
1469
- * so volatile config can be wired by reference — only the description text
1470
- * 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.
1471
1828
  * @param listSchemas - visible-schemas source (production: `ctx.tools.schemas`,
1472
1829
  * called with the calling agent so restrictions are respected).
1473
1830
  * @returns the complete tool definition.
@@ -1481,6 +1838,7 @@ export function createMcpListTool(options, listSchemas) {
1481
1838
  `With no arguments: a compact catalog grouped by server — full tool names (like "${prefix}<server>__<tool>") plus short descriptions.`,
1482
1839
  `Pass { "tool": "<full name>" } to expand that tool's complete input schema on demand.`,
1483
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.',
1484
1842
  'Pass { "verbose": true } to inline every tool\'s full schema (expensive — request it only when you actually need many schemas).',
1485
1843
  `Workflow: call ${MCP_LIST_TOOL_NAME} to discover a tool and its schema, then invoke it with ${MCP_CALL_TOOL_NAME}.`,
1486
1844
  ].join(' '),
@@ -1489,33 +1847,70 @@ export function createMcpListTool(options, listSchemas) {
1489
1847
  properties: {
1490
1848
  tool: { type: 'string', description: 'Full registered tool name whose schema to expand' },
1491
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' },
1492
1851
  verbose: { type: 'boolean', description: 'Inline every tool\'s full schema' },
1493
1852
  },
1494
1853
  additionalProperties: false,
1495
1854
  },
1496
1855
  output: {
1497
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.
1498
1859
  schema: {
1499
1860
  type: 'object',
1500
1861
  properties: {
1501
1862
  servers: { type: 'array', items: {} },
1502
1863
  tool: {},
1864
+ query: { type: 'string' },
1865
+ ranked: { type: 'boolean' },
1866
+ total: { type: 'number' },
1867
+ note: { type: 'string' },
1503
1868
  error: { type: 'string' },
1504
1869
  },
1505
1870
  additionalProperties: false,
1506
1871
  },
1507
1872
  render: (_args, value) => renderValue(value),
1508
1873
  },
1509
- execute: (args, exec) => {
1874
+ execute: async (args, exec) => {
1510
1875
  const query = normalizeMcpListArgs(args);
1511
1876
  // The calling agent's view: restricted-away tools stay out of the catalog.
1512
1877
  const schemas = listSchemas(exec.agent);
1513
- 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
+ });
1514
1908
  },
1515
1909
  };
1516
1910
  }
1517
1911
  /**
1518
1912
  * Build the `mcp_call` meta-tool definition.
1913
+ *
1519
1914
  * @param prefix - the configured MCP prefix (dispatch boundary). A thunk is
1520
1915
  * accepted so volatile config is re-read on every dispatch; a plain string
1521
1916
  * freezes the boundary at factory time.
@@ -1527,9 +1922,10 @@ export function createMcpListTool(options, listSchemas) {
1527
1922
  * gate store's current mirror; default/omitted: nothing is disabled).
1528
1923
  * Deliberately a callback — the definition object outlives any one gate
1529
1924
  * snapshot and must observe later /mcp enable/disable writes.
1925
+ * @param hooks - the usage/did-you-mean seams (default: none wired).
1530
1926
  * @returns the complete tool definition.
1531
1927
  */
1532
- export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
1928
+ export function createMcpCallTool(prefix, resolve, servers = [], getGate, hooks) {
1533
1929
  const prefixNow = typeof prefix === 'function' ? prefix : () => prefix;
1534
1930
  const serversNow = typeof servers === 'function' ? servers : () => servers;
1535
1931
  return {
@@ -1558,7 +1954,39 @@ export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
1558
1954
  schema: {},
1559
1955
  render: (args, value) => renderDispatched(args, value, name => resolve(name)),
1560
1956
  },
1561
- 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
+ },
1562
1990
  // Restore child-owned projections (image attachments): the registry
1563
1991
  // invokes THIS definition's finalizer, so it must forward to the child's
1564
1992
  // with the same exec object dispatch passed to child.execute.
@@ -1580,11 +2008,19 @@ export function createMcpCallTool(prefix, resolve, servers = [], getGate) {
1580
2008
  * unreadable document degrades to the empty gate (everything enabled) at
1581
2009
  * load time, keeping the assemble waterfall alive.
1582
2010
  *
1583
- * Config volatility: every knob (prefix/keep/servers/descriptionLimit) is a
1584
- * volatile reference — 0.1.7 settings-page writes swap values in place
1585
- * WITHOUT remounting the plugin, so nothing captured at apply time may go
1586
- * stale: the waterfall, both meta-tools and the /mcp command all re-read
1587
- * 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.
1588
2024
  *
1589
2025
  * Commands: `/mcp` mounts through a runtime `ctx.inject(['commands'], ...)`,
1590
2026
  * so hosts without the commands service keep this plugin fully functional —
@@ -1608,12 +2044,97 @@ export function apply(ctx, config) {
1608
2044
  // takes effect on the next plugin restart. On a fresh gate (no gate.json)
1609
2045
  // the store absorbs the pre-0.1.7 `mcp-adapter:` settings state once —
1610
2046
  // fully contained, audit marker under storages/mcp-adapter.
1611
- const store = createFileGateStore(resolveGateDir(config.storageDir.get()), message => ctx.logger.warn(message), {
2047
+ const gateDir = resolveGateDir(config.storageDir.get());
2048
+ const warn = (message) => ctx.logger.warn(message);
2049
+ const store = createFileGateStore(gateDir, warn, {
1612
2050
  info: message => ctx.logger.info?.(message),
1613
2051
  home: resolveDshHome(),
1614
2052
  });
1615
2053
  const getGate = () => store.get();
1616
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
+ }
1617
2138
  // Live options object: getters re-resolve volatile config per execution, so
1618
2139
  // catalog/dispatch immediately follow settings-page edits (the list tool's
1619
2140
  // description text is the only factory-time freeze, and it is cosmetic).
@@ -1622,8 +2143,21 @@ export function apply(ctx, config) {
1622
2143
  get servers() { return cfgNow().servers; },
1623
2144
  get descriptionLimit() { return cfgNow().descriptionLimit; },
1624
2145
  getGate,
2146
+ // Seam B: only ever reached for an explicit `query` call.
2147
+ rankQuery: async (query, candidates) => rankListQuery(query, candidates, jevEnv),
1625
2148
  }, scope => ctx.tools.schemas(scope));
1626
- 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
+ });
1627
2161
  const disposers = [];
1628
2162
  try {
1629
2163
  disposers.push(ctx.tools.register(listDefinition));
@@ -1677,8 +2211,8 @@ export function apply(ctx, config) {
1677
2211
  const commandDisposer = ccmds.commands.register({
1678
2212
  name: MCP_COMMAND_NAME,
1679
2213
  description: 'Show MCP status; disable/enable MCP servers',
1680
- input: { hint: '[list [server|tool] | config | disable <id> | enable <id>]' },
1681
- handler: invocation => {
2214
+ input: { hint: '[list [server|tool] | config | suggest | disable <id> | enable <id>]' },
2215
+ handler: async (invocation) => {
1682
2216
  // The receiving agent's restricted view; an absent agent degrades to
1683
2217
  // the global registry view (`schemas()` without scope).
1684
2218
  const scope = invocation.agent ?? undefined;
@@ -1688,14 +2222,22 @@ export function apply(ctx, config) {
1688
2222
  // ONE snapshot per execution keeps config.gate and view.gate identical;
1689
2223
  // volatile knobs are re-read per invocation (live settings-page edits).
1690
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
+ : [];
1691
2232
  return executeMcpCommand({
1692
2233
  rawInput: invocation.rawInput,
1693
2234
  schemas,
1694
- config: { ...cfgNow(), gate },
2235
+ config: { ...cfgNow(), gate, jev: jevNow(), usage: usageNow() },
1695
2236
  metaToolsLive,
1696
2237
  gate,
1697
2238
  signal: invocation.signal,
1698
2239
  writeGate,
2240
+ suggestions,
1699
2241
  });
1700
2242
  },
1701
2243
  });