@zackbart/connecta 0.14.1 → 0.15.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/CHANGELOG.md +83 -0
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +104 -56
- package/dist/catalog-service.js.map +1 -1
- package/dist/catalog.d.ts +8 -2
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +58 -9
- package/dist/catalog.js.map +1 -1
- package/dist/connectors/api.d.ts +4 -4
- package/dist/connectors/api.js +4 -4
- package/dist/connectors/remote-mcp.d.ts +8 -0
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +3 -1
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +8 -7
- package/dist/execute.js.map +1 -1
- package/dist/meta-tools.js +1 -1
- package/dist/meta-tools.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +97 -17
- package/dist/validate.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/code-mode.md +9 -9
- package/documentation/connectors.md +6 -1
- package/documentation/meta-tools.md +56 -13
- package/ethos.md +1 -0
- package/package.json +1 -1
- package/src/catalog-service.ts +93 -29
- package/src/catalog.ts +71 -8
- package/src/connectors/api.ts +4 -4
- package/src/connectors/remote-mcp.ts +11 -1
- package/src/execute.ts +8 -7
- package/src/meta-tools.ts +1 -1
- package/src/validate.ts +125 -20
- package/src/version.ts +1 -1
- package/templates/node/package.json +1 -1
|
@@ -128,7 +128,12 @@ Connecta deliberately sits between protocol generations
|
|
|
128
128
|
remains stateless in both cases.
|
|
129
129
|
- **Outbound:** `remoteMcp()` probes modern downstreams and falls back to the
|
|
130
130
|
byte-compatible legacy flow. Legacy downstreams are normal supported
|
|
131
|
-
deployments, not a temporary exception.
|
|
131
|
+
deployments, not a temporary exception. Automatic negotiation remains the
|
|
132
|
+
default. A known-legacy server that crashes or returns a server error for the
|
|
133
|
+
pre-initialize probe can set `versionNegotiation: "legacy"` on that one
|
|
134
|
+
connector; Connecta then starts directly with `initialize` and never sends
|
|
135
|
+
`server/discover`. Keep the default unless the downstream requires this
|
|
136
|
+
compatibility concession, so modern protocol support is still discovered.
|
|
132
137
|
- **Legacy sessions:** Connecta's own endpoint creates no protocol session, but
|
|
133
138
|
a stateful legacy downstream can still issue `Mcp-Session-Id`. Closing a
|
|
134
139
|
request scope explicitly sends the legacy DELETE before closing its transport.
|
|
@@ -46,6 +46,18 @@ read-only calls and returns typed outcomes, and an unfiltered
|
|
|
46
46
|
`connecta.search({})` browses every catalog a program can reach. Live connector
|
|
47
47
|
probing is an operator concern: the operator pages and `/health` own it.
|
|
48
48
|
|
|
49
|
+
The three discovery routes use deliberately different envelopes. These are
|
|
50
|
+
their smallest successful one-tool shapes:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
// Top-level search_tools
|
|
54
|
+
{ connectors: [{ id: "ci", tools: [{ name: "get_run", address: "ci.get_run" }] }], total: 1, offset: 0, limit: 8, hasMore: false }
|
|
55
|
+
|
|
56
|
+
// Inside execute_code
|
|
57
|
+
{ tools: [{ name: "get_run", address: "ci.get_run" }], total: 1, offset: 0, limit: 8, hasMore: false } // connecta.search
|
|
58
|
+
{ tools: [{ name: "get_run", address: "ci.get_run", inputSchema: "{ runId: integer }" }] } // connecta.describe
|
|
59
|
+
```
|
|
60
|
+
|
|
49
61
|
## Discovery context
|
|
50
62
|
|
|
51
63
|
Start an unknown-address lookup with two to four distinctive action/object
|
|
@@ -60,8 +72,10 @@ or setting it to `"all"`, preserves the complete configured catalog. This is
|
|
|
60
72
|
only a discovery filter: it neither grants authority nor changes invocation admission.
|
|
61
73
|
`includeSchemas: "compact"` adds each match's input and any declared output
|
|
62
74
|
shape. Bounded plain-object schemas also expose `inputKeys`,
|
|
63
|
-
`requiredInputKeys`, and `outputKeys`; a
|
|
64
|
-
|
|
75
|
+
`requiredInputKeys`, and `outputKeys`; a zero-input object keeps
|
|
76
|
+
`requiredInputKeys: []`, while an output object with no declared properties
|
|
77
|
+
omits `outputKeys`. A truncated shape omits its corresponding list rather than
|
|
78
|
+
repeating a large partial inventory. Matches carry declared
|
|
65
79
|
behavior annotations. Lexical rank is only one signal: select a candidate whose
|
|
66
80
|
required inputs are available, whose schema is complete enough for the call,
|
|
67
81
|
and whose safety and declared outputs fit the work. A reducer uses `outputKeys`
|
|
@@ -75,11 +89,17 @@ Compact search is deliberately a routing view, not a second copy of connector
|
|
|
75
89
|
documentation. Tool purposes are capped at 160 characters, connector
|
|
76
90
|
descriptions and property prose are omitted, required input fields render
|
|
77
91
|
before optional ones, and each input or output shape is capped at 1,024 UTF-8
|
|
78
|
-
bytes.
|
|
79
|
-
|
|
92
|
+
bytes. Within that unchanged total, each enum node may spend at most 256 UTF-8
|
|
93
|
+
bytes. This lets about three near-cap enum nodes coexist while reserving the
|
|
94
|
+
remaining quarter for surrounding syntax; the global fallback still applies
|
|
95
|
+
when the complete shape exceeds 1,024 bytes. A large enum keeps the longest
|
|
96
|
+
whole-value prefix that fits, then adds `unknown` and a comment with the exact
|
|
97
|
+
omitted-value count. An empty enum renders as the valid `never` type. A capped
|
|
98
|
+
object becomes a valid required-first shape with `unknown` types; other shapes
|
|
99
|
+
become `unknown /* truncated */`. Either cap marks the match with
|
|
80
100
|
`inputSchemaTruncated` or `outputSchemaTruncated`; repeat the search with
|
|
81
101
|
`includeSchemas: "json"` or use the existing describe path when exact
|
|
82
|
-
constraints matter.
|
|
102
|
+
constraints matter. Small enums and both exact paths remain complete.
|
|
83
103
|
|
|
84
104
|
## Connector guide selection
|
|
85
105
|
|
|
@@ -176,13 +196,34 @@ set of inflectional variants preserves singular/plural and verb-form recall
|
|
|
176
196
|
without allowing arbitrary mid-word substring matches. Ranking weights each
|
|
177
197
|
query term by its document frequency across the available catalogs in that
|
|
178
198
|
search, so a rare domain term outranks a ubiquitous action while action terms
|
|
179
|
-
still distinguish `get`, `list`, `search`, and write operations.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
199
|
+
still distinguish `get`, `list`, `search`, and write operations. The scorer
|
|
200
|
+
always evaluates useful near-matches instead of letting one broad all-term
|
|
201
|
+
description hide them. Complete matches rank before ordinary partial matches;
|
|
202
|
+
a partial candidate whose complete normalized tool name occurs in the
|
|
203
|
+
normalized raw query competes with complete matches by score, and other
|
|
204
|
+
candidates covering at least two terms fill the remaining page after them.
|
|
205
|
+
Conversational cleanup applies only to scoring terms, never to the exact-name
|
|
206
|
+
phrase check. If no tool covers every non-conversational term, the same scorer
|
|
207
|
+
preserves the wider any-term fallback and marks the result
|
|
208
|
+
`matchMode: "partial"`.
|
|
209
|
+
|
|
210
|
+
Returned tool rows expose neither lexical scores nor per-result query coverage.
|
|
211
|
+
The mixed complete/partial scorer still ranks rare domain terms, action terms,
|
|
212
|
+
and exact tool-name phrases. Select from the returned purpose, address, schema,
|
|
213
|
+
safety, and output shape. Page-level `queryAnalysis` remains the recovery path
|
|
214
|
+
when no single result covers every term or no match exists.
|
|
215
|
+
|
|
216
|
+
Only an empty or whitespace-only query browses. A non-empty query that
|
|
217
|
+
normalizes to no ASCII lexical terms returns no tools instead of unrelated
|
|
218
|
+
browse results. Its bounded `queryAnalysis.unmatchedTerms` contains the clipped
|
|
219
|
+
raw query and guidance asks for ASCII action/object terms. A mixed query still
|
|
220
|
+
searches with its ASCII terms; unsupported characters do not become false
|
|
221
|
+
matches or per-tool coverage terms.
|
|
222
|
+
|
|
223
|
+
Every partial or no-match lexical search also returns bounded page-level
|
|
224
|
+
`queryAnalysis`; an all-term result needs no recovery advice.
|
|
225
|
+
`representedTerms` occur in the current page, `otherResultTerms` occur only in
|
|
226
|
+
another result, and
|
|
186
227
|
`unmatchedTerms` have no lexical match in the catalogs that answered. Partial
|
|
187
228
|
results explain that no single tool covered every term and recommend splitting
|
|
188
229
|
distinct intents. A true negative says that no matching capability is
|
|
@@ -331,7 +372,9 @@ exist. `nextAction` points to discovery scoped to the same connector and tool
|
|
|
331
372
|
name when the compact schema is needed — routed like any other miss, so a
|
|
332
373
|
program is sent to `connecta.search` and a top-level call to `search_tools` —
|
|
333
374
|
while `retry` says to correct the listed arguments and reissue the original
|
|
334
|
-
operation. A schema
|
|
375
|
+
operation. A declared property reports only its failed schema keyword, while a
|
|
376
|
+
truly undeclared property reports `additionalProperties`; validator-internal
|
|
377
|
+
duplicate `additionalProperties` branches never reach the caller. A schema the local
|
|
335
378
|
validator cannot evaluate passes through to the provider. Provider error prose
|
|
336
379
|
is not parsed or guessed, so an unknown format remains
|
|
337
380
|
`connector_call_failed`.
|
package/ethos.md
CHANGED
|
@@ -91,6 +91,7 @@ proposing one without a new argument is not.
|
|
|
91
91
|
| `get_result` paging for program results | refused | paging rewards the unprojected return code mode exists to remove; a program can shrink anything ([#223](https://github.com/zackbart/connecta/issues/223)) |
|
|
92
92
|
| Stabilized workflows (programs → versioned scripts/skills) | gated | earns a surface only once real traffic shows programs that actually recur ([#225](https://github.com/zackbart/connecta/issues/225)) |
|
|
93
93
|
| Semantic tool search | gated | keyword search has not been shown to be the thing failing; earns its way in through [#222](https://github.com/zackbart/connecta/issues/222)'s harness ([#27](https://github.com/zackbart/connecta/issues/27)) |
|
|
94
|
+
| Per-result lexical query coverage | removed | verbose, indexed, and trailing shapes did not earn their response-token cost: the coverage-off arm beat the verbose wire, the first compact wire regressed efficiency, and the trailing wire failed its precommitted 30-run clean-route gate (13/30 vs 9/30, +13.3 pp, Fisher p=0.422); preserve the mixed complete/partial ranking from [#326](https://github.com/zackbart/connecta/issues/326), but do not revive serialized coverage without new causal evidence ([#322](https://github.com/zackbart/connecta/issues/322), [#323](https://github.com/zackbart/connecta/issues/323)) |
|
|
94
95
|
| MRTR / `input_required` passthrough | gated | statelessly relayable via `requestState`, but no host or downstream emits it yet; fails loudly until adoption evidence ([#176](https://github.com/zackbart/connecta/issues/176)) |
|
|
95
96
|
| Native Tasks for oversized results | refused | tasks solve duration, `get_result` solves size; paging on a polling extension adds round trips for nothing ([#176](https://github.com/zackbart/connecta/issues/176)) |
|
|
96
97
|
| Downstream `ttlMs` cache hints | gated | fixed TTL + fingerprint is battle-tested and catalog reads are ~3 ms; earns its way in with refresh-churn evidence ([#176](https://github.com/zackbart/connecta/issues/176)) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zackbart/connecta",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
|
package/src/catalog-service.ts
CHANGED
|
@@ -45,11 +45,29 @@ export const DEFAULT_SEARCH_LIMIT = 8;
|
|
|
45
45
|
export const MAX_SEARCH_LIMIT = 100;
|
|
46
46
|
export const MAX_DESCRIBE_ADDRESSES = 100;
|
|
47
47
|
export const MAX_DISCOVERY_RESULT_BYTES = 256_000;
|
|
48
|
-
const
|
|
49
|
-
const
|
|
48
|
+
const MAX_QUERY_TERMS = 8;
|
|
49
|
+
const MAX_QUERY_TERM_LENGTH = 64;
|
|
50
50
|
|
|
51
51
|
const encoder = new TextEncoder();
|
|
52
52
|
|
|
53
|
+
/** Clip one echoed query term without splitting a non-BMP code point. */
|
|
54
|
+
function boundedQueryTerm(term: string): {
|
|
55
|
+
text: string;
|
|
56
|
+
truncated: boolean;
|
|
57
|
+
} {
|
|
58
|
+
const characters: string[] = [];
|
|
59
|
+
for (const character of term) {
|
|
60
|
+
characters.push(character);
|
|
61
|
+
if (characters.length > MAX_QUERY_TERM_LENGTH) {
|
|
62
|
+
return {
|
|
63
|
+
text: `${characters.slice(0, MAX_QUERY_TERM_LENGTH - 1).join("")}…`,
|
|
64
|
+
truncated: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { text: characters.join(""), truncated: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
/**
|
|
54
72
|
* The discovery route a routing failure should send a caller back through. Same
|
|
55
73
|
* catalog logic serves both the top-level `search_tools` path and the
|
|
@@ -261,7 +279,9 @@ function schemaKeyMetadata(
|
|
|
261
279
|
requiredInputKeys: inputKeys.required,
|
|
262
280
|
}
|
|
263
281
|
: {}),
|
|
264
|
-
...(outputKeys
|
|
282
|
+
...(outputKeys && outputKeys.properties.length > 0
|
|
283
|
+
? { outputKeys: outputKeys.properties }
|
|
284
|
+
: {}),
|
|
265
285
|
};
|
|
266
286
|
}
|
|
267
287
|
|
|
@@ -647,6 +667,9 @@ export class CatalogService {
|
|
|
647
667
|
tool: ToolDef;
|
|
648
668
|
score: number;
|
|
649
669
|
order: number;
|
|
670
|
+
exactName: boolean;
|
|
671
|
+
matchedTermCount: number;
|
|
672
|
+
complete: boolean;
|
|
650
673
|
}> = [];
|
|
651
674
|
let matchMode: "all" | "partial" = "all";
|
|
652
675
|
const statistics = lexicalCorpusStatistics(
|
|
@@ -655,8 +678,19 @@ export class CatalogService {
|
|
|
655
678
|
),
|
|
656
679
|
retrievalQuery,
|
|
657
680
|
);
|
|
681
|
+
const trimmedQuery = query.trim();
|
|
682
|
+
const isBrowse = trimmedQuery.length === 0;
|
|
683
|
+
const queryTerms = lexicalQueryTerms(retrievalQuery);
|
|
684
|
+
const queryTermCount = queryTerms.length;
|
|
685
|
+
const unsearchableQuery = !isBrowse && queryTermCount === 0;
|
|
686
|
+
const analysisTerms = unsearchableQuery ? [trimmedQuery] : queryTerms;
|
|
687
|
+
const analyzedTerms = analysisTerms.slice(0, MAX_QUERY_TERMS);
|
|
688
|
+
const displayTerm = (term: string) => boundedQueryTerm(term).text;
|
|
689
|
+
const queryMetadataTruncated =
|
|
690
|
+
analysisTerms.length > analyzedTerms.length ||
|
|
691
|
+
analyzedTerms.some((term) => boundedQueryTerm(term).truncated);
|
|
658
692
|
const collectMatches = (mode: "all" | "partial") => {
|
|
659
|
-
matches
|
|
693
|
+
const collected: typeof matches = [];
|
|
660
694
|
let orderBase = 0;
|
|
661
695
|
searchableCatalogs.forEach((catalog, connectorIndex) => {
|
|
662
696
|
const connector = connectors[connectorIndex];
|
|
@@ -669,25 +703,59 @@ export class CatalogService {
|
|
|
669
703
|
retrievalQuery,
|
|
670
704
|
mode,
|
|
671
705
|
statistics,
|
|
706
|
+
query,
|
|
672
707
|
)) {
|
|
673
|
-
|
|
708
|
+
collected.push({
|
|
674
709
|
connector,
|
|
675
710
|
tool: ranked.tool,
|
|
676
711
|
score: ranked.score,
|
|
677
712
|
order: orderBase + ranked.order,
|
|
713
|
+
exactName: ranked.exactName,
|
|
714
|
+
matchedTermCount: ranked.matchedTermCount,
|
|
715
|
+
complete:
|
|
716
|
+
isBrowse || ranked.matchedTermCount === queryTermCount,
|
|
678
717
|
});
|
|
679
718
|
}
|
|
680
719
|
}
|
|
681
720
|
orderBase +=
|
|
682
721
|
catalog.status === "fulfilled" ? catalog.value.length : 1;
|
|
683
722
|
});
|
|
723
|
+
return collected;
|
|
684
724
|
};
|
|
685
|
-
|
|
686
|
-
|
|
725
|
+
// A non-empty query that normalizes to no lexical terms is not a browse.
|
|
726
|
+
// Ranking an empty phrase would otherwise return every tool as an
|
|
727
|
+
// unrelated zero-score match, with no coverage to explain the result.
|
|
728
|
+
const rankedMatches = unsearchableQuery
|
|
729
|
+
? []
|
|
730
|
+
: collectMatches(isBrowse ? "all" : "partial");
|
|
731
|
+
const completeMatchCount = rankedMatches.filter(
|
|
732
|
+
(match) => match.complete,
|
|
733
|
+
).length;
|
|
734
|
+
matches.push(
|
|
735
|
+
...rankedMatches.filter(
|
|
736
|
+
(match) =>
|
|
737
|
+
match.complete ||
|
|
738
|
+
completeMatchCount === 0 ||
|
|
739
|
+
match.exactName ||
|
|
740
|
+
match.matchedTermCount >= 2,
|
|
741
|
+
),
|
|
742
|
+
);
|
|
743
|
+
if (!isBrowse && !unsearchableQuery && completeMatchCount === 0) {
|
|
687
744
|
matchMode = "partial";
|
|
688
|
-
collectMatches(matchMode);
|
|
689
745
|
}
|
|
690
|
-
matches
|
|
746
|
+
// Complete matches and exact tool-name phrases share the first rank tier.
|
|
747
|
+
// This lets a strong action/object name beat a weak description-only
|
|
748
|
+
// decoy. Other partial matches fill the remaining page only after every
|
|
749
|
+
// complete match, regardless of a rare-term score spike.
|
|
750
|
+
matches.sort((a, b) => {
|
|
751
|
+
const aFirstTier = a.complete || a.exactName;
|
|
752
|
+
const bFirstTier = b.complete || b.exactName;
|
|
753
|
+
return (
|
|
754
|
+
Number(bFirstTier) - Number(aFirstTier) ||
|
|
755
|
+
b.score - a.score ||
|
|
756
|
+
a.order - b.order
|
|
757
|
+
);
|
|
758
|
+
});
|
|
691
759
|
const pageMatches = matches.slice(offset, offset + limit);
|
|
692
760
|
const entries = pageMatches.map((match) => {
|
|
693
761
|
const input = match.tool.inputSchema ?? { type: "object" };
|
|
@@ -769,12 +837,6 @@ export class CatalogService {
|
|
|
769
837
|
offset + entries.length < matches.length
|
|
770
838
|
? offset + entries.length
|
|
771
839
|
: undefined;
|
|
772
|
-
const queryTerms = lexicalQueryTerms(retrievalQuery);
|
|
773
|
-
const analyzedTerms = queryTerms.slice(0, MAX_QUERY_ANALYSIS_TERMS);
|
|
774
|
-
const displayTerm = (term: string) =>
|
|
775
|
-
term.length <= MAX_QUERY_ANALYSIS_TERM_LENGTH
|
|
776
|
-
? term
|
|
777
|
-
: `${term.slice(0, MAX_QUERY_ANALYSIS_TERM_LENGTH - 1)}…`;
|
|
778
840
|
const pageTools = new Set(pageMatches.map((match) => match.tool));
|
|
779
841
|
const matchingTools = (term: string) =>
|
|
780
842
|
new Set([
|
|
@@ -844,19 +906,24 @@ export class CatalogService {
|
|
|
844
906
|
args.connector && !scopedConnector
|
|
845
907
|
? `Connector "${args.connector}" is not configured in this deployment. Omit connector to search all configured tools.`
|
|
846
908
|
: undefined;
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
// an empty
|
|
852
|
-
//
|
|
853
|
-
// because there were no terms — the scope fields carry the whole message.
|
|
909
|
+
// Searchable queries report analysis when the scorer had to degrade. A
|
|
910
|
+
// non-empty query with no searchable terms reports the bounded raw input
|
|
911
|
+
// as unmatched instead of silently becoming a browse. A real browse has
|
|
912
|
+
// no terms to analyse and normally reports none at all. Scope failures are
|
|
913
|
+
// the exception, because an empty result alone looks like a connector that
|
|
914
|
+
// correctly exposes no tools.
|
|
854
915
|
const reportsQueryAnalysis =
|
|
855
|
-
|
|
916
|
+
unsearchableQuery ||
|
|
917
|
+
(queryTerms.length > 0
|
|
856
918
|
? matchMode === "partial"
|
|
857
|
-
: unknownConnectorGuidance !== undefined || unavailableCatalogs > 0;
|
|
919
|
+
: unknownConnectorGuidance !== undefined || unavailableCatalogs > 0);
|
|
858
920
|
const guidance =
|
|
859
|
-
|
|
921
|
+
unsearchableQuery
|
|
922
|
+
? (unknownConnectorGuidance ??
|
|
923
|
+
(scopedConnector && unavailableCatalogs > 0
|
|
924
|
+
? `Connector "${scopedConnector.id}" could not be searched because its catalog was unavailable. Inspect catalogError for the typed reason and recovery detail.`
|
|
925
|
+
: "The query contained no searchable lexical terms. Use 2–4 ASCII action/object terms, or browse with an empty query."))
|
|
926
|
+
: queryTerms.length === 0
|
|
860
927
|
? // A browse has no terms to advise about, so it stays silent unless
|
|
861
928
|
// the scope itself failed: the guidance on a scoped miss recommends
|
|
862
929
|
// browsing with an empty query, and that advice must not lead into a
|
|
@@ -901,10 +968,7 @@ export class CatalogService {
|
|
|
901
968
|
representedTerms,
|
|
902
969
|
otherResultTerms,
|
|
903
970
|
unmatchedTerms,
|
|
904
|
-
...(
|
|
905
|
-
analyzedTerms.some(
|
|
906
|
-
(term) => term.length > MAX_QUERY_ANALYSIS_TERM_LENGTH,
|
|
907
|
-
)
|
|
971
|
+
...(queryMetadataTruncated
|
|
908
972
|
? { truncated: true as const }
|
|
909
973
|
: {}),
|
|
910
974
|
...(args.connector ? { connectorScope: args.connector } : {}),
|
package/src/catalog.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { JsonSchema, ToolDef } from "./types.js";
|
|
|
3
3
|
const DEFAULT_DESCRIPTION_LENGTH = 240;
|
|
4
4
|
const DISCOVERY_DESCRIPTION_LENGTH = 160;
|
|
5
5
|
export const MAX_COMPACT_DISCOVERY_SCHEMA_BYTES = 1_024;
|
|
6
|
+
const MAX_COMPACT_DISCOVERY_ENUM_BYTES =
|
|
7
|
+
MAX_COMPACT_DISCOVERY_SCHEMA_BYTES / 4;
|
|
6
8
|
const schemaEncoder = new TextEncoder();
|
|
7
9
|
const COMPACT_DISCOVERY_TRUNCATION = " /* truncated */";
|
|
8
10
|
|
|
@@ -126,6 +128,8 @@ export interface RankedTool {
|
|
|
126
128
|
tool: ToolDef;
|
|
127
129
|
score: number;
|
|
128
130
|
order: number;
|
|
131
|
+
exactName: boolean;
|
|
132
|
+
matchedTermCount: number;
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
const searchIndexes = new WeakMap<ToolDef[], SearchIndex>();
|
|
@@ -304,8 +308,8 @@ function scoreDocument(
|
|
|
304
308
|
terms: string[],
|
|
305
309
|
mode: LexicalMatchMode,
|
|
306
310
|
statistics: LexicalCorpusStatistics,
|
|
307
|
-
): number | null {
|
|
308
|
-
if (!phrase) return 0;
|
|
311
|
+
): { score: number; matchedTermCount: number } | null {
|
|
312
|
+
if (!phrase) return { score: 0, matchedTermCount: 0 };
|
|
309
313
|
const matchedTerms = terms.filter((term) =>
|
|
310
314
|
statistics.nameMatches.get(term)?.has(doc.tool) ||
|
|
311
315
|
statistics.descriptionMatches.get(term)?.has(doc.tool),
|
|
@@ -343,10 +347,19 @@ function scoreDocument(
|
|
|
343
347
|
score += 1.5 * weight;
|
|
344
348
|
}
|
|
345
349
|
}
|
|
346
|
-
return score;
|
|
350
|
+
return { score, matchedTermCount: matchedTerms.length };
|
|
347
351
|
}
|
|
348
352
|
|
|
349
|
-
|
|
353
|
+
function queryContainsExactName(doc: SearchDocument, phrase: string): boolean {
|
|
354
|
+
if (!doc.name || !phrase) return false;
|
|
355
|
+
return (` ${phrase} `).includes(` ${doc.name} `);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Rank a connector's tools while caching its normalized plain-data index.
|
|
360
|
+
* `exactNameQuery` may retain framing removed from the scoring query: those
|
|
361
|
+
* words are weak term evidence, but remain part of a real tool-name phrase.
|
|
362
|
+
*/
|
|
350
363
|
export function rankTools(
|
|
351
364
|
tools: ToolDef[],
|
|
352
365
|
query: string,
|
|
@@ -355,13 +368,23 @@ export function rankTools(
|
|
|
355
368
|
[tools],
|
|
356
369
|
query,
|
|
357
370
|
),
|
|
371
|
+
exactNameQuery: string = query,
|
|
358
372
|
): RankedTool[] {
|
|
359
373
|
const phrase = normalized(query);
|
|
374
|
+
const exactNamePhrase = normalized(exactNameQuery);
|
|
360
375
|
const terms = [...new Set(phrase.split(/\s+/).filter(Boolean))];
|
|
361
376
|
const ranked: RankedTool[] = [];
|
|
362
377
|
documentsFor(tools).forEach((doc, order) => {
|
|
363
|
-
const
|
|
364
|
-
if (
|
|
378
|
+
const scored = scoreDocument(doc, phrase, terms, mode, statistics);
|
|
379
|
+
if (scored !== null) {
|
|
380
|
+
ranked.push({
|
|
381
|
+
tool: doc.tool,
|
|
382
|
+
score: scored.score,
|
|
383
|
+
order,
|
|
384
|
+
exactName: queryContainsExactName(doc, exactNamePhrase),
|
|
385
|
+
matchedTermCount: scored.matchedTermCount,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
365
388
|
});
|
|
366
389
|
return ranked;
|
|
367
390
|
}
|
|
@@ -405,6 +428,36 @@ function grouped(part: string): string {
|
|
|
405
428
|
return part;
|
|
406
429
|
}
|
|
407
430
|
|
|
431
|
+
function renderEnum(
|
|
432
|
+
values: unknown[],
|
|
433
|
+
byteLimit: number | undefined,
|
|
434
|
+
onTruncated: (() => void) | undefined,
|
|
435
|
+
): string {
|
|
436
|
+
if (values.length === 0) return "never";
|
|
437
|
+
const renderedValues = values.map((value) => JSON.stringify(value));
|
|
438
|
+
const full = renderedValues.join(" | ");
|
|
439
|
+
if (
|
|
440
|
+
byteLimit === undefined ||
|
|
441
|
+
schemaEncoder.encode(full).length <= byteLimit
|
|
442
|
+
) {
|
|
443
|
+
return full;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
onTruncated?.();
|
|
447
|
+
const marker = (omitted: number) =>
|
|
448
|
+
`unknown /* ${omitted} enum ${omitted === 1 ? "value" : "values"} omitted */`;
|
|
449
|
+
let rendered = `(${marker(values.length)})`;
|
|
450
|
+
const prefix: string[] = [];
|
|
451
|
+
for (let index = 0; index < renderedValues.length - 1; index += 1) {
|
|
452
|
+
prefix.push(renderedValues[index] as string);
|
|
453
|
+
const omitted = renderedValues.length - prefix.length;
|
|
454
|
+
const candidate = `(${prefix.join(" | ")} | ${marker(omitted)})`;
|
|
455
|
+
if (schemaEncoder.encode(candidate).length > byteLimit) break;
|
|
456
|
+
rendered = candidate;
|
|
457
|
+
}
|
|
458
|
+
return rendered;
|
|
459
|
+
}
|
|
460
|
+
|
|
408
461
|
function renderSchema(
|
|
409
462
|
schema: unknown,
|
|
410
463
|
defs: Record<string, unknown>,
|
|
@@ -413,6 +466,8 @@ function renderSchema(
|
|
|
413
466
|
options: {
|
|
414
467
|
propertyDescriptions: boolean;
|
|
415
468
|
requiredFirst: boolean;
|
|
469
|
+
enumByteLimit?: number;
|
|
470
|
+
onEnumTruncated?: () => void;
|
|
416
471
|
},
|
|
417
472
|
): string {
|
|
418
473
|
if (depth > 4) return "…";
|
|
@@ -463,7 +518,7 @@ function renderSchema(
|
|
|
463
518
|
);
|
|
464
519
|
}
|
|
465
520
|
if (Array.isArray(s.enum)) {
|
|
466
|
-
return s.enum.
|
|
521
|
+
return renderEnum(s.enum, options.enumByteLimit, options.onEnumTruncated);
|
|
467
522
|
}
|
|
468
523
|
// Checked before type/properties so a discriminator like
|
|
469
524
|
// { type: "string", const: "emoji" } renders as "emoji" rather than string.
|
|
@@ -602,10 +657,18 @@ export function compactDiscoverySchema(
|
|
|
602
657
|
...(schema.definitions as Record<string, unknown>),
|
|
603
658
|
};
|
|
604
659
|
let rendered: string;
|
|
660
|
+
let enumTruncated = false;
|
|
605
661
|
try {
|
|
606
662
|
rendered = renderSchema(schema, defs, new Set(), 0, {
|
|
607
663
|
propertyDescriptions: false,
|
|
608
664
|
requiredFirst: true,
|
|
665
|
+
// Three near-cap enums spend about three quarters of the complete shape
|
|
666
|
+
// budget, leaving the final quarter for surrounding syntax before the
|
|
667
|
+
// unchanged global fallback applies. Whole values keep this UTF-8 safe.
|
|
668
|
+
enumByteLimit: MAX_COMPACT_DISCOVERY_ENUM_BYTES,
|
|
669
|
+
onEnumTruncated: () => {
|
|
670
|
+
enumTruncated = true;
|
|
671
|
+
},
|
|
609
672
|
});
|
|
610
673
|
} catch {
|
|
611
674
|
rendered = JSON.stringify(schema);
|
|
@@ -613,7 +676,7 @@ export function compactDiscoverySchema(
|
|
|
613
676
|
const bytes = schemaEncoder.encode(rendered);
|
|
614
677
|
let result: CompactDiscoverySchema;
|
|
615
678
|
if (bytes.length <= MAX_COMPACT_DISCOVERY_SCHEMA_BYTES) {
|
|
616
|
-
result = { text: rendered, truncated:
|
|
679
|
+
result = { text: rendered, truncated: enumTruncated };
|
|
617
680
|
} else {
|
|
618
681
|
result = {
|
|
619
682
|
text: truncatedDiscoverySchema(schema),
|
package/src/connectors/api.ts
CHANGED
|
@@ -86,10 +86,10 @@ export interface ApiOptions {
|
|
|
86
86
|
* conversion if you prefer zod). call_tool JSON-wraps the handler's return.
|
|
87
87
|
*
|
|
88
88
|
* Arguments are validated against `inputSchema` before the handler runs
|
|
89
|
-
* (disable with `validateArgs: false`).
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
89
|
+
* (disable with `validateArgs: false`). Remote MCP inputs are also validated,
|
|
90
|
+
* but in the shared invocation path against the request-local downstream
|
|
91
|
+
* catalog. These API-only controls stay here because hand-written handlers may
|
|
92
|
+
* deliberately accept loose coercion or choose fail-closed schema handling.
|
|
93
93
|
*/
|
|
94
94
|
export function api(id: string, opts: ApiOptions): Connector {
|
|
95
95
|
const defs: ToolDef[] = opts.tools.map((t) => ({
|
|
@@ -54,6 +54,14 @@ export interface RemoteMcpOptions {
|
|
|
54
54
|
*/
|
|
55
55
|
usageGuide?: string | ConnectorUsageGuide;
|
|
56
56
|
auth?: RemoteMcpAuth;
|
|
57
|
+
/**
|
|
58
|
+
* Downstream MCP version-negotiation mode. Defaults to `"auto"`, which
|
|
59
|
+
* probes with `server/discover` and falls back to the legacy lifecycle when
|
|
60
|
+
* the response identifies a legacy server. Set `"legacy"` only for a known
|
|
61
|
+
* legacy downstream that cannot safely receive the discovery probe; that
|
|
62
|
+
* path starts directly with the ordinary 2025 `initialize` handshake.
|
|
63
|
+
*/
|
|
64
|
+
versionNegotiation?: "auto" | "legacy";
|
|
57
65
|
/**
|
|
58
66
|
* Downstream HTTP redirect policy. Defaults to `"none"`: every redirect is
|
|
59
67
|
* rejected. `"same-origin"` follows at most five redirects while preserving
|
|
@@ -665,7 +673,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
665
673
|
const c = new Client(
|
|
666
674
|
{ name: "connecta", version: CONNECTA_VERSION },
|
|
667
675
|
{
|
|
668
|
-
versionNegotiation: {
|
|
676
|
+
versionNegotiation: {
|
|
677
|
+
mode: opts.versionNegotiation ?? "auto",
|
|
678
|
+
},
|
|
669
679
|
// Connecta has no interactive relay. Surface the result manually
|
|
670
680
|
// below as one structured, non-retryable connector failure.
|
|
671
681
|
inputRequired: { autoFulfill: false },
|
package/src/execute.ts
CHANGED
|
@@ -1274,19 +1274,20 @@ function discardedEmitsText(emitted: EmitCollector): string {
|
|
|
1274
1274
|
const executeDescription = (
|
|
1275
1275
|
emitBudgets: { maxBytes: number; maxBlocks: number },
|
|
1276
1276
|
connectorGuides: boolean,
|
|
1277
|
-
) => `Choose the route before discovery. Exactly one unknown-address read uses top-level search_tools then call_tool; a known address uses call_tool directly. This is the primary surface for everything wider.
|
|
1277
|
+
) => `Choose the route before discovery. Exactly one unknown-address read uses top-level search_tools then call_tool; a known address uses call_tool directly. This is the primary surface for everything wider. For any reduction, dependency, multiple calls, loop, join, or branch, make exactly one execute_code call that searches, selects, calls, and reduces. A discovery-only program wastes its round trip: finish here, don't return catalog matches for a later call. Only readOnlyHint: true tools are available. Limits: ${EXECUTE_MAX_HOST_CALLS} host calls per run, ${EXECUTE_MAX_BATCH_CALLS} per batch, ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second host deadline.
|
|
1278
1278
|
|
|
1279
|
-
Write an async arrow function.
|
|
1280
|
-
- Connector globals call <connectorId>.<toolName>(args)
|
|
1281
|
-
- connecta.call(address, args) and connecta.batch(calls)
|
|
1282
|
-
-
|
|
1279
|
+
Write an async arrow function. NO network, filesystem, timers, or imports; only:
|
|
1280
|
+
- Connector globals call <connectorId>.<toolName>(args). Sanitization: non-[A-Za-z0-9_$] → "_" (my-service.get.thing → my_service.get_thing); prefix a leading digit; suffix a reserved word.
|
|
1281
|
+
- connecta.call(address, args) and connecta.batch(calls) use canonical addresses. Every batch entry is { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }; destructure it.
|
|
1282
|
+
- top-level search_tools returns { connectors: [{ id, tools }], total, offset, limit, hasMore }; connecta.search returns { tools, total, offset, limit, hasMore }; connecta.describe returns { tools }.
|
|
1283
|
+
- connecta.search(args) loads catalogs and must be followed by selection and calls in this program; set connector to the obvious id to load one, otherwise it loads all. For distinct operations, make separate short searches here. Require address/description to match the operation, then check requiredInputKeys, truncation, safety, and outputs; never take the first lexical or merely input-compatible match. Select by fit; do not require it to be the only match. Missing outputKeys means inspect outputSchema, not discard the candidate. Every required key needs task/prior-result data; do not prefer zero required keys. Put every requiredInputKey in call args. For dependencies, match an earlier outputKey to the later requiredInputKey. [] means no required keys, not permission to invent args. Describe only a truncated/insufficient compact shape. Reducers use declared outputKeys, never guessed items/results roots. connecta.describe takes { address: "<connectorId>.<toolName>" } or { addresses: [...] }. Use safety: "readOnly" to avoid advertising calls this sandbox cannot execute; it changes results, not authority. Missing key list = non-object, not no fields; read the schema.${connectorGuides ? " guideRequired: true = stop. Describe clears only schema_truncated; otherwise return its exact guide, fetch with top-level skills, then write the informed call." : ""}
|
|
1283
1284
|
- connecta.emit(block) — emit exactly { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }. Success-only, no host call, ${emitBudgets.maxBlocks} blocks/${emitBudgets.maxBytes} bytes; invalid or over-budget throws before accepting.
|
|
1284
|
-
- connecta.ui(html, options?) — one success-only view
|
|
1285
|
+
- connecta.ui(html, options?) — one success-only view; one arg is display-only. Reads declare { reads: { name: { address, fixedArgs?, viewArgs? } } }; markup calls connecta.read(name, args). Admission applies. It shares the ${emitBudgets.maxBytes}-byte emit budget: one budget, not two; a second, over-budget, or invalid call throws catchably. Bytes stay out; the model reads the return value, not the view; return the initial summary from its variables.
|
|
1285
1286
|
- console.log(...) — captured and returned with the result.
|
|
1286
1287
|
|
|
1287
1288
|
Dependent example (only when the second call requires a value returned by the first): async () => { const { tools } = await connecta.search({ query: "pipeline run job logs", safety: "readOnly", includeSchemas: "compact" }); const pick = (suffix) => { const match = tools.find((t) => t.address.endsWith(suffix)); if (!match) throw new Error("no tool for " + suffix); return match.address; }; const run = await connecta.call(pick(".get_run"), { runId: 42 }); const logs = await connecta.call(pick(".get_job_logs"), { jobId: run.failedJobId }); return [run, logs]; }
|
|
1288
1289
|
|
|
1289
|
-
Calls return plain values (JSON-parsing MCP text when possible) and throw; catch errors. A thrown error is only a message; connecta.batch tells a policy refusal from a transient failure. Never retry retryable: false or rate_limited immediately
|
|
1290
|
+
Calls return plain values (JSON-parsing MCP text when possible) and throw; catch errors. A thrown error is only a message; connecta.batch tells a policy refusal from a transient failure. Never retry retryable: false, or rate_limited immediately; no timers. Return JSON; reduce large results before they truncate.
|
|
1290
1291
|
|
|
1291
1292
|
Plain JS, no TypeScript. Compact schemas are TypeScript-like, not JSON Schema: write the property names they display; never guess positions or aliases.`;
|
|
1292
1293
|
|
package/src/meta-tools.ts
CHANGED
|
@@ -1372,7 +1372,7 @@ export function createMetaTools(
|
|
|
1372
1372
|
};
|
|
1373
1373
|
}
|
|
1374
1374
|
|
|
1375
|
-
const SEARCH_DESC = `Use top-level search only for exactly one unreduced read, then call_tool, or for write-capable work, then call_destructive_tool. For read-only reduction, dependent or multiple calls, never search here: make one execute_code program that searches and calls. Use 2–4 distinctive action/object terms, not the full request; set connector to the obvious integration id to load one catalog instead of all; omit limit initially (default ${DEFAULT_SEARCH_LIMIT}), page to ${MAX_SEARCH_LIMIT} if needed. safety="readOnly" returns only calls available to call_tool/code; "approvalRequired" returns the rest; omitted/"all" returns all. This filters results, not authority. includeSchemas="compact" adds the input and any declared output shape, bounded; plain objects expose inputKeys, requiredInputKeys, and outputKeys; truncation flags mark incomplete shapes; matches also carry declared annotations. Require purpose/address fit plus compatible inputs, truncation, safety, and outputs — never the first lexical match. Empty query browses all.`;
|
|
1375
|
+
const SEARCH_DESC = `Use top-level search only for exactly one unreduced read, then call_tool, or for write-capable work, then call_destructive_tool. For read-only reduction, dependent or multiple calls, never search here: make one execute_code program that searches and calls. Use 2–4 distinctive action/object terms, not the full request; set connector to the obvious integration id to load one catalog instead of all; omit limit initially (default ${DEFAULT_SEARCH_LIMIT}), page to ${MAX_SEARCH_LIMIT} if needed. safety="readOnly" returns only calls available to call_tool/code; "approvalRequired" returns the rest; omitted/"all" returns all. This filters results, not authority. includeSchemas="compact" adds the input and any declared output shape, bounded; plain objects expose inputKeys, requiredInputKeys, and outputKeys; truncation flags mark incomplete shapes; matches also carry declared annotations. Require purpose/address fit plus compatible inputs, truncation, safety, and outputs — never the first lexical match. Empty or whitespace-only query browses all; non-empty input with no ASCII terms returns no match.`;
|
|
1376
1376
|
const CALL_DESC =
|
|
1377
1377
|
'Use for ONE tool explicitly annotated readOnlyHint: true — the cheapest path for a single cold call. For two or more calls, dependent steps, loops, joins, or data reduction use execute_code, whose connecta.call and connecta.batch reach the same tools. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths; traverse arrays with [] (for example results[].id). Misses return data plus `$connecta` feedback. resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
|
|
1378
1378
|
const CALL_DESTRUCTIVE_DESC =
|