@zackbart/connecta 0.14.2 → 0.15.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/dist/catalog-service.d.ts.map +1 -1
  3. package/dist/catalog-service.js +104 -56
  4. package/dist/catalog-service.js.map +1 -1
  5. package/dist/catalog.d.ts +8 -2
  6. package/dist/catalog.d.ts.map +1 -1
  7. package/dist/catalog.js +58 -9
  8. package/dist/catalog.js.map +1 -1
  9. package/dist/connectors/api.d.ts +4 -4
  10. package/dist/connectors/api.js +4 -4
  11. package/dist/execute.d.ts.map +1 -1
  12. package/dist/execute.js +8 -7
  13. package/dist/execute.js.map +1 -1
  14. package/dist/meta-tools.js +1 -1
  15. package/dist/meta-tools.js.map +1 -1
  16. package/dist/providers/cloudflare.d.ts +5 -1
  17. package/dist/providers/cloudflare.d.ts.map +1 -1
  18. package/dist/providers/cloudflare.js +203 -70
  19. package/dist/providers/cloudflare.js.map +1 -1
  20. package/dist/providers/linear.d.ts.map +1 -1
  21. package/dist/providers/linear.js +4 -3
  22. package/dist/providers/linear.js.map +1 -1
  23. package/dist/providers/mixpanel.d.ts.map +1 -1
  24. package/dist/providers/mixpanel.js +4 -3
  25. package/dist/providers/mixpanel.js.map +1 -1
  26. package/dist/providers/stripe.d.ts.map +1 -1
  27. package/dist/providers/stripe.js +4 -3
  28. package/dist/providers/stripe.js.map +1 -1
  29. package/dist/validate.d.ts.map +1 -1
  30. package/dist/validate.js +97 -17
  31. package/dist/validate.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/documentation/cloudflare.md +37 -19
  35. package/documentation/code-mode.md +9 -9
  36. package/documentation/connectors.md +9 -7
  37. package/documentation/linear.md +6 -7
  38. package/documentation/meta-tools.md +56 -13
  39. package/documentation/mixpanel.md +6 -7
  40. package/documentation/stripe.md +12 -12
  41. package/ethos.md +2 -1
  42. package/package.json +1 -1
  43. package/src/catalog-service.ts +93 -29
  44. package/src/catalog.ts +71 -8
  45. package/src/connectors/api.ts +4 -4
  46. package/src/execute.ts +8 -7
  47. package/src/meta-tools.ts +1 -1
  48. package/src/providers/cloudflare.ts +275 -79
  49. package/src/providers/linear.ts +4 -3
  50. package/src/providers/mixpanel.ts +4 -3
  51. package/src/providers/stripe.ts +4 -3
  52. package/src/validate.ts +125 -20
  53. package/src/version.ts +1 -1
  54. package/templates/node/package.json +1 -1
@@ -137,18 +137,18 @@ exists rather than a fresh object appearing beside it. Additive writes
137
137
  `readOnlyHint: false` already routes them through `call_destructive_tool`, and
138
138
  asserting destruction only inflates the approval copy the host shows a human.
139
139
 
140
- That classification is **fill-in only**, and unconditionally so: it supplies
141
- the annotations Stripe leaves unset — Stripe documents no MCP annotations at
142
- all and contradicts an explicit downstream annotation in neither direction. A
143
- tool on the read allowlist arriving with `destructiveHint: true` or
144
- `readOnlyHint: false` keeps exactly what the downstream said and stays behind
145
- `call_destructive_tool`. A tool on neither maintained list arriving with
146
- `readOnlyHint: true` keeps that too, and stays callable from `execute_code`.
147
- Both are the downstream telling you this release's allowlist is stale, and on a
148
- name no release has reviewed its word is the only evidence there is. The one
149
- classification that still outranks the downstream is a name this release
150
- reviewed and filed destructive: a `create_refund` claiming `readOnlyHint: true`
151
- is a downstream bug rather than news, and stays on the approval path.
140
+ That classification fills in downstream silence and otherwise preserves
141
+ explicit annotations. It supplies the annotations Stripe leaves unset — Stripe
142
+ documents no MCP annotations at all. A tool on the read allowlist arriving with
143
+ `destructiveHint: true` or `readOnlyHint: false` keeps exactly what the
144
+ downstream said and stays behind `call_destructive_tool`. A tool on neither
145
+ maintained list arriving with `readOnlyHint: true` keeps that too, and stays
146
+ callable from `execute_code`. Both are the downstream telling you this release's
147
+ allowlist is stale, and on a name no release has reviewed its word is the only
148
+ evidence there is. One narrow fail-closed exception applies to a name this
149
+ release reviewed and filed destructive: a `create_refund` claiming
150
+ `readOnlyHint: true` is a downstream bug rather than news, and stays on the
151
+ approval path.
152
152
 
153
153
  An unfamiliar tool that annotates nothing fails closed onto
154
154
  `call_destructive_tool` until a Connecta release reviews it. That is not
package/ethos.md CHANGED
@@ -69,7 +69,7 @@ proposing one without a new argument is not.
69
69
  | Multi-tenancy / account model | refused | one deployment per tenant; deploy again instead |
70
70
  | Policy engine, approvals, pauses | refused | the host asks the human; connecta only annotates |
71
71
  | Runtime connector registration | refused | config-as-code is the security model |
72
- | Prebuilt connections as the preferred authoring path | accepted | an a-la-carte provider constructor, imported and constructed in the deployment file, encodes maintained defaults for providers connecta actually uses — preferred *when maintained*, with no promise of one per provider; it returns exactly one ordinary `Connector` with no extra privileges — never a bundle, a group, a preset, or a registry — its tools are hand-written or proxied from a downstream MCP catalog, never generated from a schema document; its vetted annotations classify what the downstream leaves unannotated and never overrule an explicit one; `remoteMcp()` and `api()` stay first-class ([#297](https://github.com/zackbart/connecta/issues/297)) |
72
+ | Prebuilt connections as the preferred authoring path | accepted | an a-la-carte provider constructor, imported and constructed in the deployment file, encodes maintained defaults for providers connecta actually uses — preferred *when maintained*, with no promise of one per provider; it returns exactly one ordinary `Connector` with no extra privileges — never a bundle, a group, a preset, or a registry — its tools are hand-written or proxied from a downstream MCP catalog, never generated from a schema document; its vetted annotations classify what the downstream leaves unannotated and otherwise preserve explicit annotations, with one fail-closed exception: a release-reviewed destructive classification outranks a contradictory downstream `readOnlyHint: true`, because Connecta has independently established that the tool mutates existing state; `remoteMcp()` and `api()` stay first-class ([#297](https://github.com/zackbart/connecta/issues/297), [#315](https://github.com/zackbart/connecta/issues/315)) |
73
73
  | Guarded raw REST escape hatches in a prebuilt connection | accepted | a large, fast-moving provider cannot be honestly represented by a small frozen list: a GET-only tool may expose provider-relative reads, while JSON mutations and explicit-content uploads stay separate and always cross the destructive boundary; the connector owns authentication, rate limits, error mapping, URL confinement, and safe method classification, while the provider token remains the capability boundary — this is not schema ingestion, runtime connector registration, or permission widening |
74
74
  | Provider registry / integration marketplace | refused | prebuilt connections are imports, not listings; discovery happens in documentation, never at runtime ([#297](https://github.com/zackbart/connecta/issues/297)) |
75
75
  | Protocol sessions & server push | refused | stateless per request |
@@ -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.14.2",
3
+ "version": "0.15.1",
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.",
@@ -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 MAX_QUERY_ANALYSIS_TERMS = 8;
49
- const MAX_QUERY_ANALYSIS_TERM_LENGTH = 64;
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 ? { outputKeys: outputKeys.properties } : {}),
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.length = 0;
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
- matches.push({
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
- collectMatches("all");
686
- if (query.trim() && matches.length === 0) {
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.sort((a, b) => b.score - a.score || a.order - b.order);
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
- // Term-bearing searches report analysis only when the scorer had to
848
- // degrade; a browse has no terms to analyse and normally reports none at
849
- // all. But neither "this catalog is unavailable" nor "there is no such
850
- // connector" is a statement about terms, and answering either browse with
851
- // an empty entry list alone is indistinguishable from a connector that
852
- // simply exposes no tools. The term partitions stay empty on those paths
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
- queryTerms.length > 0
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
- queryTerms.length === 0
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
- ...(queryTerms.length > analyzedTerms.length ||
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
- /** Rank a connector's tools while caching its normalized plain-data index. */
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 score = scoreDocument(doc, phrase, terms, mode, statistics);
364
- if (score !== null) ranked.push({ tool: doc.tool, score, order });
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.map((value) => JSON.stringify(value)).join(" | ");
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: false };
679
+ result = { text: rendered, truncated: enumTruncated };
617
680
  } else {
618
681
  result = {
619
682
  text: truncatedDiscoverySchema(schema),
@@ -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`). This is deliberately asymmetric with
90
- * remote MCP connectors, which stay pass-through: the downstream server is
91
- * authoritative for its own schemas, and re-validating with our JSON Schema
92
- * draft/format semantics could reject calls the downstream would accept.
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) => ({
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. If any result will be reduced — even from one connector call — or work has dependent/multiple calls, loops, joins, or branches, make exactly one execute_code call that searches, selects, calls, and reduces before returning. 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.
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. It runs with NO network, filesystem, timers, or imports — the only capabilities are:
1280
- - Connector globals call <connectorId>.<toolName>(args) with one schema-matching args object. Sanitization: non-[A-Za-z0-9_$] → "_" (my-service.get.thing → my_service.get_thing), leading digit "_" prefix, reserved word → "_" suffix.
1281
- - connecta.call(address, args) and connecta.batch(calls) call raw addresses. Every batch entry is { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }; destructure that, not a bare result.
1282
- - 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. Choose the best compatible match; do not require it to be the only match. Missing outputKeys means inspect outputSchema, not discard the candidate. Compatible means every required key has a task/prior-result value; do not prefer zero required keys. Put every requiredInputKey in call args. For dependencies, match an earlier outputKey to the later requiredInputKey. Use displayed names; [] 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. A missing key list means a non-object shape, not no fields — read the schema.${connectorGuides ? " A match with guideRequired: true is a hard stop: do not call it; describing the exact schema clears only a schema_truncated reason, so for any other reason return the exact guide name, fetch that guide with the top-level skills tool, then write the informed call." : ""}
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. One arg is display-only; live reads use { reads: { name: { address, fixedArgs?, viewArgs? } } }, then markup calls connecta.read(name, args). Read admission is enforced; fixed keys cannot be overridden and undeclared keys fail. It shares the ${emitBudgets.maxBytes}-byte emit budget one budget, not two; a second, over-budget, or invalid call throws catchably. Bytes stay out of context, so the model reads the return value, not the view: return the initial summary from its variables; later reads update only the 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 — there are no timers. Return JSON; large results truncate, so reduce instead of returning raw payloads.
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 =