@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.
- package/CHANGELOG.md +141 -0
- package/dist/auth/bearer.js +2 -0
- package/dist/auth/downstream-oauth.d.ts +12 -1
- package/dist/auth/downstream-oauth.js +147 -35
- package/dist/call-admission.d.ts +4 -0
- package/dist/call-admission.js +26 -0
- package/dist/catalog-drift.js +9 -4
- package/dist/catalog-service.d.ts +2 -0
- package/dist/catalog-service.js +25 -8
- package/dist/catalog.d.ts +2 -0
- package/dist/catalog.js +246 -121
- package/dist/connectors/api.js +11 -1
- package/dist/connectors/guarded-fetch.d.ts +1 -1
- package/dist/connectors/guarded-fetch.js +27 -20
- package/dist/connectors/remote-mcp.js +84 -53
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +58 -0
- package/dist/execute.js +85 -23
- package/dist/executor-result.js +3 -1
- package/dist/executors/quickjs-child.js +5 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -0
- package/dist/executors/quickjs-runtime.d.ts +1 -1
- package/dist/executors/quickjs-runtime.js +38 -21
- package/dist/executors/quickjs.js +68 -27
- package/dist/index.d.ts +14 -0
- package/dist/index.js +24 -3
- package/dist/invocation.js +134 -93
- package/dist/mcp-result.js +3 -2
- package/dist/meta-tools.js +118 -39
- package/dist/registry.d.ts +14 -2
- package/dist/registry.js +87 -13
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +84 -13
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +1 -0
- package/dist/routes/shared.js +4 -4
- package/dist/server.js +15 -3
- package/dist/skills.js +6 -5
- package/dist/storage/file.d.ts +6 -2
- package/dist/storage/file.js +312 -34
- package/dist/storage/memory.js +12 -1
- package/dist/validate.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +22 -6
- package/documentation/auth.md +42 -9
- package/documentation/call-admission.md +24 -8
- package/documentation/code-mode.md +34 -22
- package/documentation/connectors.md +47 -5
- package/documentation/meta-tools.md +74 -6
- package/documentation/operations.md +19 -19
- package/documentation/provider-conventions.md +7 -0
- package/documentation/request-admission.md +38 -4
- package/documentation/storage-and-credentials.md +54 -1
- package/documentation/upgrading.md +18 -4
- package/package.json +1 -1
- 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
|
package/dist/auth/bearer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
72
|
-
? undefined
|
|
73
|
-
: new Error("OAuth refresh response did not match the token schema.");
|
|
116
|
+
parsed = await readRefreshResponse(response);
|
|
74
117
|
}
|
|
75
|
-
catch {
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
759
|
+
if (coordinated)
|
|
760
|
+
this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
|
|
656
761
|
}
|
|
657
762
|
catch (error) {
|
|
658
|
-
|
|
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
|
}
|
package/dist/call-admission.d.ts
CHANGED
|
@@ -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;
|
package/dist/call-admission.js
CHANGED
|
@@ -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;
|
package/dist/catalog-drift.js
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
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;
|
package/dist/catalog-service.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { compactDiscoverySchema,
|
|
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
|
-
|
|
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
|
|
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:
|
|
804
|
+
inputSchema: compactInput?.text ?? input,
|
|
805
|
+
...(compactInput?.truncated ? { inputSchemaTruncated: true } : {}),
|
|
806
|
+
...(compactOutput?.truncated ? { outputSchemaTruncated: true } : {}),
|
|
790
807
|
...(output.schema
|
|
791
808
|
? {
|
|
792
|
-
outputSchema:
|
|
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;
|