@zackbart/connecta 0.24.2 → 0.24.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/downstream-oauth.d.ts +12 -1
  4. package/dist/auth/downstream-oauth.js +147 -35
  5. package/dist/call-admission.d.ts +4 -0
  6. package/dist/call-admission.js +26 -0
  7. package/dist/catalog-drift.js +9 -4
  8. package/dist/catalog-service.d.ts +2 -0
  9. package/dist/catalog-service.js +25 -8
  10. package/dist/catalog.d.ts +2 -0
  11. package/dist/catalog.js +246 -121
  12. package/dist/connectors/api.js +11 -1
  13. package/dist/connectors/guarded-fetch.d.ts +1 -1
  14. package/dist/connectors/guarded-fetch.js +27 -20
  15. package/dist/connectors/remote-mcp.js +84 -53
  16. package/dist/errors.d.ts +17 -0
  17. package/dist/errors.js +58 -0
  18. package/dist/execute.js +85 -23
  19. package/dist/executor-result.js +3 -1
  20. package/dist/executors/quickjs-child.js +5 -1
  21. package/dist/executors/quickjs-protocol.d.ts +4 -0
  22. package/dist/executors/quickjs-runtime.d.ts +1 -1
  23. package/dist/executors/quickjs-runtime.js +38 -21
  24. package/dist/executors/quickjs.js +68 -27
  25. package/dist/index.d.ts +14 -0
  26. package/dist/index.js +24 -3
  27. package/dist/invocation.js +134 -93
  28. package/dist/mcp-result.js +3 -2
  29. package/dist/meta-tools.js +118 -39
  30. package/dist/registry.d.ts +14 -2
  31. package/dist/registry.js +87 -13
  32. package/dist/routes/mcp.d.ts +4 -1
  33. package/dist/routes/mcp.js +84 -13
  34. package/dist/routes/oauth.js +4 -0
  35. package/dist/routes/shared.d.ts +1 -0
  36. package/dist/routes/shared.js +4 -4
  37. package/dist/server.js +15 -3
  38. package/dist/skills.js +6 -5
  39. package/dist/storage/file.d.ts +6 -2
  40. package/dist/storage/file.js +312 -34
  41. package/dist/storage/memory.js +12 -1
  42. package/dist/validate.js +3 -3
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/documentation/architecture.md +22 -6
  46. package/documentation/auth.md +42 -9
  47. package/documentation/call-admission.md +24 -8
  48. package/documentation/code-mode.md +34 -22
  49. package/documentation/connectors.md +47 -5
  50. package/documentation/meta-tools.md +74 -6
  51. package/documentation/operations.md +19 -19
  52. package/documentation/provider-conventions.md +7 -0
  53. package/documentation/request-admission.md +38 -4
  54. package/documentation/storage-and-credentials.md +54 -1
  55. package/documentation/upgrading.md +18 -4
  56. package/package.json +1 -1
  57. package/templates/node/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,147 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.24.3 — 2026-09-16
6
+
7
+ A bug-fix release from a full audit of the execution path, invocation, catalog
8
+ rendering, downstream OAuth, and the route table, plus the one MCP 2026-07-28
9
+ requirement the previous inventory had missed. Two changes can be felt by a
10
+ deployment: `/mcp` now validates the browser `Origin` header, so a browser MCP
11
+ client hosted on an origin other than `publicUrl` or loopback needs
12
+ `allowedOrigins`; and the overload and shutdown JSON-RPC error codes moved
13
+ from `-32001`/`-32002` to `-31001`/`-31002`. Everything else is a fix a
14
+ deployment can take without action. No storage format changed, but
15
+ `fileStorage` now refuses a second process on the same state file, which a
16
+ deployment sharing one file between two processes was never safe doing.
17
+
18
+ ### Added
19
+
20
+ - **`allowedOrigins`.** `/mcp` and every `/mcp/<pool>` path refuse a
21
+ disallowed `Origin` with a fixed 403 before redirects, admission, auth, or
22
+ preflight, as the Streamable HTTP transport requires against DNS rebinding.
23
+ The default admits the `publicUrl` origin and HTTP(S) loopback at any port;
24
+ a list replaces the default; `"*"` keeps unrestricted CORS. Clients that
25
+ send no `Origin` are unaffected. Allowed preflight now returns 204 without
26
+ admission or auth and echoes valid requested `mcp-param-*` header names.
27
+ - **Host-owned refresh recovery.** A valid token response is a consumed
28
+ refresh token. When the request that owned a refresh is cancelled,
29
+ redirected to authorization, or invalidated before the SDK saves the
30
+ response, the coordinator persists the rotation itself, holds contenders
31
+ behind the pending-mutation marker until that write lands, and hands them
32
+ the saved rotation. A retired refresh token is never redeemed twice, and two
33
+ deterministic waves of eight overlapping scopes pin one grant per wave
34
+ (#526).
35
+ - Downstream JSON-RPC `-32602` refusals map to `invalid_args`, and 4xx
36
+ responses with a JSON message keep that message, bounded, without inferring
37
+ retryability from prose. 429 stays `rate_limited` and 408 becomes `timeout`.
38
+ - `Retry-After` accepts the HTTP-date form as well as delta-seconds.
39
+ - Personal connectors admit calls through their principal registry's own
40
+ budget instead of sharing one root budget; shutdown closes both, and
41
+ eviction never discards a live budget.
42
+ - `records/mcp-2026-07-28.md` gains rows for Origin validation, deterministic
43
+ `tools/list` order, the bearer challenge, SEP-2243 parameter headers, and
44
+ trace propagation, and corrects the stale extensions row.
45
+ - **Bounded `get_result` stash.** `results.maxStashBytes` (default 8 MiB)
46
+ and `results.maxStashEntries` (default 64) bound the paging stash per
47
+ runtime, with accounting that reserves capacity before concurrent writes
48
+ finish and reclaims expired entries before reuse. A refused stash keeps the
49
+ call successful with its preview and the paging-unavailable notice.
50
+ - **Byte-range paging.** Stashed results are stored as a byte-addressable
51
+ envelope, so `get_result` decodes only the requested page instead of
52
+ re-encoding the whole result on every call. Entries stashed before the
53
+ upgrade still page until their TTL expires.
54
+ - **Identity-partitioned results.** The stash partition derives from any
55
+ authenticated subject or principal, no longer only from providers that
56
+ declare `activityActorNamespace`. Open deployments share one partition.
57
+ - **Sanitized `unavailable` detail.** Unreachable downstreams may carry
58
+ `details.host` (origin only) and `details.code` (a closed allowlist of
59
+ network errnos, or `timeout`) so an outage is distinguishable from a typo
60
+ without leaking a path, query, or credential. Activity stays payload-free.
61
+ - **`fileStorage` writer lock.** A second instance or process opening the
62
+ same state file fails at construction naming the holder. The lock is
63
+ heartbeat-based, so a container restart that reuses a pid cannot wedge a
64
+ deployment, and each write uses a unique temp file. The returned store now
65
+ has `close()`.
66
+ - **Logs survive every executor failure.** QuickJS streams console entries
67
+ to the parent, so a program that is cancelled, killed at the deadline,
68
+ crashed, or lost to an IPC failure still returns what it printed.
69
+
70
+ ### Changed
71
+
72
+ - `/health` no longer names connectors: drift reports are keyed by a truncated
73
+ SHA-256 of the connector id and downstream admission is summed without ids.
74
+ `connecta doctor` keeps its stale-allowlist signal.
75
+ - Overload and shutdown JSON-RPC codes are `-31001` and `-31002`, outside the
76
+ reserved range, per the specification's allocation policy.
77
+ - `connecta.search` and `connecta.describe` spend the same host-call budget as
78
+ `connecta.call`; only `connecta.emit` is exempt (L4, M7).
79
+ - One per-call deadline now covers catalog resolution, admission, and the
80
+ connector call, so a hung connector fails with a catchable `timeout` instead
81
+ of consuming the whole execution wall clock.
82
+ - Compact describe caps each shape at 8,192 UTF-8 bytes and sets
83
+ `inputSchemaTruncated` / `outputSchemaTruncated`; use `format: "json"` for
84
+ the exact schema.
85
+ - Top-level discovery measures the complete tool result, both copies and JSON
86
+ escaping, against the 256,000-byte ceiling.
87
+ - Downstream `isError` text is bounded at 512 UTF-8 bytes, and error framing
88
+ fits the call's result cap in both result modes.
89
+ - Open deployments warn whenever any connector is configured, since `api()`
90
+ headers can carry secrets without declaring credential hooks.
91
+
92
+ ### Fixed
93
+
94
+ - **A long downstream error could leak the sandbox's per-run secret to guest
95
+ code and let a program forge a typed failure.** The QuickJS bridge sliced a
96
+ rejected host call at 4,000 characters, which clipped the authenticated
97
+ failure frame mid-JSON. Details are bounded before framing, the bridge
98
+ refuses an oversized frame whole, the prelude hides a malformed frame, and
99
+ the raw transport lives in a private closure so guest code cannot swap
100
+ `Error` or reach the bridge.
101
+ - **A completed destructive call was reported as a retryable failure when the
102
+ `get_result` stash write failed.** The truncated preview is returned with a
103
+ paging-unavailable notice, activity records success, and
104
+ `result_processing_failed` is never retryable.
105
+ - **The OAuth refresh gate wedged permanently** when a token fetch succeeded
106
+ but the SDK's save never ran, answering 503 to every later refresh in the
107
+ isolate until a forced reauthorization.
108
+ - **Compact schema rendering went quartic on `allOf`-of-`$ref` schemas.** A
109
+ 3.5 KB downstream schema took over four seconds of synchronous CPU, and the
110
+ describe path produced a 1.9 MB string from 1.8 KB. Rendering now spends a
111
+ shared 2,000-visit budget with memoized `$ref` expansion.
112
+ - `call_tool` in MCP result mode returned `{"content":[]}` for a downstream
113
+ result carrying only `structuredContent`; a text block is now synthesized,
114
+ and a `null` structured value survives unwrapping.
115
+ - In-program `connecta.search` returned `offset: null` and an empty page for a
116
+ non-numeric offset, and threw a raw `TypeError` for a non-string query; both
117
+ are `invalid_args`.
118
+ - Caller-authored text in `get_result`, `authorize_connector`, `skills`, and
119
+ `search_tools` refusals is bounded; a `connector` over 512 bytes is
120
+ `invalid_args`. A failed stash read is a typed `unavailable`.
121
+ - Legacy `mcp-session-id` DELETE now runs on credential rotation, generation
122
+ change, disconnect, and abandoned connects, not only on scope close.
123
+ - Pool names outside `[a-z0-9_-]` fell through to a generic 404 without CORS
124
+ or authentication; every `/mcp/` suffix now reaches the identical pool 404
125
+ after auth.
126
+ - The absent-grant warning set was unbounded and keyed by caller-derivable
127
+ text; it is capped at 1,024 entries.
128
+ - `requiredInputKeys` could name keys the schema did not declare.
129
+ - Validation error detail embedded whole enums; drift digests recursed without
130
+ a depth bound; refresh token responses were read without a byte ceiling;
131
+ credential revision races could loop without bound.
132
+ - Containment matching for escaped failures ignores messages under eight
133
+ characters, and a QuickJS host-result reply that cannot be serialized settles
134
+ the call instead of hanging until the wall deadline.
135
+ - Every `connecta.call` attempt spends one host call on entry, so unknown
136
+ addresses cannot loop for free; the escaped-failure list keeps the most
137
+ recent 64; an empty terminal error string is a failure, not a success.
138
+ - The compact renderer renders `prefixItems` as a tuple, marks
139
+ `dependentSchemas` and `if`/`then`/`else` shapes conditional with the
140
+ truncation flag, and resolves `$dynamicRef` like `$ref`.
141
+ - The guarded transport's no-body-stream path enforces the byte ceiling on
142
+ `text()` and `json()`.
143
+ - The pool-name timing oracle is accepted and documented; `authorize_connector`
144
+ honoring tool-level grants is documented and pinned.
145
+
5
146
  ## 0.24.2 — 2026-09-16
6
147
 
7
148
  `connectorAccess` can now grant individual tools, and a deployment can declare
@@ -39,6 +39,8 @@ export function bearerToken(secret, options = {}) {
39
39
  status: 401,
40
40
  headers: {
41
41
  "Content-Type": "application/json",
42
+ // A configured secret has no OAuth metadata or issuer to
43
+ // advertise; interactive adapters own resource discovery.
42
44
  "WWW-Authenticate": "Bearer",
43
45
  },
44
46
  }),
@@ -13,6 +13,16 @@ interface OAuthRefreshFlight {
13
13
  release: (outcome: OAuthRefreshFlightOutcome) => void;
14
14
  stopObservingOwnerAbort: () => void;
15
15
  mutationId: object;
16
+ writing: boolean;
17
+ /**
18
+ * Set once the token endpoint answered with valid tokens: the authorization
19
+ * server has consumed the rotating refresh token, so if the SDK's own
20
+ * saveTokens never arrives (owner cancelled, redirected, or invalidated),
21
+ * `fail` persists this copy itself rather than stranding the marker or
22
+ * letting a contender redeem the retired token.
23
+ */
24
+ acceptedTokens?: OAuthTokens;
25
+ persist?: (tokens: OAuthTokens) => Promise<void>;
16
26
  }
17
27
  /**
18
28
  * Share one rotating-token redemption within one connector runtime and OAuth
@@ -41,6 +51,8 @@ export declare class OAuthRefreshCoordinator {
41
51
  /** @internal Opaque basis for issuer-aware provider token reads. */
42
52
  successfulRefreshIdentity(generation: string): object | undefined;
43
53
  coordinatedFetch(provider: KvOAuthProvider, baseFetch: FetchLike, requestSignal?: AbortSignal): FetchLike;
54
+ /** Start storage only while this owner still holds its exact flight. */
55
+ beginMutation(generation: string, flight: OAuthRefreshFlight): boolean;
44
56
  /** Publish one exact owner's successful save without disturbing a newer try. */
45
57
  succeedMutation(generation: string, flight: OAuthRefreshFlight): void;
46
58
  /** Give joined callers a fetch/flow failure, without rejecting the gate. */
@@ -92,7 +104,6 @@ export declare class KvOAuthProvider implements OAuthClientProvider {
92
104
  flowGeneration(): Promise<string>;
93
105
  /** @internal Record the refresh attempt this provider owns. */
94
106
  captureRefreshFlight(generation: string, flight: OAuthRefreshFlight): void;
95
- private succeedRefreshFlight;
96
107
  private failRefreshFlight;
97
108
  /** True when another request saved a refresh result after this flow's read. */
98
109
  refreshBasisChanged(current: OAuthTokens, generation: string): boolean;
@@ -63,18 +63,66 @@ function sdkAcceptsOAuthTokens(value) {
63
63
  }
64
64
  return true;
65
65
  }
66
- async function refreshResponseFailure(response) {
66
+ const MAX_REFRESH_RESPONSE_BYTES = 65_536;
67
+ class OversizedRefreshResponse extends Error {
68
+ constructor() {
69
+ super(`OAuth refresh response exceeded ${MAX_REFRESH_RESPONSE_BYTES} bytes.`);
70
+ }
71
+ }
72
+ async function readRefreshResponse(response) {
73
+ if (Number(response.headers.get("content-length")) > MAX_REFRESH_RESPONSE_BYTES) {
74
+ void response.body?.cancel().catch(() => { });
75
+ throw new OversizedRefreshResponse();
76
+ }
77
+ const reader = response.clone().body?.getReader();
78
+ if (!reader)
79
+ return undefined;
80
+ const decoder = new TextDecoder();
81
+ let size = 0;
82
+ let text = "";
83
+ try {
84
+ while (true) {
85
+ const { done, value } = await reader.read();
86
+ if (done)
87
+ break;
88
+ size += value.byteLength;
89
+ if (size > MAX_REFRESH_RESPONSE_BYTES) {
90
+ // A cloned body's cancellation can await its sibling. Cancel both,
91
+ // without making the refusal wait for the provider to finish sending.
92
+ void reader.cancel().catch(() => { });
93
+ void response.body?.cancel().catch(() => { });
94
+ throw new OversizedRefreshResponse();
95
+ }
96
+ text += decoder.decode(value, { stream: true });
97
+ }
98
+ return JSON.parse(text + decoder.decode());
99
+ }
100
+ finally {
101
+ reader.releaseLock();
102
+ }
103
+ }
104
+ /**
105
+ * Classify the token endpoint's answer once, on a clone, and keep the parsed
106
+ * tokens: a valid response has already consumed the rotating refresh token, so
107
+ * the host persists it (`coordinatedFetch`) rather than trusting the SDK's
108
+ * later `saveTokens` to arrive on a request that may already be cancelled.
109
+ */
110
+ async function refreshResponseOutcome(response) {
67
111
  if (!response.ok) {
68
- return new Error(`OAuth refresh failed with HTTP ${response.status}.`);
112
+ return { failure: new Error(`OAuth refresh failed with HTTP ${response.status}.`) };
69
113
  }
114
+ let parsed;
70
115
  try {
71
- return sdkAcceptsOAuthTokens(await response.clone().json())
72
- ? undefined
73
- : new Error("OAuth refresh response did not match the token schema.");
116
+ parsed = await readRefreshResponse(response);
74
117
  }
75
- catch {
76
- return new Error("OAuth refresh response did not contain JSON tokens.");
118
+ catch (error) {
119
+ if (error instanceof OversizedRefreshResponse)
120
+ throw error;
121
+ return { failure: new Error("OAuth refresh response did not contain JSON tokens.") };
77
122
  }
123
+ return sdkAcceptsOAuthTokens(parsed)
124
+ ? { tokens: parsed }
125
+ : { failure: new Error("OAuth refresh response did not match the token schema.") };
78
126
  }
79
127
  function refreshMutationPendingResponse() {
80
128
  return Response.json({
@@ -189,7 +237,9 @@ export class OAuthRefreshCoordinator {
189
237
  const requestedRefreshToken = init?.body instanceof URLSearchParams
190
238
  ? init.body.get("refresh_token")
191
239
  : new URLSearchParams(String(init?.body ?? "")).get("refresh_token");
192
- while (true) {
240
+ for (let attempt = 0; attempt < 64; attempt++) {
241
+ if (requestSignal?.aborted)
242
+ throw aborted(requestSignal);
193
243
  const revisionBeforeReads = this.stateRevision;
194
244
  const activeGeneration = await provider.generation();
195
245
  this.observeAuthoritativeGeneration(activeGeneration);
@@ -258,6 +308,8 @@ export class OAuthRefreshCoordinator {
258
308
  release: (outcome) => release(outcome),
259
309
  stopObservingOwnerAbort: () => { },
260
310
  mutationId: {},
311
+ writing: false,
312
+ persist: (tokens) => provider.saveTokens(tokens),
261
313
  };
262
314
  this.flights.set(generation, flight);
263
315
  this.advanceStateRevision();
@@ -280,18 +332,35 @@ export class OAuthRefreshCoordinator {
280
332
  }
281
333
  try {
282
334
  const response = await baseFetch(input, requestSignal ? { ...init, signal: requestSignal } : init);
283
- // These responses never reach a successful saveTokens callback. Give
335
+ // Failed responses never reach a successful saveTokens callback. Give
284
336
  // current waiters a bounded failure now while leaving the owner's
285
337
  // response untouched for the SDK to parse and classify itself.
286
- const failure = await refreshResponseFailure(response);
287
- if (failure) {
288
- this.fail(generation, flight, failure);
338
+ const outcome = await refreshResponseOutcome(response);
339
+ if (outcome.failure) {
340
+ this.fail(generation, flight, outcome.failure);
341
+ return response;
289
342
  }
290
- else if (requestSignal?.aborted ||
291
- !this.markMutationPending(generation, flight)) {
292
- throw requestSignal?.aborted
293
- ? aborted(requestSignal)
294
- : new Error("OAuth refresh ended before tokens could be saved.");
343
+ // A valid response means the authorization server has consumed the
344
+ // rotating refresh token. The SDK's saveTokens normally persists it;
345
+ // keep this copy so `fail` can persist it instead if that callback
346
+ // never comes (the SDK merges the old refresh token the same way).
347
+ const rotatedRefreshToken = outcome.tokens.refresh_token ?? currentTokens?.refresh_token;
348
+ flight.acceptedTokens = {
349
+ ...outcome.tokens,
350
+ ...(rotatedRefreshToken !== undefined
351
+ ? { refresh_token: rotatedRefreshToken }
352
+ : {}),
353
+ };
354
+ if (!this.markMutationPending(generation, flight)) {
355
+ // Already settled (the owner was cancelled first). Still write the
356
+ // rotation: losing it would leave a dead credential.
357
+ void flight.persist?.(flight.acceptedTokens).catch(() => { });
358
+ throw new Error("OAuth refresh ended before tokens could be saved.");
359
+ }
360
+ if (requestSignal?.aborted) {
361
+ // Same recovery as an abort landing later: `fail` persists.
362
+ this.fail(generation, flight, aborted(requestSignal));
363
+ throw aborted(requestSignal);
295
364
  }
296
365
  return response;
297
366
  }
@@ -300,8 +369,16 @@ export class OAuthRefreshCoordinator {
300
369
  throw error;
301
370
  }
302
371
  }
372
+ return refreshMutationPendingResponse();
303
373
  };
304
374
  }
375
+ /** Start storage only while this owner still holds its exact flight. */
376
+ beginMutation(generation, flight) {
377
+ if (this.flights.get(generation) !== flight)
378
+ return false;
379
+ flight.writing = true;
380
+ return true;
381
+ }
305
382
  /** Publish one exact owner's successful save without disturbing a newer try. */
306
383
  succeedMutation(generation, flight) {
307
384
  if (this.finishMutation(generation, flight)) {
@@ -312,6 +389,37 @@ export class OAuthRefreshCoordinator {
312
389
  }
313
390
  /** Give joined callers a fetch/flow failure, without rejecting the gate. */
314
391
  fail(generation, flight, error) {
392
+ // Only saveTokens owns a live credential write. If it has started, its
393
+ // success/failure callback clears the marker even after owner cancellation.
394
+ if (flight.writing) {
395
+ this.settle(generation, flight, { status: "failed", error });
396
+ return;
397
+ }
398
+ // The token endpoint already answered with valid tokens but the SDK will
399
+ // not save them (the owner was cancelled, redirected, or invalidated).
400
+ // The old refresh token is spent, so the host persists the rotation
401
+ // itself and keeps contenders behind the marker until it lands. Joined
402
+ // callers then receive the saved rotation, never a retired token to
403
+ // redeem again. saveTokens' own bookkeeping settles the flight when the
404
+ // provider still holds it; otherwise settle here once the write ends.
405
+ const tokens = flight.acceptedTokens;
406
+ if (tokens !== undefined &&
407
+ flight.persist !== undefined &&
408
+ this.pendingMutations.get(generation) === flight.mutationId) {
409
+ flight.writing = true;
410
+ void flight.persist(tokens).then(() => {
411
+ if (this.finishMutation(generation, flight)) {
412
+ this.settle(generation, flight, { status: "refreshed" });
413
+ }
414
+ }, (writeError) => {
415
+ if (this.finishMutation(generation, flight)) {
416
+ this.settle(generation, flight, { status: "failed", error: writeError });
417
+ }
418
+ });
419
+ return;
420
+ }
421
+ // A response alone is not a write and must never strand the generation.
422
+ this.finishMutation(generation, flight);
315
423
  this.settle(generation, flight, { status: "failed", error });
316
424
  }
317
425
  /** Finish an exact failed credential write, then publish its failure. */
@@ -417,23 +525,11 @@ export class KvOAuthProvider {
417
525
  captureRefreshFlight(generation, flight) {
418
526
  this.refreshFlight = { generation, flight };
419
527
  }
420
- succeedRefreshFlight() {
421
- const owned = this.refreshFlight;
422
- this.refreshFlight = undefined;
423
- if (owned) {
424
- this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
425
- }
426
- }
427
- failRefreshFlight(error, mutationFinished = false) {
528
+ failRefreshFlight(error) {
428
529
  const owned = this.refreshFlight;
429
530
  this.refreshFlight = undefined;
430
531
  if (owned) {
431
- if (mutationFinished) {
432
- this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
433
- }
434
- else {
435
- this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
436
- }
532
+ this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
437
533
  }
438
534
  }
439
535
  /** True when another request saved a refresh result after this flow's read. */
@@ -650,14 +746,30 @@ export class KvOAuthProvider {
650
746
  return tokens;
651
747
  }
652
748
  async saveTokens(tokens, ctx) {
749
+ // A retired flight (the owner was cancelled or superseded after the
750
+ // token response) still writes: the authorization server has already
751
+ // consumed the old refresh token, so dropping the rotated one would leave a
752
+ // dead credential. writeValue itself refuses when the generation moved.
753
+ // Only the coordinator bookkeeping belongs to the exact live flight.
754
+ const owned = this.refreshFlight;
755
+ const coordinated = owned !== undefined &&
756
+ this.refreshCoordinator?.beginMutation(owned.generation, owned.flight) === true;
653
757
  try {
654
758
  await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
655
- this.succeedRefreshFlight();
759
+ if (coordinated)
760
+ this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
656
761
  }
657
762
  catch (error) {
658
- this.failRefreshFlight(error, true);
763
+ if (coordinated)
764
+ this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
659
765
  throw error;
660
766
  }
767
+ finally {
768
+ // The write owns this identity even if an SDK failure callback has
769
+ // already detached the provider's flight while storage was pending.
770
+ if (this.refreshFlight === owned)
771
+ this.refreshFlight = undefined;
772
+ }
661
773
  }
662
774
  /**
663
775
  * OAuth `state`. The SDK calls this (when present) and appends the value to
@@ -701,9 +813,9 @@ export class KvOAuthProvider {
701
813
  return stored.value;
702
814
  }
703
815
  async redirectToAuthorization(authorizationUrl) {
704
- if (!this.allowAuthorization)
705
- throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
706
816
  try {
817
+ if (!this.allowAuthorization)
818
+ throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
707
819
  await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
708
820
  this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
709
821
  }
@@ -36,6 +36,8 @@ export interface ConnectorCallAdmissionSnapshot {
36
36
  max: number;
37
37
  };
38
38
  }
39
+ /** Sum gauges and counters without publishing connector or principal keys. */
40
+ export declare function aggregateCallAdmissionSnapshots(snapshots: readonly ConnectorCallAdmissionSnapshot[]): ConnectorCallAdmissionSnapshot;
39
41
  /**
40
42
  * Per-runtime, per-connector call admission. State contains only bounded
41
43
  * partition keys, counters, timestamps, signals, and promise continuations;
@@ -64,6 +66,8 @@ export declare class ConnectorCallAdmissionController {
64
66
  acquire(input: Readonly<ConnectorCallAdmissionInput> & {
65
67
  signal?: AbortSignal;
66
68
  }): Promise<CallAdmissionPermit>;
69
+ /** A principal registry may be evicted only after calls and budgets drain. */
70
+ isIdle(): boolean;
67
71
  snapshot(): ConnectorCallAdmissionSnapshot;
68
72
  close(): void;
69
73
  private admit;
@@ -22,6 +22,27 @@ export class CallAdmissionError extends ConnectorCallError {
22
22
  export function isCallAdmissionError(error) {
23
23
  return error instanceof CallAdmissionError;
24
24
  }
25
+ /** Sum gauges and counters without publishing connector or principal keys. */
26
+ export function aggregateCallAdmissionSnapshots(snapshots) {
27
+ const aggregate = {
28
+ rules: 0, partitions: 0, active: 0, queued: 0, closed: snapshots.length > 0,
29
+ totals: { admitted: 0, queued: 0, rejected: 0, rateLimited: 0, cancelled: 0 },
30
+ queueWaitMs: { count: 0, total: 0, max: 0 },
31
+ };
32
+ for (const snapshot of snapshots) {
33
+ for (const key of ["rules", "partitions", "active", "queued"]) {
34
+ aggregate[key] += snapshot[key];
35
+ }
36
+ aggregate.closed &&= snapshot.closed;
37
+ for (const key of ["admitted", "queued", "rejected", "rateLimited", "cancelled"]) {
38
+ aggregate.totals[key] += snapshot.totals[key];
39
+ }
40
+ aggregate.queueWaitMs.count += snapshot.queueWaitMs.count;
41
+ aggregate.queueWaitMs.total += snapshot.queueWaitMs.total;
42
+ aggregate.queueWaitMs.max = Math.max(aggregate.queueWaitMs.max, snapshot.queueWaitMs.max);
43
+ }
44
+ return aggregate;
45
+ }
25
46
  function positiveWhole(value, name) {
26
47
  if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
27
48
  throw new TypeError(`${name} must be a positive whole number.`);
@@ -194,6 +215,11 @@ export class ConnectorCallAdmissionController {
194
215
  waiter.onAbort();
195
216
  });
196
217
  }
218
+ /** A principal registry may be evicted only after calls and budgets drain. */
219
+ isIdle() {
220
+ this.evictIdlePartitions(Date.now());
221
+ return this.partitions.size === 0;
222
+ }
197
223
  snapshot() {
198
224
  let active = 0;
199
225
  let queued = 0;
@@ -36,15 +36,20 @@ export function boundedCatalogDrift(report) {
36
36
  }
37
37
  const encoder = new TextEncoder();
38
38
  /** Deterministic JSON: object keys sorted, so key order is not a schema change. */
39
- function canonicalize(value) {
40
- if (Array.isArray(value))
41
- return value.map(canonicalize);
39
+ function canonicalize(value, depth = 0) {
40
+ // Drift is advisory. Beyond this bound compare an explicit marker instead
41
+ // of letting a downstream schema exhaust the host stack.
42
+ if (depth > 64)
43
+ return "[schema depth truncated]";
44
+ if (Array.isArray(value)) {
45
+ return value.map((item) => canonicalize(item, depth + 1));
46
+ }
42
47
  if (value === null || typeof value !== "object")
43
48
  return value;
44
49
  const entries = Object.entries(value)
45
50
  .filter(([, item]) => item !== undefined)
46
51
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
47
- return Object.fromEntries(entries.map(([key, item]) => [key, canonicalize(item)]));
52
+ return Object.fromEntries(entries.map(([key, item]) => [key, canonicalize(item, depth + 1)]));
48
53
  }
49
54
  /**
50
55
  * Digest the schemas of one downstream tool.
@@ -114,6 +114,8 @@ export interface CatalogDescription {
114
114
  inputSchema?: unknown;
115
115
  outputSchema?: unknown;
116
116
  outputSchemaSource?: "observed";
117
+ inputSchemaTruncated?: true;
118
+ outputSchemaTruncated?: true;
117
119
  annotations?: ToolDef["annotations"];
118
120
  error?: string;
119
121
  errorDetails?: CatalogDescriptionFailureDetail;
@@ -1,4 +1,4 @@
1
- import { compactDiscoverySchema, compactSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, summarizeDiscoveryDescription, summarizeDescription, } from "./catalog.js";
1
+ import { compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, summarizeDiscoveryDescription, summarizeDescription, } from "./catalog.js";
2
2
  import { mapSettledWithConcurrency, resolveDiscoveryConcurrency, } from "./concurrency.js";
3
3
  import { boundedEchoText, classifyCallError, framingError, } from "./errors.js";
4
4
  import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName, } from "./skills.js";
@@ -180,9 +180,6 @@ function schemaKeyMetadata(input, output) {
180
180
  : {}),
181
181
  };
182
182
  }
183
- function renderSchema(schema, format) {
184
- return format === "json" ? schema : compactSchema(schema);
185
- }
186
183
  function renderSearchSchema(schema, format) {
187
184
  if (format === "json")
188
185
  return { schema, truncated: false };
@@ -337,11 +334,25 @@ export class CatalogService {
337
334
  };
338
335
  }
339
336
  async search(args) {
337
+ if (args.query !== undefined && typeof args.query !== "string") {
338
+ throw new DiscoveryPolicyError("invalid_args", "query must be a string. Omit it or use an empty string to browse the catalog.");
339
+ }
340
+ if (args.connector !== undefined &&
341
+ boundedEchoText(args.connector) !== args.connector) {
342
+ // The scope is echoed back as `queryAnalysis.connectorScope`; a clipped
343
+ // copy could name a different connector, so refuse instead of clamping.
344
+ throw new DiscoveryPolicyError("invalid_args", "connector must be at most 512 UTF-8 bytes.");
345
+ }
340
346
  const query = args.query ?? "";
341
347
  const retrievalQuery = lexicalSearchQuery(query);
342
348
  const safety = discoverySafety(args.safety);
343
349
  const limit = discoverySearchLimit(args.limit);
344
- const offset = Math.max(0, Math.trunc(args.offset ?? 0));
350
+ if (args.offset !== undefined && (typeof args.offset !== "number" ||
351
+ !Number.isInteger(args.offset) ||
352
+ args.offset < 0)) {
353
+ throw new DiscoveryPolicyError("invalid_args", "offset must be a non-negative whole number. Start at 0 or use the previous page's nextOffset.");
354
+ }
355
+ const offset = args.offset ?? 0;
345
356
  const scopedConnector = args.connector
346
357
  ? this.registry.getConnector(args.connector)
347
358
  : undefined;
@@ -768,7 +779,11 @@ export class CatalogService {
768
779
  const input = tool.inputSchema ?? { type: "object" };
769
780
  const output = this.outputSchema(addressResolution.connector.id, tool);
770
781
  const description = summarizeDescription(tool.description, args.fullDescriptions === true);
771
- const requiredReasons = guideRequiredReasons(addressResolution.connector, tool, false);
782
+ const compactInput = format === "compact"
783
+ ? compactDescriptionSchema(input) : undefined;
784
+ const compactOutput = format === "compact" && output.schema
785
+ ? compactDescriptionSchema(output.schema) : undefined;
786
+ const requiredReasons = guideRequiredReasons(addressResolution.connector, tool, compactInput?.truncated === true || compactOutput?.truncated === true);
772
787
  const guideSummary = connectorGuideSummary(addressResolution.connector);
773
788
  return {
774
789
  address,
@@ -786,10 +801,12 @@ export class CatalogService {
786
801
  guideRequiredReasons: requiredReasons,
787
802
  }
788
803
  : {}),
789
- inputSchema: renderSchema(input, format),
804
+ inputSchema: compactInput?.text ?? input,
805
+ ...(compactInput?.truncated ? { inputSchemaTruncated: true } : {}),
806
+ ...(compactOutput?.truncated ? { outputSchemaTruncated: true } : {}),
790
807
  ...(output.schema
791
808
  ? {
792
- outputSchema: renderSchema(output.schema, format),
809
+ outputSchema: compactOutput?.text ?? output.schema,
793
810
  }
794
811
  : {}),
795
812
  ...(output.source ? { outputSchemaSource: output.source } : {}),
package/dist/catalog.d.ts CHANGED
@@ -50,6 +50,8 @@ export declare function lexicalCorpusStatistics(toolSets: ToolDef[][], query: st
50
50
  export declare function rankTools(tools: ToolDef[], query: string, mode?: LexicalMatchMode, statistics?: LexicalCorpusStatistics, exactNameQuery?: string): RankedTool[];
51
51
  /** Render and cache a compact TypeScript-like representation of JSON Schema. */
52
52
  export declare function compactSchema(schema: JsonSchema): string;
53
+ /** Describe allows 8 KiB for property prose, with the same work cap as search. */
54
+ export declare function compactDescriptionSchema(schema: JsonSchema): CompactDiscoverySchema;
53
55
  export interface CompactDiscoverySchema {
54
56
  text: string;
55
57
  truncated: boolean;