@zackbart/connecta 0.7.8 → 0.8.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.
Files changed (187) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +67 -144
  3. package/dist/activity.d.ts +1 -1
  4. package/dist/activity.d.ts.map +1 -1
  5. package/dist/activity.js.map +1 -1
  6. package/dist/auth/bearer.d.ts +1 -1
  7. package/dist/auth/bearer.js +4 -4
  8. package/dist/auth/bearer.js.map +1 -1
  9. package/dist/auth/clerk.d.ts +1 -1
  10. package/dist/auth/clerk.js +1 -1
  11. package/dist/call-admission.d.ts +81 -0
  12. package/dist/call-admission.d.ts.map +1 -0
  13. package/dist/call-admission.js +345 -0
  14. package/dist/call-admission.js.map +1 -0
  15. package/dist/catalog-fingerprint.d.ts +20 -0
  16. package/dist/catalog-fingerprint.d.ts.map +1 -0
  17. package/dist/catalog-fingerprint.js +34 -0
  18. package/dist/catalog-fingerprint.js.map +1 -0
  19. package/dist/catalog-limits.d.ts +10 -0
  20. package/dist/catalog-limits.d.ts.map +1 -0
  21. package/dist/catalog-limits.js +10 -0
  22. package/dist/catalog-limits.js.map +1 -0
  23. package/dist/catalog-service.d.ts +138 -0
  24. package/dist/catalog-service.d.ts.map +1 -0
  25. package/dist/catalog-service.js +407 -0
  26. package/dist/catalog-service.js.map +1 -0
  27. package/dist/catalog.js +2 -2
  28. package/dist/catalog.js.map +1 -1
  29. package/dist/concurrency.d.ts +4 -0
  30. package/dist/concurrency.d.ts.map +1 -0
  31. package/dist/concurrency.js +31 -0
  32. package/dist/concurrency.js.map +1 -0
  33. package/dist/connectors/api.d.ts +3 -1
  34. package/dist/connectors/api.d.ts.map +1 -1
  35. package/dist/connectors/api.js +22 -11
  36. package/dist/connectors/api.js.map +1 -1
  37. package/dist/connectors/remote-mcp.d.ts +3 -1
  38. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  39. package/dist/connectors/remote-mcp.js +44 -18
  40. package/dist/connectors/remote-mcp.js.map +1 -1
  41. package/dist/credential-health.d.ts +0 -22
  42. package/dist/credential-health.d.ts.map +1 -1
  43. package/dist/credential-health.js +14 -10
  44. package/dist/credential-health.js.map +1 -1
  45. package/dist/credentials.d.ts +3 -2
  46. package/dist/credentials.d.ts.map +1 -1
  47. package/dist/errors.d.ts +1 -1
  48. package/dist/errors.d.ts.map +1 -1
  49. package/dist/errors.js.map +1 -1
  50. package/dist/execute.d.ts +9 -8
  51. package/dist/execute.d.ts.map +1 -1
  52. package/dist/execute.js +144 -291
  53. package/dist/execute.js.map +1 -1
  54. package/dist/executor-result.d.ts +0 -3
  55. package/dist/executor-result.d.ts.map +1 -1
  56. package/dist/executor-result.js +2 -2
  57. package/dist/executor-result.js.map +1 -1
  58. package/dist/executors/quickjs-child.js +3 -3
  59. package/dist/executors/quickjs-child.js.map +1 -1
  60. package/dist/executors/quickjs-protocol.d.ts +4 -1
  61. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  62. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  63. package/dist/executors/quickjs-runtime.js +7 -2
  64. package/dist/executors/quickjs-runtime.js.map +1 -1
  65. package/dist/executors/quickjs.d.ts.map +1 -1
  66. package/dist/executors/quickjs.js +4 -1
  67. package/dist/executors/quickjs.js.map +1 -1
  68. package/dist/favicon.js +2 -2
  69. package/dist/favicon.js.map +1 -1
  70. package/dist/index.d.ts +6 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +45 -17
  73. package/dist/index.js.map +1 -1
  74. package/dist/invocation.d.ts +96 -0
  75. package/dist/invocation.d.ts.map +1 -0
  76. package/dist/invocation.js +369 -0
  77. package/dist/invocation.js.map +1 -0
  78. package/dist/meta-tools.d.ts +11 -86
  79. package/dist/meta-tools.d.ts.map +1 -1
  80. package/dist/meta-tools.js +145 -485
  81. package/dist/meta-tools.js.map +1 -1
  82. package/dist/operator-ui/generated.d.ts +3 -0
  83. package/dist/operator-ui/generated.d.ts.map +1 -0
  84. package/dist/operator-ui/generated.js +5 -0
  85. package/dist/operator-ui/generated.js.map +1 -0
  86. package/dist/operator-ui/model.d.ts +94 -0
  87. package/dist/operator-ui/model.d.ts.map +1 -0
  88. package/dist/operator-ui/model.js +28 -0
  89. package/dist/operator-ui/model.js.map +1 -0
  90. package/dist/registry.d.ts +47 -6
  91. package/dist/registry.d.ts.map +1 -1
  92. package/dist/registry.js +356 -35
  93. package/dist/registry.js.map +1 -1
  94. package/dist/routes/activity.d.ts +3 -0
  95. package/dist/routes/activity.d.ts.map +1 -0
  96. package/dist/routes/activity.js +173 -0
  97. package/dist/routes/activity.js.map +1 -0
  98. package/dist/routes/credentials.d.ts +3 -0
  99. package/dist/routes/credentials.d.ts.map +1 -0
  100. package/dist/routes/credentials.js +181 -0
  101. package/dist/routes/credentials.js.map +1 -0
  102. package/dist/routes/mcp.d.ts +8 -0
  103. package/dist/routes/mcp.d.ts.map +1 -0
  104. package/dist/routes/mcp.js +383 -0
  105. package/dist/routes/mcp.js.map +1 -0
  106. package/dist/routes/oauth.d.ts +4 -0
  107. package/dist/routes/oauth.d.ts.map +1 -0
  108. package/dist/routes/oauth.js +268 -0
  109. package/dist/routes/oauth.js.map +1 -0
  110. package/dist/routes/shared.d.ts +97 -0
  111. package/dist/routes/shared.d.ts.map +1 -0
  112. package/dist/routes/shared.js +199 -0
  113. package/dist/routes/shared.js.map +1 -0
  114. package/dist/routes/ui.d.ts +3 -0
  115. package/dist/routes/ui.d.ts.map +1 -0
  116. package/dist/routes/ui.js +122 -0
  117. package/dist/routes/ui.js.map +1 -0
  118. package/dist/server.d.ts +9 -40
  119. package/dist/server.d.ts.map +1 -1
  120. package/dist/server.js +67 -1292
  121. package/dist/server.js.map +1 -1
  122. package/dist/skills.d.ts +0 -15
  123. package/dist/skills.d.ts.map +1 -1
  124. package/dist/skills.js +6 -5
  125. package/dist/skills.js.map +1 -1
  126. package/dist/storage/file.d.ts.map +1 -1
  127. package/dist/storage/file.js +3 -1
  128. package/dist/storage/file.js.map +1 -1
  129. package/dist/storage/memory.d.ts.map +1 -1
  130. package/dist/storage/memory.js +3 -1
  131. package/dist/storage/memory.js.map +1 -1
  132. package/dist/toolkits.d.ts +0 -22
  133. package/dist/toolkits.d.ts.map +1 -1
  134. package/dist/toolkits.js +2 -2
  135. package/dist/toolkits.js.map +1 -1
  136. package/dist/types.d.ts +64 -2
  137. package/dist/types.d.ts.map +1 -1
  138. package/dist/ui.d.ts +3 -93
  139. package/dist/ui.d.ts.map +1 -1
  140. package/dist/ui.js +26 -1389
  141. package/dist/ui.js.map +1 -1
  142. package/dist/version.d.ts +1 -1
  143. package/dist/version.js +1 -1
  144. package/package.json +15 -8
  145. package/src/activity.ts +5 -1
  146. package/src/auth/bearer.ts +4 -4
  147. package/src/auth/clerk.ts +1 -1
  148. package/src/call-admission.ts +522 -0
  149. package/src/catalog-fingerprint.ts +52 -0
  150. package/src/catalog-limits.ts +11 -0
  151. package/src/catalog-service.ts +601 -0
  152. package/src/catalog.ts +2 -2
  153. package/src/concurrency.ts +39 -0
  154. package/src/connectors/api.ts +25 -11
  155. package/src/connectors/remote-mcp.ts +55 -21
  156. package/src/credential-health.ts +13 -10
  157. package/src/credentials.ts +2 -2
  158. package/src/errors.ts +1 -1
  159. package/src/execute.ts +194 -377
  160. package/src/executor-result.ts +2 -2
  161. package/src/executors/quickjs-child.ts +8 -4
  162. package/src/executors/quickjs-protocol.ts +1 -1
  163. package/src/executors/quickjs-runtime.ts +5 -1
  164. package/src/executors/quickjs.ts +11 -8
  165. package/src/favicon.ts +2 -2
  166. package/src/index.ts +54 -17
  167. package/src/invocation.ts +527 -0
  168. package/src/meta-tools.ts +369 -797
  169. package/src/operator-ui/browser.css +413 -0
  170. package/src/operator-ui/browser.ts +1121 -0
  171. package/src/operator-ui/generated.ts +4 -0
  172. package/src/operator-ui/model.ts +127 -0
  173. package/src/registry.ts +489 -44
  174. package/src/routes/activity.ts +220 -0
  175. package/src/routes/credentials.ts +266 -0
  176. package/src/routes/mcp.ts +485 -0
  177. package/src/routes/oauth.ts +328 -0
  178. package/src/routes/shared.ts +330 -0
  179. package/src/routes/ui.ts +153 -0
  180. package/src/server.ts +70 -1702
  181. package/src/skills.ts +6 -5
  182. package/src/storage/file.ts +3 -1
  183. package/src/storage/memory.ts +3 -1
  184. package/src/toolkits.ts +2 -2
  185. package/src/types.ts +70 -2
  186. package/src/ui.ts +52 -1489
  187. package/src/version.ts +1 -1
package/src/meta-tools.ts CHANGED
@@ -1,22 +1,37 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { compactSchema, rankTools, summarizeDescription } from "./catalog.js";
4
- import {
5
- recordToolActivity,
6
- type ActivityCallSource,
7
- type ActivityRequestContext,
3
+ import type {
4
+ ActivityCallSource,
5
+ ActivityRequestContext,
8
6
  } from "./activity.js";
7
+ import {
8
+ boundedDiscoveryText,
9
+ CatalogService,
10
+ DiscoveryPolicyError,
11
+ groupedSearchResult,
12
+ MAX_DESCRIBE_ADDRESSES,
13
+ MAX_DISCOVERY_RESULT_BYTES,
14
+ MAX_SEARCH_LIMIT,
15
+ } from "./catalog-service.js";
16
+ import {
17
+ mapSettledWithConcurrency,
18
+ resolveDiscoveryConcurrency,
19
+ } from "./concurrency.js";
9
20
  import {
10
21
  closeConnectorScope,
11
22
  type DeferredWork,
12
23
  } from "./connector-scope.js";
13
- import { unwrapMcpResult } from "./mcp-result.js";
14
24
  import {
15
25
  classifyCallError,
16
- ConnectorCallError,
17
26
  messageLooksRetryable,
18
27
  type CallErrorDetails,
19
28
  } from "./errors.js";
29
+ import {
30
+ InvocationService,
31
+ MAX_RETRY_BACKOFF_MS,
32
+ retryBackoffMs,
33
+ type InvocationTiming,
34
+ } from "./invocation.js";
20
35
  import {
21
36
  isValidMaxResultBytes,
22
37
  MIN_MAX_RESULT_BYTES,
@@ -24,8 +39,6 @@ import {
24
39
  type RegistryView,
25
40
  } from "./registry.js";
26
41
  import {
27
- connectorGuide,
28
- connectorSkillName,
29
42
  hasConnectorGuides,
30
43
  listSkills,
31
44
  resolveSkill,
@@ -36,7 +49,16 @@ import {
36
49
  withAbortableTimeout,
37
50
  } from "./timeout.js";
38
51
  import { credentialVerdictApplies } from "./credential-health.js";
39
- import type { ConnectorStatus, KVStorage, ToolDef } from "./types.js";
52
+ import type { ConnectorStatus, KVStorage } from "./types.js";
53
+
54
+ export {
55
+ MAX_DESCRIBE_ADDRESSES,
56
+ MAX_DISCOVERY_RESULT_BYTES,
57
+ MAX_RETRY_BACKOFF_MS,
58
+ MAX_SEARCH_LIMIT,
59
+ DiscoveryPolicyError,
60
+ retryBackoffMs,
61
+ };
40
62
 
41
63
  interface TextContent {
42
64
  type: "text";
@@ -50,6 +72,8 @@ export interface ToolResult {
50
72
  }
51
73
 
52
74
  const RESULT_TTL_SECONDS = 900;
75
+ const enc = new TextEncoder();
76
+ const dec = new TextDecoder();
53
77
 
54
78
  export function jsonResult(obj: unknown): ToolResult {
55
79
  return {
@@ -68,95 +92,8 @@ function msg(err: unknown): string {
68
92
  return err instanceof Error ? err.message : String(err);
69
93
  }
70
94
 
71
- export const DEFAULT_SEARCH_LIMIT = 25;
72
- /**
73
- * A discovery page is for choosing the next tool, not exporting the catalog.
74
- * One hundred leaves room for broad browsing while keeping each deliberate
75
- * page far below the catalog sizes Connecta supports.
76
- */
77
- export const MAX_SEARCH_LIMIT = 100;
78
- /** Same one-request work bound for address-based discovery. */
79
- export const MAX_DESCRIBE_ADDRESSES = 100;
80
- /**
81
- * Final UTF-8 ceiling for a generated search/describe response. The count
82
- * limits are the ordinary guard; this catches unusually large full schemas or
83
- * descriptions that make even a bounded page expensive.
84
- */
85
- export const MAX_DISCOVERY_RESULT_BYTES = 256_000;
86
- const enc = new TextEncoder();
87
- const dec = new TextDecoder();
88
-
89
- type ErrorDetails = CallErrorDetails;
90
-
91
- export class DiscoveryPolicyError extends Error {
92
- constructor(
93
- readonly code: "invalid_args" | "result_too_large",
94
- message: string,
95
- ) {
96
- super(message);
97
- this.name = "DiscoveryPolicyError";
98
- }
99
- }
100
-
101
- /** Validate before ranking so a huge page request does no proportional work. */
102
- export function discoverySearchLimit(value: unknown): number {
103
- if (value === undefined) return DEFAULT_SEARCH_LIMIT;
104
- if (
105
- typeof value !== "number" ||
106
- !Number.isInteger(value) ||
107
- value < 1 ||
108
- value > MAX_SEARCH_LIMIT
109
- ) {
110
- throw new DiscoveryPolicyError(
111
- "invalid_args",
112
- `limit must be a whole number from 1 through ${MAX_SEARCH_LIMIT}. Page through larger catalogs with offset.`,
113
- );
114
- }
115
- return value;
116
- }
117
-
118
- /** Validate the raw list so duplicate addresses consume the same bound. */
119
- export function discoveryAddresses(value: unknown): unknown[] {
120
- if (!Array.isArray(value)) {
121
- throw new DiscoveryPolicyError(
122
- "invalid_args",
123
- "addresses must be an array.",
124
- );
125
- }
126
- if (value.length > MAX_DESCRIBE_ADDRESSES) {
127
- throw new DiscoveryPolicyError(
128
- "invalid_args",
129
- `addresses must contain at most ${MAX_DESCRIBE_ADDRESSES} entries. Split a larger list across describe_tools calls.`,
130
- );
131
- }
132
- return value;
133
- }
134
-
135
- /** Serialize once and count the exact bytes jsonResult would emit. */
136
- function boundedDiscoveryText(
137
- value: unknown,
138
- hint: string,
139
- ): string {
140
- const text = JSON.stringify(value, null, 2);
141
- if (text === undefined) {
142
- throw new TypeError("Discovery result is not JSON-serializable.");
143
- }
144
- const bytes = enc.encode(text).length;
145
- if (bytes > MAX_DISCOVERY_RESULT_BYTES) {
146
- throw new DiscoveryPolicyError(
147
- "result_too_large",
148
- `Discovery result is ${bytes} UTF-8 bytes, over the ${MAX_DISCOVERY_RESULT_BYTES}-byte ceiling. ${hint}`,
149
- );
150
- }
151
- return text;
152
- }
153
-
154
- /** Apply the same final result guard to code-mode discovery helpers. */
155
- export function assertDiscoveryResultSize(
156
- value: unknown,
157
- hint: string,
158
- ): void {
159
- boundedDiscoveryText(value, hint);
95
+ function errorDetails(code: string, message: string): CallErrorDetails {
96
+ return { code, message, retryable: messageLooksRetryable(message) };
160
97
  }
161
98
 
162
99
  function discoveryErrorResult(error: DiscoveryPolicyError): ToolResult {
@@ -188,53 +125,14 @@ function discoveryResult(value: unknown, hint: string): ToolResult {
188
125
  }
189
126
  }
190
127
 
191
- /**
192
- * The longest the engine will park a synchronous inbound request in *waiting
193
- * alone*. The engine already treats ~15 s as the outer bound of one reasonable
194
- * connector call (EXECUTE_HOST_CALL_TIMEOUT_MS), so sleeping for minutes trades
195
- * a fast, informative failure for a hung one. A connector-reported window this
196
- * long isn't truncated — it's declined (see `retryBackoffMs`) and reported
197
- * verbatim as `error.retryAfterMs`, so the agent, which can afford to wait,
198
- * decides when to re-issue.
199
- */
200
- export const MAX_RETRY_BACKOFF_MS = 10_000;
201
-
202
- /**
203
- * How long to wait before the next attempt, or `undefined` for "don't retry".
204
- *
205
- * A connector that read a `Retry-After` header knows the window exactly, so it
206
- * is honoured **exactly or not at all**: truncating an exponential *guess* is
207
- * harmless, but truncating a *known* window means deliberately retrying inside
208
- * a rate limit — the harm this channel exists to prevent. A window longer than
209
- * `MAX_RETRY_BACKOFF_MS` therefore declines the retry rather than shortening
210
- * it. (`retryAfterMs` is normalized non-negative, so `0` means "retry now".)
211
- * Connectors that report no window keep the historical exponential guess.
212
- *
213
- * Waits are per attempt, matching the per-attempt `timeoutMs` race in
214
- * `runCall`. Exported for direct testing.
215
- */
216
- export function retryBackoffMs(
217
- attempt: number,
218
- retryAfterMs: number | undefined,
219
- ): number | undefined {
220
- if (retryAfterMs === undefined) {
221
- return Math.min(250 * 2 ** (attempt - 1), 1_000);
222
- }
223
- return retryAfterMs <= MAX_RETRY_BACKOFF_MS ? retryAfterMs : undefined;
224
- }
225
-
226
- /** Details for failures that never reached a connector (no thrown value). */
227
- function errorDetails(code: string, message: string): ErrorDetails {
228
- return { code, message, retryable: messageLooksRetryable(message) };
229
- }
230
128
 
231
129
  /** True if `b` is a UTF-8 continuation byte (0b10xxxxxx). */
232
- function isContinuationByte(b: number): boolean {
233
- return (b & 0xc0) === 0x80;
130
+ function isContinuationByte(b: number | undefined): boolean {
131
+ return b !== undefined && (b & 0xc0) === 0x80;
234
132
  }
235
133
 
236
134
  /** Smallest accepted `get_result` byte offset. */
237
- export const MIN_RESULT_OFFSET = 0;
135
+ const MIN_RESULT_OFFSET = 0;
238
136
 
239
137
  /**
240
138
  * The one definition of a usable `get_result` offset: a whole number of bytes
@@ -250,7 +148,7 @@ export const MIN_RESULT_OFFSET = 0;
250
148
  * error. An offset past the end of the payload stays legal: it is a whole
251
149
  * number of bytes, and it answers with an empty final page.
252
150
  */
253
- export function isValidResultOffset(value: number): boolean {
151
+ function isValidResultOffset(value: number): boolean {
254
152
  return Number.isInteger(value) && value >= MIN_RESULT_OFFSET;
255
153
  }
256
154
 
@@ -314,8 +212,9 @@ export function alignEndToCharBoundary(
314
212
 
315
213
  /** Resolve a dot-path (segments) against a value; `key[]` maps the tail over an array. */
316
214
  function resolvePath(value: unknown, segments: string[]): unknown {
317
- if (segments.length === 0) return value;
318
- const [seg, ...rest] = segments;
215
+ const seg = segments[0];
216
+ if (seg === undefined) return value;
217
+ const rest = segments.slice(1);
319
218
  const isArr = seg.endsWith("[]");
320
219
  const key = isArr ? seg.slice(0, -2) : seg;
321
220
  let next: unknown = value;
@@ -377,7 +276,7 @@ function applyFieldsToContent(
377
276
  * the three give one answer to the same question. A value JSON cannot serialize
378
277
  * at all (a BigInt) still throws, as before, and is reported as a failure.
379
278
  */
380
- export function serializeResultText(value: unknown): string {
279
+ function serializeResultText(value: unknown): string {
381
280
  const serialized = JSON.stringify(value, null, 2);
382
281
  return serialized === undefined ? String(value) : serialized;
383
282
  }
@@ -532,7 +431,7 @@ export interface ListArgs {
532
431
  /** When false, return cached/observed health without downstream I/O. */
533
432
  probe?: boolean;
534
433
  }
535
- export type ResultMode = "mcp" | "value";
434
+ type ResultMode = "mcp" | "value";
536
435
  export interface CallArgs {
537
436
  address: string;
538
437
  args?: Record<string, unknown>;
@@ -597,7 +496,11 @@ export function createMetaTools(
597
496
  defaultToolTimeoutMs?: number;
598
497
  /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
599
498
  probeTimeoutMs?: number;
499
+ /** Maximum simultaneous connector discovery operations. Default 4. */
500
+ discoveryConcurrency?: number;
600
501
  activity?: ActivityRequestContext;
502
+ /** Inbound request cancellation shared by direct and batch child calls. */
503
+ requestSignal?: AbortSignal;
601
504
  /** Runtime continuation for the bounded tail of probe-owned teardown. */
602
505
  defer?: DeferredWork;
603
506
  } = {},
@@ -608,10 +511,19 @@ export function createMetaTools(
608
511
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
609
512
  const probeTimeoutMs =
610
513
  normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
514
+ const discoveryConcurrency = resolveDiscoveryConcurrency(
515
+ opts.discoveryConcurrency,
516
+ );
611
517
  // createMetaTools() is called once per inbound MCP request. Sharing this
612
518
  // identity lets remote connectors reuse one downstream client inside that
613
519
  // request without leaking request-bound I/O into the next one.
614
520
  const requestScope = {};
521
+ const catalog = new CatalogService(registry, baseUrl, {
522
+ requestScope,
523
+ probeTimeoutMs,
524
+ concurrency: discoveryConcurrency,
525
+ });
526
+ const invocation = new InvocationService(registry, catalog, opts.activity);
615
527
  const withProbeDeadline = <T>(
616
528
  label: string,
617
529
  operation: (options: {
@@ -629,281 +541,115 @@ export function createMetaTools(
629
541
  toolResult: ToolResult;
630
542
  durationMs: number;
631
543
  attempts: number;
632
- timing: {
633
- catalogMs: number;
634
- connectorMs: number;
635
- backoffMs: number;
636
- resultProcessingMs: number;
637
- totalMs: number;
638
- };
544
+ timing: InvocationTiming;
639
545
  value?: unknown;
640
- error?: ErrorDetails;
546
+ error?: CallErrorDetails;
641
547
  }
642
548
 
643
- /** Shared call path used by call tools and batch_call: safety → fields → size guard. */
549
+ interface ProcessedCallResult {
550
+ toolResult: ToolResult;
551
+ value?: unknown;
552
+ }
553
+
554
+ /** MCP adapter: shared invocation semantics plus MCP-only result shaping. */
644
555
  async function runCall(
645
556
  call: BatchCall,
646
557
  source: ActivityCallSource,
647
558
  options: { allowDestructive?: boolean } = {},
648
559
  ): Promise<RunCallOutcome> {
649
- const started = Date.now();
650
- let catalogMs = 0;
651
- let connectorMs = 0;
652
- let backoffMs = 0;
653
- let resultProcessingMs = 0;
654
- let attempts = 0;
655
- const timing = () => ({
656
- catalogMs,
657
- connectorMs,
658
- backoffMs,
659
- resultProcessingMs,
660
- totalMs: Date.now() - started,
661
- });
662
- const resolved = registry.resolveAddress(call.address);
663
- const record = (
664
- outcome: "success" | "error" | "timeout",
665
- errorCode?: string,
666
- ) => {
667
- if (!resolved) return;
668
- recordToolActivity(opts.activity, {
669
- connectorId: resolved.connector.id,
670
- toolName: resolved.toolName,
671
- address: `${resolved.connector.id}.${resolved.toolName}`,
560
+ const results = registry.resultsStorage();
561
+ const fields = call.fields && call.fields.length > 0 ? call.fields : null;
562
+ const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
563
+ const outcome = await invocation.invoke<ProcessedCallResult>(
564
+ call.address,
565
+ call.args ?? {},
566
+ {
672
567
  source,
673
- outcome,
674
- durationMs: Date.now() - started,
675
- attempts,
676
- ...(errorCode ? { errorCode } : {}),
677
- });
678
- };
679
- const failed = (error: ErrorDetails): RunCallOutcome => {
680
- const durationMs = Date.now() - started;
681
- const diagnostics = timing();
682
- record(error.code === "timeout" ? "timeout" : "error", error.code);
568
+ ...(options.allowDestructive !== undefined
569
+ ? { allowDestructive: options.allowDestructive }
570
+ : {}),
571
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
572
+ ...(call.maxRetries !== undefined
573
+ ? { maxRetries: call.maxRetries }
574
+ : {}),
575
+ ...(opts.requestSignal !== undefined
576
+ ? { requestSignal: opts.requestSignal }
577
+ : {}),
578
+ unwrapResult: call.resultMode === "value",
579
+ processResult: async (result, resolved) => {
580
+ // Result-size cap for THIS call: the connector's own override wins,
581
+ // then the deployment-wide value, then the built-in default (already
582
+ // folded into `globalCap`). Resolved per call so one batch_call can
583
+ // mix a tight-capped connector with siblings on the global cap. An
584
+ // override the registry already warned about at startup is dropped
585
+ // here, so the connector simply inherits `globalCap`.
586
+ const cap = resolveMaxResultBytes(
587
+ resolved.connector.maxResultBytes,
588
+ globalCap,
589
+ );
590
+ if (call.resultMode === "value") {
591
+ let value = fields ? applyFields(result, fields) : result;
592
+ value = await guardValue(value, results, cap);
593
+ return {
594
+ toolResult: jsonResult({ ok: true, data: value }),
595
+ value,
596
+ };
597
+ }
598
+ if (resolved.connector.kind === "mcp") {
599
+ const mcpResult = result as { content?: TextContent[] };
600
+ let content = mcpResult?.content ?? [];
601
+ if (fields) content = applyFieldsToContent(content, fields);
602
+ return { toolResult: await guardContent(content, results, cap) };
603
+ }
604
+ const value = fields ? applyFields(result, fields) : result;
605
+ return {
606
+ toolResult: await guardText(
607
+ serializeResultText(value),
608
+ results,
609
+ cap,
610
+ ),
611
+ value,
612
+ };
613
+ },
614
+ },
615
+ );
616
+ if (!outcome.ok) {
683
617
  return {
684
618
  toolResult:
685
619
  call.resultMode === "value"
686
620
  ? jsonResult({
687
621
  ok: false,
688
- error,
689
- durationMs,
690
- attempts,
691
- ...(call.diagnostics ? { timing: diagnostics } : {}),
622
+ error: outcome.error,
623
+ durationMs: outcome.durationMs,
624
+ attempts: outcome.attempts,
625
+ ...(call.diagnostics ? { timing: outcome.timing } : {}),
692
626
  })
693
- : errorResult(error.message),
694
- durationMs,
695
- attempts,
696
- timing: diagnostics,
697
- error,
627
+ : errorResult(outcome.error.message),
628
+ durationMs: outcome.durationMs,
629
+ attempts: outcome.attempts,
630
+ timing: outcome.timing,
631
+ error: outcome.error,
698
632
  };
699
- };
700
- if (!resolved) {
701
- return failed(
702
- errorDetails("unknown_address", `Unknown address "${call.address}"`),
703
- );
704
633
  }
705
- const results = registry.resultsStorage();
706
- // Result-size cap for THIS call: the connector's own override wins, then
707
- // the deployment-wide value, then the built-in default (already folded
708
- // into `globalCap`). Resolved per call so one batch_call can mix a
709
- // tight-capped connector with siblings on the global cap. An override the
710
- // registry already warned about at startup is dropped here, so the
711
- // connector simply inherits `globalCap`.
712
- const cap = resolveMaxResultBytes(
713
- resolved.connector.maxResultBytes,
714
- globalCap,
715
- );
716
- const fields = call.fields && call.fields.length > 0 ? call.fields : null;
717
- // An explicit per-call deadline always wins; the config default only fills
718
- // the gap, and stays off entirely when the deployment sets none.
719
- const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
720
- const maxRetries = Math.min(
721
- 2,
722
- Math.max(0, Math.trunc(call.maxRetries ?? 0)),
723
- );
724
- const catalogStarted = Date.now();
725
- let definition: ToolDef | undefined;
726
- try {
727
- definition = (
728
- await registry.getTools(resolved.connector.id, baseUrl, requestScope)
729
- ).find((tool) => tool.name === resolved.toolName);
730
- } catch (err) {
731
- catalogMs += Date.now() - catalogStarted;
732
- // A connector whose catalog cannot be fetched is as unusable as one whose
733
- // execution fails, so it feeds health accounting the same way the
734
- // execution catch below does — otherwise a connector every call_tool
735
- // fails against (a revoked downstream grant, say) still reads clean from
736
- // the cheap `list_connectors({ probe: false })` signal.
737
- //
738
- // Recorded HERE rather than inside the registry's catalog fetch on
739
- // purpose: `registry` is this connection's VIEW, so a toolkit-scoped
740
- // session records into its own log as well as the deployment-wide one,
741
- // which `Registry.refreshTools` could not reach. A cache hit that avoids
742
- // a live listTools call therefore records nothing either way — it is not
743
- // evidence of health, and success stays what it has always been: an
744
- // actual downstream call that returned.
745
- registry.recordFailure(resolved.connector.id, Date.now() - started, err);
746
- // classifyCallError so a typed auth_required thrown while listing tools
747
- // (e.g. a revoked downstream OAuth grant) keeps its code.
748
- return failed(classifyCallError(err, "catalog_lookup_failed"));
749
- }
750
- catalogMs += Date.now() - catalogStarted;
751
- if (!definition) {
752
- return failed(
753
- errorDetails(
754
- "unknown_tool",
755
- `Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
756
- ),
757
- );
758
- }
759
- const explicitlyReadOnly =
760
- definition.annotations?.readOnlyHint === true &&
761
- definition.annotations?.destructiveHint !== true;
762
- if (!explicitlyReadOnly && !options.allowDestructive) {
763
- return failed(
764
- errorDetails(
765
- "destructive_tool_requires_approval",
766
- `Tool "${call.address}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`,
767
- ),
768
- );
769
- }
770
- const retrySafe =
771
- definition.annotations?.readOnlyHint === true ||
772
- definition.annotations?.idempotentHint === true;
773
-
774
- let result: unknown;
775
- while (true) {
776
- attempts++;
777
- const controller = timeoutMs ? new AbortController() : undefined;
778
- let timer: ReturnType<typeof setTimeout> | undefined;
779
- const ctx = registry.contextFor(
780
- resolved.connector.id,
781
- baseUrl,
782
- requestScope,
783
- { signal: controller?.signal, timeoutMs },
784
- );
785
- const connectorStarted = Date.now();
786
- try {
787
- const pending = resolved.connector.callTool(
788
- resolved.toolName,
789
- call.args ?? {},
790
- ctx,
791
- );
792
- result = timeoutMs
793
- ? await Promise.race([
794
- pending,
795
- new Promise<never>((_, reject) => {
796
- timer = setTimeout(() => {
797
- reject(
798
- new ConnectorCallError(
799
- "timeout",
800
- `Tool call timed out after ${timeoutMs}ms`,
801
- ),
802
- );
803
- controller?.abort();
804
- }, timeoutMs);
805
- }),
806
- ])
807
- : await pending;
808
- connectorMs += Date.now() - connectorStarted;
809
- const mcpResult = result as {
810
- content?: TextContent[];
811
- isError?: boolean;
812
- };
813
- if (resolved.connector.kind === "mcp" && mcpResult?.isError) {
814
- throw new Error(
815
- mcpResult.content?.map((block) => block.text).join("") ||
816
- "Downstream tool call failed",
817
- );
818
- }
819
- break;
820
- } catch (err) {
821
- // Includes connector setup, downstream execution, and timeout wait.
822
- connectorMs += Date.now() - connectorStarted;
823
- const details = classifyCallError(err);
824
- if (attempts <= maxRetries && retrySafe && details.retryable) {
825
- const wait = retryBackoffMs(attempts, details.retryAfterMs);
826
- if (wait !== undefined) {
827
- const backoffStarted = Date.now();
828
- if (wait > 0) {
829
- await new Promise((resolve) => setTimeout(resolve, wait));
830
- }
831
- backoffMs += Date.now() - backoffStarted;
832
- continue;
833
- }
834
- // The reported window is longer than the engine will park a
835
- // synchronous request for. Fall through to failure with
836
- // retryAfterMs reported verbatim so the agent can re-issue.
837
- }
838
- registry.recordFailure(
839
- resolved.connector.id,
840
- Date.now() - started,
841
- err,
842
- );
843
- return failed(details);
844
- } finally {
845
- if (timer) clearTimeout(timer);
846
- }
847
- }
848
-
849
- registry.recordSuccess(resolved.connector.id, Date.now() - started);
850
- const processingStarted = Date.now();
851
- try {
852
- const mr = result as { content?: TextContent[]; isError?: boolean };
853
- if (call.resultMode === "value") {
854
- let value = unwrapMcpResult(resolved.connector.kind, result);
855
- if (fields) value = applyFields(value, fields);
856
- value = await guardValue(value, results, cap);
857
- resultProcessingMs += Date.now() - processingStarted;
858
- const durationMs = Date.now() - started;
859
- const diagnostics = timing();
860
- record("success");
861
- return {
862
- toolResult: jsonResult({
634
+ const valueModeResult =
635
+ call.resultMode === "value"
636
+ ? jsonResult({
863
637
  ok: true,
864
- data: value,
865
- durationMs,
866
- attempts,
867
- ...(call.diagnostics ? { timing: diagnostics } : {}),
868
- }),
869
- durationMs,
870
- attempts,
871
- timing: diagnostics,
872
- value,
873
- };
874
- }
875
- if (resolved.connector.kind === "mcp") {
876
- let content = mr?.content ?? [];
877
- if (fields) content = applyFieldsToContent(content, fields);
878
- const toolResult = await guardContent(content, results, cap);
879
- resultProcessingMs += Date.now() - processingStarted;
880
- record("success");
881
- return {
882
- toolResult,
883
- durationMs: Date.now() - started,
884
- attempts,
885
- timing: timing(),
886
- };
887
- }
888
- const value = fields ? applyFields(result, fields) : result;
889
- const toolResult = await guardText(
890
- serializeResultText(value),
891
- results,
892
- cap,
893
- );
894
- resultProcessingMs += Date.now() - processingStarted;
895
- record("success");
896
- return {
897
- toolResult,
898
- durationMs: Date.now() - started,
899
- attempts,
900
- timing: timing(),
901
- value,
902
- };
903
- } catch (err) {
904
- resultProcessingMs += Date.now() - processingStarted;
905
- return failed(errorDetails("result_processing_failed", msg(err)));
906
- }
638
+ data: outcome.value.value,
639
+ durationMs: outcome.durationMs,
640
+ attempts: outcome.attempts,
641
+ ...(call.diagnostics ? { timing: outcome.timing } : {}),
642
+ })
643
+ : outcome.value.toolResult;
644
+ return {
645
+ toolResult: valueModeResult,
646
+ durationMs: outcome.durationMs,
647
+ attempts: outcome.attempts,
648
+ timing: outcome.timing,
649
+ ...(Object.prototype.hasOwnProperty.call(outcome.value, "value")
650
+ ? { value: outcome.value.value }
651
+ : {}),
652
+ };
907
653
  }
908
654
 
909
655
  return {
@@ -934,181 +680,184 @@ export function createMetaTools(
934
680
  // call scope. Closing it cannot defeat call_tool/batch/execute_code reuse.
935
681
  const connectors = registry.listConnectors();
936
682
  const scope = probe ? {} : requestScope;
937
- const pending = connectors.map(
938
- async (c) => {
939
- const statusStarted = Date.now();
940
- const observed = registry.healthFor(c.id);
941
- const verdict = await registry.credentialHealthFor(c.id);
942
- let status:
943
- | ConnectorStatus
944
- | { state: "ok" | "error" | "unknown"; message?: string };
945
- if (probe) {
946
- try {
947
- status = await withProbeDeadline(
948
- `list_connectors probe of "${c.id}"`,
949
- (options) =>
950
- registry.statusFor(c.id, baseUrl, scope, options),
951
- );
952
- } catch (err) {
953
- // A probe that outran probeTimeoutMs (or otherwise threw)
954
- // degrades this connector to an error status rather than
955
- // hanging the whole list_connectors call.
956
- status = { state: "error", message: msg(err) };
957
- }
958
- } else if (
959
- verdict &&
960
- // Deployment-wide, deliberately, like `hasObservedSuccess` beside
961
- // it: a sibling toolkit's successful call proves the shared
962
- // credential works, and a verdict retired for one view but not
963
- // another would make the same connector read differently per scope
964
- // for a reason that has nothing to do with scope.
965
- credentialVerdictApplies(verdict, registry.observedSuccessAt(c.id))
966
- ) {
967
- // The proactive layer (issue #24): a liveness check already found
968
- // the stored credential dead, so say so on the cheap path instead of
969
- // waiting for an agent's real call to discover it. Only while it is
970
- // the freshest evidence — a successful call since then retires it.
971
- status = {
972
- state: verdict.state,
973
- ...(verdict.message ? { message: verdict.message } : {}),
974
- ...(verdict.authorizationUrl
975
- ? { authorizationUrl: verdict.authorizationUrl }
976
- : {}),
977
- };
978
- } else {
979
- // "error" comes from THIS view's own observations — a sibling
980
- // toolkit's failure is not this session's experience — while
981
- // ok/unknown may lean on the deployment-wide success signal, since
982
- // "the connector answers at all" is a fact about the connector.
983
- // Unscoped, the two are the same log, so this is unchanged there.
984
- const derived =
985
- observed?.consecutiveFailures && observed.consecutiveFailures > 0
986
- ? ("error" as const)
987
- : registry.hasObservedSuccess(c.id) || c.kind === "api"
988
- ? ("ok" as const)
989
- : ("unknown" as const);
990
- status = {
991
- // A successful liveness check upgrades "unknown" — nothing has
992
- // been called yet, but the credential was verified, which is how
993
- // re-authorization shows up here as ok rather than as an absence
994
- // of evidence. It never DOWNgrades an observed failure: a real
995
- // call that failed is stronger evidence than a background check.
996
- state:
997
- derived === "unknown" && verdict?.state === "ok"
998
- ? ("ok" as const)
999
- : derived,
1000
- ...(observed?.lastError ? { message: observed.lastError } : {}),
1001
- };
1002
- }
1003
- // Stamped where the observation actually happened — after the status
1004
- // probe, not before it. A 30-second probe stamped at its start would
1005
- // report a verdict older than it is, and would lose the race against a
1006
- // real call that succeeded WHILE it ran (that success must retire the
1007
- // verdict, and only an honest timestamp says so).
1008
- const checkedAt = new Date().toISOString();
1009
- // A live status probe IS a liveness observation of the stored
1010
- // credential, so it updates the same verdict a background check
1011
- // writes: the cached read afterwards agrees with what the operator
1012
- // just saw, and they are not swept again moments later. Recorded from
1013
- // the STATUS phase only, and only when the connector actually answered
1014
- // — a catalog refresh below is not a credential check (the sweep never
1015
- // fetches one), it is already counted in the health log, and letting
1016
- // its failure land here would spend the freshness budget on it. The
1017
- // registry ignores this for connectors storing no credential of ours.
1018
- if (probe && (status.state === "ok" || status.state === "auth_required")) {
1019
- await registry.recordCredentialHealth(c.id, {
1020
- state: status.state,
1021
- checkedAt,
1022
- ...(status.message ? { message: status.message } : {}),
1023
- ...("authorizationUrl" in status && status.authorizationUrl
1024
- ? { authorizationUrl: status.authorizationUrl }
1025
- : {}),
1026
- });
683
+ const inspect = async (c: (typeof connectors)[number]) => {
684
+ const statusStarted = Date.now();
685
+ const observed = registry.healthFor(c.id);
686
+ const verdict = await registry.credentialHealthFor(c.id);
687
+ let status:
688
+ | ConnectorStatus
689
+ | { state: "ok" | "error" | "unknown"; message?: string };
690
+ if (probe) {
691
+ try {
692
+ status = await withProbeDeadline(
693
+ `list_connectors probe of "${c.id}"`,
694
+ (options) =>
695
+ registry.statusFor(c.id, baseUrl, scope, options),
696
+ );
697
+ } catch (err) {
698
+ // A probe that outran probeTimeoutMs (or otherwise threw)
699
+ // degrades this connector to an error status rather than
700
+ // hanging the whole list_connectors call.
701
+ status = { state: "error", message: msg(err) };
1027
702
  }
1028
- let tools = registry.peekTools(c.id);
1029
- // An auth_required status may have just started OAuth. A second
1030
- // listTools probe would overwrite its state/verifier while returning
1031
- // the first (now stale) authorization URL.
1032
- if (probe && status.state === "ok") {
1033
- try {
1034
- tools = await withProbeDeadline(
1035
- `list_connectors catalog refresh of "${c.id}"`,
1036
- (options) =>
1037
- registry.refreshTools(c.id, baseUrl, scope, options),
1038
- );
1039
- registry.recordSuccess(c.id, Date.now() - statusStarted);
1040
- } catch (err) {
1041
- const details = classifyCallError(err);
1042
- if (details.code === "auth_required") {
1043
- let authStatus: ConnectorStatus | undefined;
1044
- try {
1045
- authStatus = await withProbeDeadline(
1046
- `list_connectors authorization status of "${c.id}"`,
1047
- (options) =>
1048
- registry.statusFor(c.id, baseUrl, scope, options),
1049
- );
1050
- } catch {
1051
- // The typed auth verdict is still authoritative; this second
1052
- // read exists only to recover the connector's pending URL.
1053
- }
1054
- status =
1055
- authStatus?.state === "auth_required"
1056
- ? authStatus
1057
- : {
1058
- state: "auth_required" as const,
1059
- message: details.message,
1060
- };
1061
- await registry.recordCredentialHealth(c.id, {
1062
- state: "auth_required",
1063
- checkedAt,
1064
- ...(status.message ? { message: status.message } : {}),
1065
- ...("authorizationUrl" in status &&
1066
- status.authorizationUrl
1067
- ? { authorizationUrl: status.authorizationUrl }
1068
- : {}),
1069
- });
1070
- } else {
1071
- status = { state: "error" as const, message: msg(err) };
1072
- }
1073
- registry.recordFailure(c.id, Date.now() - statusStarted, err);
1074
- }
1075
- }
1076
- const latencyMs = Date.now() - statusStarted;
1077
- const latestObserved = registry.healthFor(c.id);
1078
- const credentialCheck = probe
1079
- ? await registry.credentialHealthFor(c.id)
1080
- : verdict;
1081
- return {
1082
- id: c.id,
1083
- ...(c.title ? { title: c.title } : {}),
1084
- description: c.description,
1085
- toolCount: tools?.length ?? 0,
1086
- status: status.state,
703
+ } else if (
704
+ verdict &&
705
+ // Deployment-wide, deliberately, like `hasObservedSuccess` beside
706
+ // it: a sibling toolkit's successful call proves the shared
707
+ // credential works, and a verdict retired for one view but not
708
+ // another would make the same connector read differently per scope
709
+ // for a reason that has nothing to do with scope.
710
+ credentialVerdictApplies(verdict, registry.observedSuccessAt(c.id))
711
+ ) {
712
+ // The proactive layer (issue #24): a liveness check already found
713
+ // the stored credential dead, so say so on the cheap path instead of
714
+ // waiting for an agent's real call to discover it. Only while it is
715
+ // the freshest evidence — a successful call since then retires it.
716
+ status = {
717
+ state: verdict.state,
718
+ ...(verdict.message ? { message: verdict.message } : {}),
719
+ ...(verdict.authorizationUrl
720
+ ? { authorizationUrl: verdict.authorizationUrl }
721
+ : {}),
722
+ };
723
+ } else {
724
+ // "error" comes from THIS view's own observations — a sibling
725
+ // toolkit's failure is not this session's experience — while
726
+ // ok/unknown may lean on the deployment-wide success signal, since
727
+ // "the connector answers at all" is a fact about the connector.
728
+ // Unscoped, the two are the same log, so this is unchanged there.
729
+ const derived =
730
+ observed?.consecutiveFailures && observed.consecutiveFailures > 0
731
+ ? ("error" as const)
732
+ : registry.hasObservedSuccess(c.id) || c.kind === "api"
733
+ ? ("ok" as const)
734
+ : ("unknown" as const);
735
+ status = {
736
+ // A successful liveness check upgrades "unknown" — nothing has
737
+ // been called yet, but the credential was verified, which is how
738
+ // re-authorization shows up here as ok rather than as an absence
739
+ // of evidence. It never DOWNgrades an observed failure: a real
740
+ // call that failed is stronger evidence than a background check.
741
+ state:
742
+ derived === "unknown" && verdict?.state === "ok"
743
+ ? ("ok" as const)
744
+ : derived,
745
+ ...(observed?.lastError ? { message: observed.lastError } : {}),
746
+ };
747
+ }
748
+ // Stamped where the observation actually happened — after the status
749
+ // probe, not before it. A 30-second probe stamped at its start would
750
+ // report a verdict older than it is, and would lose the race against a
751
+ // real call that succeeded WHILE it ran (that success must retire the
752
+ // verdict, and only an honest timestamp says so).
753
+ const checkedAt = new Date().toISOString();
754
+ // A live status probe IS a liveness observation of the stored
755
+ // credential, so it updates the same verdict a background check
756
+ // writes: the cached read afterwards agrees with what the operator
757
+ // just saw, and they are not swept again moments later. Recorded from
758
+ // the STATUS phase only, and only when the connector actually answered
759
+ // — a catalog refresh below is not a credential check (the sweep never
760
+ // fetches one), it is already counted in the health log, and letting
761
+ // its failure land here would spend the freshness budget on it. The
762
+ // registry ignores this for connectors storing no credential of ours.
763
+ if (probe && (status.state === "ok" || status.state === "auth_required")) {
764
+ await registry.recordCredentialHealth(c.id, {
765
+ state: status.state,
1087
766
  checkedAt,
1088
- latencyMs,
1089
- probe,
1090
- ...(latestObserved ?? observed ?? {}),
1091
- ...(credentialCheck ? { credentialCheck } : {}),
767
+ ...(status.message ? { message: status.message } : {}),
1092
768
  ...("authorizationUrl" in status && status.authorizationUrl
1093
769
  ? { authorizationUrl: status.authorizationUrl }
1094
770
  : {}),
1095
- ...(status.message ? { message: status.message } : {}),
1096
- };
1097
- },
771
+ });
772
+ }
773
+ let tools = registry.peekTools(c.id);
774
+ // An auth_required status may have just started OAuth. A second
775
+ // listTools probe would overwrite its state/verifier while returning
776
+ // the first (now stale) authorization URL.
777
+ if (probe && status.state === "ok") {
778
+ try {
779
+ tools = await withProbeDeadline(
780
+ `list_connectors catalog refresh of "${c.id}"`,
781
+ (options) =>
782
+ registry.refreshTools(c.id, baseUrl, scope, options),
783
+ );
784
+ registry.recordSuccess(c.id, Date.now() - statusStarted);
785
+ } catch (err) {
786
+ const details = classifyCallError(err);
787
+ if (details.code === "auth_required") {
788
+ let authStatus: ConnectorStatus | undefined;
789
+ try {
790
+ authStatus = await withProbeDeadline(
791
+ `list_connectors authorization status of "${c.id}"`,
792
+ (options) =>
793
+ registry.statusFor(c.id, baseUrl, scope, options),
794
+ );
795
+ } catch {
796
+ // The typed auth verdict is still authoritative; this second
797
+ // read exists only to recover the connector's pending URL.
798
+ }
799
+ status =
800
+ authStatus?.state === "auth_required"
801
+ ? authStatus
802
+ : {
803
+ state: "auth_required" as const,
804
+ message: details.message,
805
+ };
806
+ await registry.recordCredentialHealth(c.id, {
807
+ state: "auth_required",
808
+ checkedAt,
809
+ ...(status.message && { message: status.message }),
810
+ ...("authorizationUrl" in status &&
811
+ status.authorizationUrl
812
+ ? { authorizationUrl: status.authorizationUrl }
813
+ : {}),
814
+ });
815
+ } else {
816
+ status = { state: "error" as const, message: msg(err) };
817
+ }
818
+ registry.recordFailure(c.id, Date.now() - statusStarted, err);
819
+ }
820
+ }
821
+ const latencyMs = Date.now() - statusStarted;
822
+ const latestObserved = registry.healthFor(c.id);
823
+ const credentialCheck = probe
824
+ ? await registry.credentialHealthFor(c.id)
825
+ : verdict;
826
+ return {
827
+ id: c.id,
828
+ ...(c.title ? { title: c.title } : {}),
829
+ description: c.description,
830
+ toolCount: tools?.length ?? 0,
831
+ status: status.state,
832
+ checkedAt,
833
+ latencyMs,
834
+ probe,
835
+ ...(latestObserved ?? observed),
836
+ ...(credentialCheck ? { credentialCheck } : {}),
837
+ ...("authorizationUrl" in status &&
838
+ status.authorizationUrl && {
839
+ authorizationUrl: status.authorizationUrl,
840
+ }),
841
+ ...(status.message && { message: status.message }),
842
+ };
843
+ };
844
+ const settled = await mapSettledWithConcurrency(
845
+ connectors,
846
+ discoveryConcurrency,
847
+ inspect,
1098
848
  );
1099
- if (!probe) {
1100
- return jsonResult({ connectors: await Promise.all(pending) });
849
+ if (probe) {
850
+ await mapSettledWithConcurrency(
851
+ connectors,
852
+ discoveryConcurrency,
853
+ (connector) =>
854
+ closeConnectorScope(
855
+ connector,
856
+ registry.contextFor(connector.id, baseUrl, scope),
857
+ opts.defer,
858
+ ),
859
+ );
1101
860
  }
1102
- const settled = await Promise.allSettled(pending);
1103
- await Promise.all(
1104
- connectors.map((connector) =>
1105
- closeConnectorScope(
1106
- connector,
1107
- registry.contextFor(connector.id, baseUrl, scope),
1108
- opts.defer,
1109
- ),
1110
- ),
1111
- );
1112
861
  const out = settled.map((result) => {
1113
862
  if (result.status === "rejected") throw result.reason;
1114
863
  return result.value;
@@ -1117,235 +866,31 @@ export function createMetaTools(
1117
866
  },
1118
867
 
1119
868
  async searchTools(args: SearchArgs): Promise<ToolResult> {
1120
- const q = args.query ?? "";
1121
- let limit: number;
1122
869
  try {
1123
- limit = discoverySearchLimit(args.limit);
870
+ return discoveryResult(
871
+ groupedSearchResult(await catalog.search(args)),
872
+ "Request a smaller limit, omit fullDescriptions, or use compact schemas.",
873
+ );
1124
874
  } catch (err) {
1125
875
  if (err instanceof DiscoveryPolicyError) {
1126
876
  return discoveryErrorResult(err);
1127
877
  }
1128
878
  throw err;
1129
879
  }
1130
- const offset = Math.max(0, Math.trunc(args.offset ?? 0));
1131
- const conns = args.connector
1132
- ? [registry.getConnector(args.connector)].filter(
1133
- (c): c is NonNullable<typeof c> => Boolean(c),
1134
- )
1135
- : registry.listConnectors();
1136
- const matches: Array<{
1137
- connectorId: string;
1138
- connectorTitle?: string;
1139
- connectorDescription?: string;
1140
- connectorGuideSkill?: string;
1141
- tool: ToolDef;
1142
- score: number;
1143
- order: number;
1144
- }> = [];
1145
- const catalogs = await Promise.allSettled(
1146
- conns.map((c) =>
1147
- withProbeDeadline(
1148
- `search_tools probe of "${c.id}"`,
1149
- (options) =>
1150
- registry.getTools(c.id, baseUrl, requestScope, options),
1151
- ),
1152
- ),
1153
- );
1154
- let matchMode: "all" | "partial" = "all";
1155
- const collectMatches = (mode: "all" | "partial") => {
1156
- matches.length = 0;
1157
- let orderBase = 0;
1158
- catalogs.forEach((catalog, connectorIndex) => {
1159
- const c = conns[connectorIndex];
1160
- if (catalog.status === "fulfilled") {
1161
- for (const ranked of rankTools(catalog.value, q, mode)) {
1162
- matches.push({
1163
- connectorId: c.id,
1164
- connectorTitle: c.title,
1165
- connectorDescription: c.description,
1166
- ...(connectorGuide(c)
1167
- ? { connectorGuideSkill: connectorSkillName(c.id) }
1168
- : {}),
1169
- tool: ranked.tool,
1170
- score: ranked.score,
1171
- order: orderBase + ranked.order,
1172
- });
1173
- }
1174
- }
1175
- orderBase +=
1176
- catalog.status === "fulfilled" ? catalog.value.length : 1;
1177
- });
1178
- };
1179
- collectMatches("all");
1180
- if (q.trim() && matches.length === 0) {
1181
- matchMode = "partial";
1182
- collectMatches(matchMode);
1183
- }
1184
- matches.sort((a, b) => b.score - a.score || a.order - b.order);
1185
- const page = matches.slice(offset, offset + limit);
1186
- const groups: {
1187
- id: string;
1188
- title?: string;
1189
- description?: string;
1190
- /** Skill name of this connector's usage guide, when it has one. */
1191
- guide?: string;
1192
- tools: Array<{
1193
- name: string;
1194
- address: string;
1195
- description?: string;
1196
- inputSchema?: unknown;
1197
- outputSchema?: unknown;
1198
- annotations?: ToolDef["annotations"];
1199
- }>;
1200
- }[] = [];
1201
- const byConnector = new Map<string, (typeof groups)[number]>();
1202
- for (const match of page) {
1203
- let group = byConnector.get(match.connectorId);
1204
- if (!group) {
1205
- group = {
1206
- id: match.connectorId,
1207
- ...(match.connectorTitle ? { title: match.connectorTitle } : {}),
1208
- description: match.connectorDescription,
1209
- ...(match.connectorGuideSkill
1210
- ? { guide: match.connectorGuideSkill }
1211
- : {}),
1212
- tools: [],
1213
- };
1214
- byConnector.set(match.connectorId, group);
1215
- groups.push(group);
1216
- }
1217
- const schema = match.tool.inputSchema ?? { type: "object" };
1218
- group.tools.push({
1219
- name: match.tool.name,
1220
- address: `${match.connectorId}.${match.tool.name}`,
1221
- description: summarizeDescription(
1222
- match.tool.description,
1223
- args.fullDescriptions === true,
1224
- ),
1225
- ...(args.includeSchemas
1226
- ? {
1227
- inputSchema:
1228
- args.includeSchemas === "json"
1229
- ? schema
1230
- : compactSchema(schema),
1231
- }
1232
- : {}),
1233
- ...(args.includeSchemas && match.tool.outputSchema
1234
- ? {
1235
- outputSchema:
1236
- args.includeSchemas === "json"
1237
- ? match.tool.outputSchema
1238
- : compactSchema(match.tool.outputSchema),
1239
- }
1240
- : {}),
1241
- ...(match.tool.annotations
1242
- ? { annotations: match.tool.annotations }
1243
- : {}),
1244
- });
1245
- }
1246
- const nextOffset =
1247
- offset + page.length < matches.length
1248
- ? offset + page.length
1249
- : undefined;
1250
- return discoveryResult(
1251
- {
1252
- connectors: groups,
1253
- total: matches.length,
1254
- offset,
1255
- limit,
1256
- hasMore: nextOffset !== undefined,
1257
- ...(nextOffset !== undefined ? { nextOffset } : {}),
1258
- ...(matchMode === "partial" && matches.length > 0
1259
- ? { matchMode }
1260
- : {}),
1261
- },
1262
- "Request a smaller limit, omit fullDescriptions, or use compact schemas.",
1263
- );
1264
880
  },
1265
881
 
1266
882
  async describeTools(args: DescribeArgs): Promise<ToolResult> {
1267
883
  try {
1268
- discoveryAddresses(args.addresses);
884
+ return discoveryResult(
885
+ { tools: await catalog.describe(args) },
886
+ 'Split the address list or use format: "compact".',
887
+ );
1269
888
  } catch (err) {
1270
889
  if (err instanceof DiscoveryPolicyError) {
1271
890
  return discoveryErrorResult(err);
1272
891
  }
1273
892
  throw err;
1274
893
  }
1275
- const format = args.format ?? "compact";
1276
- const resolved = args.addresses.map((address) => ({
1277
- address,
1278
- resolved: registry.resolveAddress(address),
1279
- }));
1280
- const connectorIds = [
1281
- ...new Set(
1282
- resolved
1283
- .map((entry) => entry.resolved?.connector.id)
1284
- .filter((id): id is string => Boolean(id)),
1285
- ),
1286
- ];
1287
- const loaded = await Promise.allSettled(
1288
- connectorIds.map((id) =>
1289
- withProbeDeadline(
1290
- `describe_tools probe of "${id}"`,
1291
- (options) =>
1292
- registry.getTools(id, baseUrl, requestScope, options),
1293
- ),
1294
- ),
1295
- );
1296
- const catalogs = new Map<string, ToolDef[] | Error>();
1297
- loaded.forEach((result, index) => {
1298
- catalogs.set(
1299
- connectorIds[index],
1300
- result.status === "fulfilled"
1301
- ? result.value
1302
- : result.reason instanceof Error
1303
- ? result.reason
1304
- : new Error(String(result.reason)),
1305
- );
1306
- });
1307
- const out = resolved.map(({ address, resolved }) => {
1308
- if (!resolved) {
1309
- return { address, error: `Unknown address "${address}"` };
1310
- }
1311
- const catalog = catalogs.get(resolved.connector.id);
1312
- if (catalog instanceof Error) {
1313
- return { address, error: catalog.message };
1314
- }
1315
- const tool = catalog?.find((t) => t.name === resolved.toolName);
1316
- if (!tool) {
1317
- return {
1318
- address,
1319
- error: `Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
1320
- };
1321
- }
1322
- const schema = tool.inputSchema ?? { type: "object" };
1323
- return {
1324
- address,
1325
- name: tool.name,
1326
- description: summarizeDescription(
1327
- tool.description,
1328
- args.fullDescriptions === true,
1329
- ),
1330
- ...(connectorGuide(resolved.connector)
1331
- ? { guide: connectorSkillName(resolved.connector.id) }
1332
- : {}),
1333
- inputSchema: format === "json" ? schema : compactSchema(schema),
1334
- ...(tool.outputSchema
1335
- ? {
1336
- outputSchema:
1337
- format === "json"
1338
- ? tool.outputSchema
1339
- : compactSchema(tool.outputSchema),
1340
- }
1341
- : {}),
1342
- ...(tool.annotations ? { annotations: tool.annotations } : {}),
1343
- };
1344
- });
1345
- return discoveryResult(
1346
- { tools: out },
1347
- 'Split the address list or use format: "compact".',
1348
- );
1349
894
  },
1350
895
 
1351
896
  async callTool(args: CallArgs): Promise<ToolResult> {
@@ -1424,17 +969,29 @@ export function createMetaTools(
1424
969
  runCall(
1425
970
  {
1426
971
  ...c,
1427
- resultMode: c.resultMode ?? args.resultMode,
1428
- timeoutMs: c.timeoutMs ?? args.timeoutMs,
1429
- maxRetries: c.maxRetries ?? args.maxRetries,
1430
- diagnostics: c.diagnostics ?? args.diagnostics,
972
+ ...((c.resultMode ?? args.resultMode) !== undefined
973
+ ? { resultMode: c.resultMode ?? args.resultMode }
974
+ : {}),
975
+ ...((c.timeoutMs ?? args.timeoutMs) !== undefined
976
+ ? { timeoutMs: c.timeoutMs ?? args.timeoutMs }
977
+ : {}),
978
+ ...((c.maxRetries ?? args.maxRetries) !== undefined
979
+ ? { maxRetries: c.maxRetries ?? args.maxRetries }
980
+ : {}),
981
+ ...((c.diagnostics ?? args.diagnostics) !== undefined
982
+ ? { diagnostics: c.diagnostics ?? args.diagnostics }
983
+ : {}),
1431
984
  },
1432
985
  "batch_call",
1433
986
  ),
1434
987
  ),
1435
988
  );
1436
989
  const results = settled.map((s, i) => {
1437
- const address = args.calls[i].address;
990
+ const call = args.calls[i];
991
+ if (!call) {
992
+ throw new Error("Batch result has no corresponding call");
993
+ }
994
+ const { address } = call;
1438
995
  if (s.status === "rejected") {
1439
996
  return {
1440
997
  address,
@@ -1452,19 +1009,19 @@ export function createMetaTools(
1452
1009
  errorDetails: r.error,
1453
1010
  durationMs: r.durationMs,
1454
1011
  attempts: r.attempts,
1455
- ...((args.calls[i].diagnostics ?? args.diagnostics)
1012
+ ...((call.diagnostics ?? args.diagnostics)
1456
1013
  ? { timing: r.timing }
1457
1014
  : {}),
1458
1015
  };
1459
1016
  }
1460
- if ((args.calls[i].resultMode ?? args.resultMode) === "value") {
1017
+ if ((call.resultMode ?? args.resultMode) === "value") {
1461
1018
  return {
1462
1019
  address,
1463
1020
  ok: true,
1464
1021
  data: r.value,
1465
1022
  durationMs: r.durationMs,
1466
1023
  attempts: r.attempts,
1467
- ...((args.calls[i].diagnostics ?? args.diagnostics)
1024
+ ...((call.diagnostics ?? args.diagnostics)
1468
1025
  ? { timing: r.timing }
1469
1026
  : {}),
1470
1027
  };
@@ -1475,7 +1032,7 @@ export function createMetaTools(
1475
1032
  result: r.toolResult.content,
1476
1033
  durationMs: r.durationMs,
1477
1034
  attempts: r.attempts,
1478
- ...((args.calls[i].diagnostics ?? args.diagnostics)
1035
+ ...((call.diagnostics ?? args.diagnostics)
1479
1036
  ? { timing: r.timing }
1480
1037
  : {}),
1481
1038
  };
@@ -1539,7 +1096,10 @@ export function createMetaTools(
1539
1096
  }
1540
1097
  const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
1541
1098
  try {
1542
- const status = await connector.startAuth(ctx, { force: args.force });
1099
+ const status = await connector.startAuth(
1100
+ ctx,
1101
+ args.force !== undefined ? { force: args.force } : {},
1102
+ );
1543
1103
  // startAuth just spoke to the downstream about this exact credential, so
1544
1104
  // its answer replaces any older liveness verdict — including the stale
1545
1105
  // `auth_required` that sent the agent here, once it reports ok.
@@ -1656,15 +1216,27 @@ export function registerMetaTools(
1656
1216
  baseUrl: string;
1657
1217
  defaultToolTimeoutMs?: number;
1658
1218
  probeTimeoutMs?: number;
1219
+ discoveryConcurrency?: number;
1659
1220
  activity?: ActivityRequestContext;
1221
+ requestSignal?: AbortSignal;
1660
1222
  defer?: DeferredWork;
1661
1223
  },
1662
1224
  ): void {
1663
1225
  const mt = createMetaTools(registry, ctx.baseUrl, {
1664
- defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
1665
- probeTimeoutMs: ctx.probeTimeoutMs,
1666
- activity: ctx.activity,
1667
- defer: ctx.defer,
1226
+ ...(ctx.defaultToolTimeoutMs !== undefined
1227
+ ? { defaultToolTimeoutMs: ctx.defaultToolTimeoutMs }
1228
+ : {}),
1229
+ ...(ctx.probeTimeoutMs !== undefined
1230
+ ? { probeTimeoutMs: ctx.probeTimeoutMs }
1231
+ : {}),
1232
+ ...(ctx.discoveryConcurrency !== undefined
1233
+ ? { discoveryConcurrency: ctx.discoveryConcurrency }
1234
+ : {}),
1235
+ ...(ctx.activity !== undefined ? { activity: ctx.activity } : {}),
1236
+ ...(ctx.requestSignal !== undefined
1237
+ ? { requestSignal: ctx.requestSignal }
1238
+ : {}),
1239
+ ...(ctx.defer !== undefined ? { defer: ctx.defer } : {}),
1668
1240
  });
1669
1241
 
1670
1242
  server.registerTool(