@aiwayds/dsh-mcp-adapter 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -3
- package/README.zh.md +50 -4
- package/lib/index.d.ts +275 -22
- package/lib/index.js +612 -70
- package/lib/index.js.map +1 -1
- package/lib/jev/backends.d.ts +35 -0
- package/lib/jev/backends.js +48 -0
- package/lib/jev/backends.js.map +1 -0
- package/lib/jev/client.d.ts +133 -0
- package/lib/jev/client.js +288 -0
- package/lib/jev/client.js.map +1 -0
- package/lib/jev/config.d.ts +82 -0
- package/lib/jev/config.js +55 -0
- package/lib/jev/config.js.map +1 -0
- package/lib/jev/dual.d.ts +42 -0
- package/lib/jev/dual.js +42 -0
- package/lib/jev/dual.js.map +1 -0
- package/lib/jev/gate.d.ts +51 -0
- package/lib/jev/gate.js +141 -0
- package/lib/jev/gate.js.map +1 -0
- package/lib/jev/log.d.ts +93 -0
- package/lib/jev/log.js +138 -0
- package/lib/jev/log.js.map +1 -0
- package/lib/jev/seams.d.ts +118 -0
- package/lib/jev/seams.js +364 -0
- package/lib/jev/seams.js.map +1 -0
- package/lib/jev/thresholds.d.ts +37 -0
- package/lib/jev/thresholds.js +44 -0
- package/lib/jev/thresholds.js.map +1 -0
- package/lib/rank.d.ts +157 -0
- package/lib/rank.js +327 -0
- package/lib/rank.js.map +1 -0
- package/lib/usage.d.ts +232 -0
- package/lib/usage.js +362 -0
- package/lib/usage.js.map +1 -0
- package/package.json +13 -13
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
|
|
649
|
-
* and any query that selects nothing
|
|
650
|
-
*
|
|
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
|
|
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
|
|
687
|
-
//
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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 {
|
|
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
|
|
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; `'
|
|
858
|
-
*
|
|
859
|
-
*
|
|
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
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
1584
|
-
* volatile reference — 0.1.7 settings-page writes swap
|
|
1585
|
-
* WITHOUT remounting the plugin, so nothing captured at apply
|
|
1586
|
-
* stale: the waterfall, both meta-tools and the /mcp command all
|
|
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
|
|
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
|
});
|