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