@zackbart/connecta 0.10.1 → 0.10.3

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 (78) hide show
  1. package/AGENTS.md +113 -0
  2. package/CHANGELOG.md +70 -0
  3. package/README.md +53 -9
  4. package/bin/connecta.mjs +272 -0
  5. package/dist/catalog-service.d.ts +40 -1
  6. package/dist/catalog-service.d.ts.map +1 -1
  7. package/dist/catalog-service.js +137 -12
  8. package/dist/catalog-service.js.map +1 -1
  9. package/dist/catalog.d.ts +17 -0
  10. package/dist/catalog.d.ts.map +1 -1
  11. package/dist/catalog.js +113 -13
  12. package/dist/catalog.js.map +1 -1
  13. package/dist/errors.d.ts +28 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +40 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/execute.d.ts +45 -1
  18. package/dist/execute.d.ts.map +1 -1
  19. package/dist/execute.js +265 -68
  20. package/dist/execute.js.map +1 -1
  21. package/dist/invocation.d.ts.map +1 -1
  22. package/dist/invocation.js +34 -6
  23. package/dist/invocation.js.map +1 -1
  24. package/dist/meta-tools.d.ts +1 -0
  25. package/dist/meta-tools.d.ts.map +1 -1
  26. package/dist/meta-tools.js +412 -12
  27. package/dist/meta-tools.js.map +1 -1
  28. package/dist/skills.d.ts +1 -1
  29. package/dist/skills.d.ts.map +1 -1
  30. package/dist/skills.js +1 -1
  31. package/dist/tool-safety.d.ts +10 -0
  32. package/dist/tool-safety.d.ts.map +1 -0
  33. package/dist/tool-safety.js +12 -0
  34. package/dist/tool-safety.js.map +1 -0
  35. package/dist/validate.d.ts.map +1 -1
  36. package/dist/validate.js +100 -1
  37. package/dist/validate.js.map +1 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/documentation/architecture.md +7 -0
  41. package/documentation/auth.md +58 -0
  42. package/documentation/call-admission.md +7 -0
  43. package/documentation/code-first-exploration.md +292 -0
  44. package/documentation/code-mode.md +696 -0
  45. package/documentation/connector-guides.md +7 -0
  46. package/documentation/connectors.md +69 -0
  47. package/documentation/mcp-2026-07-28.md +46 -0
  48. package/documentation/meta-tools.md +185 -0
  49. package/documentation/operations.md +7 -0
  50. package/documentation/operator-ui.md +7 -0
  51. package/documentation/request-admission.md +7 -0
  52. package/documentation/storage-and-credentials.md +54 -0
  53. package/ethos.md +132 -0
  54. package/examples/node/README.md +53 -0
  55. package/examples/node/src/index.ts +73 -0
  56. package/examples/worker/README.md +160 -0
  57. package/examples/worker/src/cloudflare-kv.ts +43 -0
  58. package/examples/worker/src/d1-activity-row.ts +100 -0
  59. package/examples/worker/src/d1-activity.ts +144 -0
  60. package/examples/worker/src/index.ts +136 -0
  61. package/examples/worker/wrangler.jsonc +26 -0
  62. package/package.json +11 -1
  63. package/src/catalog-service.ts +181 -16
  64. package/src/catalog.ts +143 -12
  65. package/src/errors.ts +88 -1
  66. package/src/execute.ts +372 -96
  67. package/src/invocation.ts +45 -8
  68. package/src/meta-tools.ts +506 -11
  69. package/src/skills.ts +1 -1
  70. package/src/tool-safety.ts +15 -0
  71. package/src/validate.ts +128 -0
  72. package/src/version.ts +1 -1
  73. package/templates/node/.env.example +5 -0
  74. package/templates/node/AGENTS.md +19 -0
  75. package/templates/node/README.md +33 -0
  76. package/templates/node/package.json +23 -0
  77. package/templates/node/src/index.ts +43 -0
  78. package/templates/node/tsconfig.json +12 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zackbart/connecta",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
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.",
@@ -11,6 +11,9 @@
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
14
+ "bin": {
15
+ "connecta": "./bin/connecta.mjs"
16
+ },
14
17
  "repository": {
15
18
  "type": "git",
16
19
  "url": "git+https://github.com/zackbart/connecta.git"
@@ -26,9 +29,16 @@
26
29
  "connectors"
27
30
  ],
28
31
  "files": [
32
+ "bin",
29
33
  "dist",
30
34
  "src",
31
35
  "assets",
36
+ "documentation",
37
+ "examples/node",
38
+ "examples/worker",
39
+ "templates",
40
+ "AGENTS.md",
41
+ "ethos.md",
32
42
  "README.md",
33
43
  "CHANGELOG.md",
34
44
  "SECURITY.md",
@@ -1,9 +1,12 @@
1
1
  import {
2
+ compactDiscoverySchema,
2
3
  compactSchema,
3
4
  lexicalCorpusStatistics,
5
+ lexicalQueryTerms,
4
6
  lexicalSearchQuery,
5
7
  rankTools,
6
8
  schemaObjectKeys,
9
+ summarizeDiscoveryDescription,
7
10
  summarizeDescription,
8
11
  } from "./catalog.js";
9
12
  import {
@@ -25,6 +28,7 @@ import {
25
28
  normalizeTimeoutMs,
26
29
  withAbortableTimeout,
27
30
  } from "./timeout.js";
31
+ import { isExplicitlyReadOnly } from "./tool-safety.js";
28
32
  import type {
29
33
  Connector,
30
34
  JsonSchema,
@@ -35,6 +39,8 @@ export const DEFAULT_SEARCH_LIMIT = 8;
35
39
  export const MAX_SEARCH_LIMIT = 100;
36
40
  export const MAX_DESCRIBE_ADDRESSES = 100;
37
41
  export const MAX_DISCOVERY_RESULT_BYTES = 256_000;
42
+ const MAX_QUERY_ANALYSIS_TERMS = 8;
43
+ const MAX_QUERY_ANALYSIS_TERM_LENGTH = 64;
38
44
 
39
45
  const encoder = new TextEncoder();
40
46
 
@@ -101,6 +107,11 @@ export function boundedDiscoveryText(value: unknown, hint: string): string {
101
107
  export interface CatalogSearchArgs {
102
108
  query?: string;
103
109
  connector?: string;
110
+ /**
111
+ * Result classification only; never changes which tools exist or what may
112
+ * execute. Omitted and "all" preserve the complete configured catalog.
113
+ */
114
+ safety?: "readOnly" | "approvalRequired" | "all";
104
115
  limit?: number;
105
116
  offset?: number;
106
117
  fullDescriptions?: boolean;
@@ -109,6 +120,35 @@ export interface CatalogSearchArgs {
109
120
  includeSchemaKeys?: boolean;
110
121
  }
111
122
 
123
+ function discoverySafety(
124
+ value: unknown,
125
+ ): "readOnly" | "approvalRequired" | "all" {
126
+ if (value === undefined) return "all";
127
+ if (
128
+ value !== "readOnly" &&
129
+ value !== "approvalRequired" &&
130
+ value !== "all"
131
+ ) {
132
+ throw new DiscoveryPolicyError(
133
+ "invalid_args",
134
+ 'safety must be "readOnly", "approvalRequired", or "all".',
135
+ );
136
+ }
137
+ return value;
138
+ }
139
+
140
+ function toolsForSafety(
141
+ tools: ToolDef[],
142
+ safety: "readOnly" | "approvalRequired" | "all",
143
+ ): ToolDef[] {
144
+ if (safety === "all") return tools;
145
+ return tools.filter((tool) =>
146
+ safety === "readOnly"
147
+ ? isExplicitlyReadOnly(tool)
148
+ : !isExplicitlyReadOnly(tool),
149
+ );
150
+ }
151
+
112
152
  export interface CatalogDescribeArgs {
113
153
  addresses?: unknown;
114
154
  format?: "compact" | "json";
@@ -124,6 +164,8 @@ interface CatalogSearchEntry {
124
164
  description?: string;
125
165
  inputSchema?: unknown;
126
166
  outputSchema?: unknown;
167
+ inputSchemaTruncated?: true;
168
+ outputSchemaTruncated?: true;
127
169
  inputKeys?: string[];
128
170
  requiredInputKeys?: string[];
129
171
  outputKeys?: string[];
@@ -165,6 +207,16 @@ export interface CatalogSearchPage {
165
207
  hasMore: boolean;
166
208
  nextOffset?: number;
167
209
  matchMode?: "partial";
210
+ queryAnalysis?: {
211
+ representedTerms: string[];
212
+ otherResultTerms: string[];
213
+ unmatchedTerms: string[];
214
+ truncated?: true;
215
+ connectorScope?: string;
216
+ unknownConnector?: true;
217
+ unavailableConnectorCount?: number;
218
+ guidance?: string;
219
+ };
168
220
  }
169
221
 
170
222
  export interface CatalogDescription {
@@ -203,6 +255,15 @@ function renderSchema(schema: JsonSchema, format: "compact" | "json"): unknown {
203
255
  return format === "json" ? schema : compactSchema(schema);
204
256
  }
205
257
 
258
+ function renderSearchSchema(
259
+ schema: JsonSchema,
260
+ format: "compact" | "json",
261
+ ): { schema: unknown; truncated: boolean } {
262
+ if (format === "json") return { schema, truncated: false };
263
+ const compact = compactDiscoverySchema(schema);
264
+ return { schema: compact.text, truncated: compact.truncated };
265
+ }
266
+
206
267
  /**
207
268
  * Request-local catalog operations shared by MCP meta-tools and code mode.
208
269
  * Successful catalogs may be reused inside this request; failures are not
@@ -397,12 +458,16 @@ export class CatalogService {
397
458
  async search(args: CatalogSearchArgs): Promise<CatalogSearchPage> {
398
459
  const query = args.query ?? "";
399
460
  const retrievalQuery = lexicalSearchQuery(query);
461
+ const safety = discoverySafety(args.safety);
400
462
  const limit = discoverySearchLimit(args.limit);
401
463
  const offset = Math.max(0, Math.trunc(args.offset ?? 0));
464
+ const scopedConnector = args.connector
465
+ ? this.registry.getConnector(args.connector)
466
+ : undefined;
402
467
  const connectors = args.connector
403
- ? [this.registry.getConnector(args.connector)].filter(
404
- (connector): connector is Connector => Boolean(connector),
405
- )
468
+ ? scopedConnector
469
+ ? [scopedConnector]
470
+ : []
406
471
  : this.registry.listConnectors();
407
472
  const catalogs = await mapSettledWithConcurrency(
408
473
  connectors,
@@ -413,6 +478,14 @@ export class CatalogService {
413
478
  `search_tools probe of "${connector.id}"`,
414
479
  ),
415
480
  );
481
+ const searchableCatalogs = catalogs.map((catalog) =>
482
+ catalog.status === "fulfilled"
483
+ ? {
484
+ status: "fulfilled" as const,
485
+ value: toolsForSafety(catalog.value, safety),
486
+ }
487
+ : catalog,
488
+ );
416
489
  const matches: Array<{
417
490
  connector: Connector;
418
491
  tool: ToolDef;
@@ -421,7 +494,7 @@ export class CatalogService {
421
494
  }> = [];
422
495
  let matchMode: "all" | "partial" = "all";
423
496
  const statistics = lexicalCorpusStatistics(
424
- catalogs.flatMap((catalog) =>
497
+ searchableCatalogs.flatMap((catalog) =>
425
498
  catalog.status === "fulfilled" ? [catalog.value] : [],
426
499
  ),
427
500
  retrievalQuery,
@@ -429,7 +502,7 @@ export class CatalogService {
429
502
  const collectMatches = (mode: "all" | "partial") => {
430
503
  matches.length = 0;
431
504
  let orderBase = 0;
432
- catalogs.forEach((catalog, connectorIndex) => {
505
+ searchableCatalogs.forEach((catalog, connectorIndex) => {
433
506
  const connector = connectors[connectorIndex];
434
507
  if (!connector) {
435
508
  throw new Error("Catalog result has no corresponding connector");
@@ -459,9 +532,17 @@ export class CatalogService {
459
532
  collectMatches(matchMode);
460
533
  }
461
534
  matches.sort((a, b) => b.score - a.score || a.order - b.order);
462
- const entries = matches.slice(offset, offset + limit).map((match) => {
535
+ const pageMatches = matches.slice(offset, offset + limit);
536
+ const entries = pageMatches.map((match) => {
463
537
  const input = match.tool.inputSchema ?? { type: "object" };
464
- const description = summarizeDescription(
538
+ const renderedInput = args.includeSchemas
539
+ ? renderSearchSchema(input, args.includeSchemas)
540
+ : undefined;
541
+ const renderedOutput =
542
+ args.includeSchemas && match.tool.outputSchema
543
+ ? renderSearchSchema(match.tool.outputSchema, args.includeSchemas)
544
+ : undefined;
545
+ const description = summarizeDiscoveryDescription(
465
546
  match.tool.description,
466
547
  args.fullDescriptions === true,
467
548
  );
@@ -476,16 +557,20 @@ export class CatalogService {
476
557
  ...(description !== undefined ? { description } : {}),
477
558
  ...(args.includeSchemas
478
559
  ? {
479
- inputSchema:
480
- renderSchema(input, args.includeSchemas),
560
+ inputSchema: renderedInput?.schema,
481
561
  }
482
562
  : {}),
563
+ ...(renderedInput?.truncated
564
+ ? { inputSchemaTruncated: true as const }
565
+ : {}),
483
566
  ...(args.includeSchemas && match.tool.outputSchema
484
567
  ? {
485
- outputSchema:
486
- renderSchema(match.tool.outputSchema, args.includeSchemas),
568
+ outputSchema: renderedOutput?.schema,
487
569
  }
488
570
  : {}),
571
+ ...(renderedOutput?.truncated
572
+ ? { outputSchemaTruncated: true as const }
573
+ : {}),
489
574
  ...(args.includeSchemas && args.includeSchemaKeys
490
575
  ? schemaKeyMetadata(input, match.tool.outputSchema)
491
576
  : {}),
@@ -499,6 +584,62 @@ export class CatalogService {
499
584
  offset + entries.length < matches.length
500
585
  ? offset + entries.length
501
586
  : undefined;
587
+ const queryTerms = lexicalQueryTerms(retrievalQuery);
588
+ const analyzedTerms = queryTerms.slice(0, MAX_QUERY_ANALYSIS_TERMS);
589
+ const displayTerm = (term: string) =>
590
+ term.length <= MAX_QUERY_ANALYSIS_TERM_LENGTH
591
+ ? term
592
+ : `${term.slice(0, MAX_QUERY_ANALYSIS_TERM_LENGTH - 1)}…`;
593
+ const pageTools = new Set(pageMatches.map((match) => match.tool));
594
+ const matchingTools = (term: string) =>
595
+ new Set([
596
+ ...(statistics.nameMatches.get(term) ?? []),
597
+ ...(statistics.descriptionMatches.get(term) ?? []),
598
+ ]);
599
+ const representedTerms: string[] = [];
600
+ const otherResultTerms: string[] = [];
601
+ const unmatchedTerms: string[] = [];
602
+ for (const term of analyzedTerms) {
603
+ const termTools = matchingTools(term);
604
+ if ([...termTools].some((tool) => pageTools.has(tool))) {
605
+ representedTerms.push(displayTerm(term));
606
+ } else if (termTools.size > 0) {
607
+ otherResultTerms.push(displayTerm(term));
608
+ } else {
609
+ unmatchedTerms.push(displayTerm(term));
610
+ }
611
+ }
612
+ const unavailableCatalogs = catalogs.filter(
613
+ (catalog) => catalog.status === "rejected",
614
+ ).length;
615
+ const safetyLabel =
616
+ safety === "readOnly"
617
+ ? "read-only "
618
+ : safety === "approvalRequired"
619
+ ? "approval-required "
620
+ : "";
621
+ const filterRecovery =
622
+ safety === "all" ? "" : " Change safety to inspect the other tools.";
623
+ const guidance =
624
+ queryTerms.length === 0
625
+ ? undefined
626
+ : matches.length === 0
627
+ ? args.connector && !scopedConnector
628
+ ? `Connector "${args.connector}" is not configured in this deployment. Omit connector to search all configured tools.`
629
+ : scopedConnector
630
+ ? unavailableCatalogs > 0
631
+ ? `Connector "${scopedConnector.id}" could not be searched because its catalog was unavailable. Retry later.`
632
+ : `No matching ${safetyLabel}capability was found on connector "${scopedConnector.id}". Refine terms or browse it with an empty query.${filterRecovery}`
633
+ : unavailableCatalogs === 0
634
+ ? `No matching ${safetyLabel}capability is configured in this deployment. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`
635
+ : `No matching ${safetyLabel}capability was found in the catalogs that answered; ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`
636
+ : matchMode === "partial"
637
+ ? scopedConnector
638
+ ? `No single tool on connector "${scopedConnector.id}" matched every term. Split distinct intents into separate searches.`
639
+ : unavailableCatalogs === 0
640
+ ? "No single tool matched every term. Split distinct intents into separate searches."
641
+ : "No single tool matched every term in the catalogs that answered. Split distinct intents into separate searches."
642
+ : undefined;
502
643
  return {
503
644
  entries,
504
645
  total: matches.length,
@@ -509,6 +650,29 @@ export class CatalogService {
509
650
  ...(matchMode === "partial" && matches.length > 0
510
651
  ? { matchMode }
511
652
  : {}),
653
+ ...(queryTerms.length > 0 && matchMode === "partial"
654
+ ? {
655
+ queryAnalysis: {
656
+ representedTerms,
657
+ otherResultTerms,
658
+ unmatchedTerms,
659
+ ...(queryTerms.length > analyzedTerms.length ||
660
+ analyzedTerms.some(
661
+ (term) => term.length > MAX_QUERY_ANALYSIS_TERM_LENGTH,
662
+ )
663
+ ? { truncated: true as const }
664
+ : {}),
665
+ ...(args.connector ? { connectorScope: args.connector } : {}),
666
+ ...(args.connector && !scopedConnector
667
+ ? { unknownConnector: true as const }
668
+ : {}),
669
+ ...(unavailableCatalogs > 0
670
+ ? { unavailableConnectorCount: unavailableCatalogs }
671
+ : {}),
672
+ ...(guidance ? { guidance } : {}),
673
+ },
674
+ }
675
+ : {}),
512
676
  };
513
677
  }
514
678
 
@@ -592,7 +756,6 @@ export function groupedSearchResult(page: CatalogSearchPage) {
592
756
  const groups: Array<{
593
757
  id: string;
594
758
  title?: string;
595
- description?: string;
596
759
  guide?: string;
597
760
  tools: CatalogSearchEntry["tool"][];
598
761
  }> = [];
@@ -605,9 +768,6 @@ export function groupedSearchResult(page: CatalogSearchPage) {
605
768
  const group: (typeof groups)[number] = {
606
769
  id: entry.connector.id,
607
770
  ...(entry.connector.title ? { title: entry.connector.title } : {}),
608
- ...(entry.connector.description !== undefined
609
- ? { description: entry.connector.description }
610
- : {}),
611
771
  ...(entry.guide ? { guide: entry.guide } : {}),
612
772
  tools: [],
613
773
  };
@@ -624,17 +784,22 @@ export function groupedSearchResult(page: CatalogSearchPage) {
624
784
  hasMore: page.hasMore,
625
785
  ...(page.nextOffset !== undefined ? { nextOffset: page.nextOffset } : {}),
626
786
  ...(page.matchMode ? { matchMode: page.matchMode } : {}),
787
+ ...(page.queryAnalysis ? { queryAnalysis: page.queryAnalysis } : {}),
627
788
  };
628
789
  }
629
790
 
630
791
  export function flatSearchResult(page: CatalogSearchPage) {
631
792
  return {
632
- tools: page.entries.map((entry) => entry.tool),
793
+ tools: page.entries.map((entry) => ({
794
+ ...entry.tool,
795
+ ...(entry.guide ? { guide: entry.guide } : {}),
796
+ })),
633
797
  total: page.total,
634
798
  offset: page.offset,
635
799
  limit: page.limit,
636
800
  hasMore: page.hasMore,
637
801
  ...(page.nextOffset !== undefined ? { nextOffset: page.nextOffset } : {}),
638
802
  ...(page.matchMode ? { matchMode: page.matchMode } : {}),
803
+ ...(page.queryAnalysis ? { queryAnalysis: page.queryAnalysis } : {}),
639
804
  };
640
805
  }
package/src/catalog.ts CHANGED
@@ -1,16 +1,35 @@
1
1
  import type { JsonSchema, ToolDef } from "./types.js";
2
2
 
3
3
  const DEFAULT_DESCRIPTION_LENGTH = 240;
4
+ const DISCOVERY_DESCRIPTION_LENGTH = 160;
5
+ export const MAX_COMPACT_DISCOVERY_SCHEMA_BYTES = 1_024;
6
+ const schemaEncoder = new TextEncoder();
7
+ const COMPACT_DISCOVERY_TRUNCATION = " /* truncated */";
4
8
 
5
9
  export function summarizeDescription(
6
10
  text: string | undefined,
7
11
  full: boolean,
12
+ ): string | undefined {
13
+ return summarizeToLength(text, full, DEFAULT_DESCRIPTION_LENGTH);
14
+ }
15
+
16
+ export function summarizeDiscoveryDescription(
17
+ text: string | undefined,
18
+ full: boolean,
19
+ ): string | undefined {
20
+ return summarizeToLength(text, full, DISCOVERY_DESCRIPTION_LENGTH);
21
+ }
22
+
23
+ function summarizeToLength(
24
+ text: string | undefined,
25
+ full: boolean,
26
+ maxLength: number,
8
27
  ): string | undefined {
9
28
  if (!text) return undefined;
10
29
  if (full) return text;
11
30
  const compact = text.replace(/\s+/g, " ").trim();
12
- if (compact.length <= DEFAULT_DESCRIPTION_LENGTH) return compact;
13
- return `${compact.slice(0, DEFAULT_DESCRIPTION_LENGTH - 1).trimEnd()}…`;
31
+ if (compact.length <= maxLength) return compact;
32
+ return `${compact.slice(0, maxLength - 1).trimEnd()}…`;
14
33
  }
15
34
 
16
35
  function lexicalTokens(text: string): string[] {
@@ -28,6 +47,11 @@ function normalized(text: string): string {
28
47
  return lexicalTokens(text).join(" ");
29
48
  }
30
49
 
50
+ /** Distinct normalized terms in query order, shared by ranking and feedback. */
51
+ export function lexicalQueryTerms(query: string): string[] {
52
+ return [...new Set(lexicalTokens(query))];
53
+ }
54
+
31
55
  /**
32
56
  * Conversational framing selected by the #188 research run before the #189
33
57
  * holdout existed. Action-bearing terms such as get/list/search/find/create
@@ -208,7 +232,7 @@ export function lexicalCorpusStatistics(
208
232
  toolSets: ToolDef[][],
209
233
  query: string,
210
234
  ): LexicalCorpusStatistics {
211
- const terms = [...new Set(lexicalTokens(query))];
235
+ const terms = lexicalQueryTerms(query);
212
236
  if (terms.length === 0) {
213
237
  return {
214
238
  documentCount: toolSets.reduce(
@@ -386,6 +410,10 @@ function renderSchema(
386
410
  defs: Record<string, unknown>,
387
411
  seen: Set<string>,
388
412
  depth: number,
413
+ options: {
414
+ propertyDescriptions: boolean;
415
+ requiredFirst: boolean;
416
+ },
389
417
  ): string {
390
418
  if (depth > 4) return "…";
391
419
  if (schema === null || typeof schema !== "object") {
@@ -404,10 +432,10 @@ function renderSchema(
404
432
  if (Array.isArray(s.allOf)) {
405
433
  const { allOf: _members, ...own } = s;
406
434
  const parts = declaresShape(own)
407
- ? [renderSchema(own, defs, seen, depth)]
435
+ ? [renderSchema(own, defs, seen, depth, options)]
408
436
  : [];
409
437
  for (const member of s.allOf) {
410
- parts.push(renderSchema(member, defs, seen, depth + 1));
438
+ parts.push(renderSchema(member, defs, seen, depth + 1, options));
411
439
  }
412
440
  if (parts.length === 0) return "unknown";
413
441
  if (parts.length === 1) return parts[0] as string;
@@ -420,7 +448,7 @@ function renderSchema(
420
448
  const target = defs[name];
421
449
  if (target === undefined) return name;
422
450
  seen.add(name);
423
- const rendered = renderSchema(target, defs, seen, depth);
451
+ const rendered = renderSchema(target, defs, seen, depth, options);
424
452
  seen.delete(name);
425
453
  return rendered;
426
454
  }
@@ -428,7 +456,9 @@ function renderSchema(
428
456
  const union = (s.oneOf ?? s.anyOf) as unknown[] | undefined;
429
457
  if (Array.isArray(union)) {
430
458
  return (
431
- union.map((u) => renderSchema(u, defs, seen, depth + 1)).join(" | ") ||
459
+ union
460
+ .map((u) => renderSchema(u, defs, seen, depth + 1, options))
461
+ .join(" | ") ||
432
462
  "unknown"
433
463
  );
434
464
  }
@@ -444,7 +474,7 @@ function renderSchema(
444
474
  const type = s.type;
445
475
  if (type === "array" || s.items) {
446
476
  const items = s.items
447
- ? renderSchema(s.items, defs, seen, depth + 1)
477
+ ? renderSchema(s.items, defs, seen, depth + 1, options)
448
478
  : "unknown";
449
479
  return `${items}[]`;
450
480
  }
@@ -453,17 +483,31 @@ function renderSchema(
453
483
  const required = new Set(
454
484
  (Array.isArray(s.required) ? s.required : []) as string[],
455
485
  );
456
- const keys = Object.keys(props);
486
+ const declaredKeys = Object.keys(props);
487
+ const keys = options.requiredFirst
488
+ ? [
489
+ ...declaredKeys.filter((key) => required.has(key)),
490
+ ...declaredKeys.filter((key) => !required.has(key)),
491
+ ]
492
+ : declaredKeys;
457
493
  if (keys.length === 0) return "{}";
458
494
  return `{ ${keys
459
495
  .map((key) => {
460
496
  const optional = required.has(key) ? "" : "?";
461
- const rendered = renderSchema(props[key], defs, seen, depth + 1);
497
+ const rendered = renderSchema(
498
+ props[key],
499
+ defs,
500
+ seen,
501
+ depth + 1,
502
+ options,
503
+ );
462
504
  const description = (
463
505
  props[key] as Record<string, unknown> | null
464
506
  )?.description;
465
507
  const comment =
466
- typeof description === "string" ? ` // ${description}` : "";
508
+ options.propertyDescriptions && typeof description === "string"
509
+ ? ` // ${description}`
510
+ : "";
467
511
  return `${key}${optional}: ${rendered}${comment}`;
468
512
  })
469
513
  .join(", ")} }`;
@@ -485,7 +529,10 @@ export function compactSchema(schema: JsonSchema): string {
485
529
  };
486
530
  let rendered: string;
487
531
  try {
488
- rendered = renderSchema(schema, defs, new Set(), 0);
532
+ rendered = renderSchema(schema, defs, new Set(), 0, {
533
+ propertyDescriptions: true,
534
+ requiredFirst: false,
535
+ });
489
536
  } catch {
490
537
  rendered = JSON.stringify(schema);
491
538
  }
@@ -493,6 +540,90 @@ export function compactSchema(schema: JsonSchema): string {
493
540
  return rendered;
494
541
  }
495
542
 
543
+ export interface CompactDiscoverySchema {
544
+ text: string;
545
+ truncated: boolean;
546
+ }
547
+
548
+ const compactDiscoverySchemas = new WeakMap<
549
+ JsonSchema,
550
+ CompactDiscoverySchema
551
+ >();
552
+
553
+ /**
554
+ * A valid, bounded replacement for a discovery shape too large to carry.
555
+ *
556
+ * Required object keys come first and every retained key is JSON-quoted, so
557
+ * arbitrary downstream names remain valid TypeScript property signatures.
558
+ * Types become `unknown`: pretending a severed nested type is exact would be
559
+ * worse than making the existing truncation flag's recovery route explicit.
560
+ */
561
+ function truncatedDiscoverySchema(schema: JsonSchema): string {
562
+ const keys = schemaObjectKeys(schema);
563
+ if (!keys) return `unknown${COMPACT_DISCOVERY_TRUNCATION}`;
564
+ const required = new Set(keys.required);
565
+ const ordered = [
566
+ ...keys.properties.filter((key) => required.has(key)),
567
+ ...keys.properties.filter((key) => !required.has(key)),
568
+ ];
569
+ const parts: string[] = [];
570
+ for (const key of ordered) {
571
+ const part = `${JSON.stringify(key)}${required.has(key) ? "" : "?"}: unknown`;
572
+ const candidate = `{ ${[...parts, part].join(", ")} }${COMPACT_DISCOVERY_TRUNCATION}`;
573
+ if (
574
+ schemaEncoder.encode(candidate).length >
575
+ MAX_COMPACT_DISCOVERY_SCHEMA_BYTES
576
+ ) {
577
+ break;
578
+ }
579
+ parts.push(part);
580
+ }
581
+ if (parts.length === 0 && ordered.length > 0) {
582
+ return `unknown${COMPACT_DISCOVERY_TRUNCATION}`;
583
+ }
584
+ return `{ ${parts.join(", ")} }${COMPACT_DISCOVERY_TRUNCATION}`;
585
+ }
586
+
587
+ /**
588
+ * Render the schema shape carried by search results.
589
+ *
590
+ * Search is a routing step, so repeated property prose does not earn its
591
+ * context cost there. Required inputs render first, and the result has a hard
592
+ * UTF-8 budget; exact JSON and the prose-rich compact rendering remain
593
+ * available through the existing full retrieval paths.
594
+ */
595
+ export function compactDiscoverySchema(
596
+ schema: JsonSchema,
597
+ ): CompactDiscoverySchema {
598
+ const cached = compactDiscoverySchemas.get(schema);
599
+ if (cached) return cached;
600
+ const defs = {
601
+ ...(schema.$defs as Record<string, unknown>),
602
+ ...(schema.definitions as Record<string, unknown>),
603
+ };
604
+ let rendered: string;
605
+ try {
606
+ rendered = renderSchema(schema, defs, new Set(), 0, {
607
+ propertyDescriptions: false,
608
+ requiredFirst: true,
609
+ });
610
+ } catch {
611
+ rendered = JSON.stringify(schema);
612
+ }
613
+ const bytes = schemaEncoder.encode(rendered);
614
+ let result: CompactDiscoverySchema;
615
+ if (bytes.length <= MAX_COMPACT_DISCOVERY_SCHEMA_BYTES) {
616
+ result = { text: rendered, truncated: false };
617
+ } else {
618
+ result = {
619
+ text: truncatedDiscoverySchema(schema),
620
+ truncated: true,
621
+ };
622
+ }
623
+ compactDiscoverySchemas.set(schema, result);
624
+ return result;
625
+ }
626
+
496
627
  /** The property and required names a schema resolves to, or undefined. */
497
628
  export interface SchemaObjectKeys {
498
629
  properties: string[];