@zackbart/connecta 0.5.0 → 0.6.1

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 (76) hide show
  1. package/CHANGELOG.md +505 -0
  2. package/README.md +159 -267
  3. package/dist/auth/bearer.d.ts +10 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +21 -0
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +28 -3
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +161 -4
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  12. package/dist/connectors/remote-mcp.js +8 -0
  13. package/dist/connectors/remote-mcp.js.map +1 -1
  14. package/dist/credential-health.d.ts +220 -0
  15. package/dist/credential-health.d.ts.map +1 -0
  16. package/dist/credential-health.js +551 -0
  17. package/dist/credential-health.js.map +1 -0
  18. package/dist/credentials.d.ts +35 -1
  19. package/dist/credentials.d.ts.map +1 -1
  20. package/dist/credentials.js +42 -0
  21. package/dist/credentials.js.map +1 -1
  22. package/dist/execute.d.ts.map +1 -1
  23. package/dist/execute.js +16 -4
  24. package/dist/execute.js.map +1 -1
  25. package/dist/index.d.ts +46 -5
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +118 -15
  28. package/dist/index.js.map +1 -1
  29. package/dist/meta-tools.d.ts +56 -5
  30. package/dist/meta-tools.d.ts.map +1 -1
  31. package/dist/meta-tools.js +249 -92
  32. package/dist/meta-tools.js.map +1 -1
  33. package/dist/registry.d.ts +62 -0
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +85 -1
  36. package/dist/registry.js.map +1 -1
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +305 -40
  39. package/dist/server.js.map +1 -1
  40. package/dist/skills.d.ts +1 -1
  41. package/dist/skills.d.ts.map +1 -1
  42. package/dist/skills.js +3 -3
  43. package/dist/skills.js.map +1 -1
  44. package/dist/timeout.d.ts +16 -0
  45. package/dist/timeout.d.ts.map +1 -0
  46. package/dist/timeout.js +38 -0
  47. package/dist/timeout.js.map +1 -0
  48. package/dist/toolkits.d.ts +95 -1
  49. package/dist/toolkits.d.ts.map +1 -1
  50. package/dist/toolkits.js +190 -5
  51. package/dist/toolkits.js.map +1 -1
  52. package/dist/types.d.ts +81 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/ui.d.ts +52 -0
  55. package/dist/ui.d.ts.map +1 -1
  56. package/dist/ui.js +144 -13
  57. package/dist/ui.js.map +1 -1
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +1 -1
  61. package/src/auth/bearer.ts +35 -1
  62. package/src/auth/clerk.ts +204 -7
  63. package/src/connectors/remote-mcp.ts +9 -0
  64. package/src/credential-health.ts +753 -0
  65. package/src/credentials.ts +71 -1
  66. package/src/execute.ts +28 -4
  67. package/src/index.ts +204 -22
  68. package/src/meta-tools.ts +286 -109
  69. package/src/registry.ts +125 -1
  70. package/src/server.ts +366 -38
  71. package/src/skills.ts +3 -3
  72. package/src/timeout.ts +49 -0
  73. package/src/toolkits.ts +241 -6
  74. package/src/types.ts +87 -1
  75. package/src/ui.ts +156 -14
  76. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,511 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.6.1 — 2026-07-26
6
+
7
+ A patch release: three bug fixes and a documentation overhaul. No new
8
+ configuration, no new exported types, no change to the published API surface —
9
+ `ConnectaConfig` and every type are byte-identical to 0.6.0. Nothing here is
10
+ breaking. But two of the fixes *withdraw* behavior a deployment could have been
11
+ relying on by accident, and in both cases withdrawing it is what the fix is, not
12
+ a decision taken alongside it:
13
+
14
+ - **A `uiAuth.signInUrl` or `signUpUrl` that is not an absolute `https:` URL is
15
+ now dropped** rather than handed to `Clerk.load` (issue #56). Relative paths,
16
+ `http:`, protocol-relative, `javascript:` and `data:` values all fall together,
17
+ under the same gate `frontendApiUrl` has always passed. A rejected value reaches no part of the rendered page: the key is
18
+ simply absent from `/ui`'s inline `AUTH` object, so Clerk falls back to its own
19
+ default exactly as it does for an unset field, and `/ui` still renders and
20
+ still signs operators in. Construction logs one warning naming each dropped
21
+ field. What belongs in these fields is a hosted Account Portal address
22
+ (`https://accounts.<domain>`, `https://<slug>.accounts.dev`), so a deployment
23
+ that noticed this at all was pointing operators at a sign-in page connecta does
24
+ not host.
25
+ - **The declared credential shape now picks the credential test hook, on every
26
+ surface** (issue #55). Two of the four shape/hook combinations change behavior.
27
+ A **single-value `credential` declaring both hooks** now runs
28
+ `testCredential(value)` with the raw string, where the test route previously
29
+ preferred `testCredentials` and handed it the vault's reserved `{ value }` map
30
+ — the single-value hook now receives the string it was written to expect
31
+ instead of a one-entry map named after a storage detail. A **single-value
32
+ `credential` declaring only `testCredentials`** is no longer tested or probed
33
+ at all: /ui renders no Test button, a direct POST answers 400 naming the
34
+ mismatch, `createConnecta` warns at construction, and the liveness sweep added
35
+ in 0.6.0 reports it `not_checkable` instead of calling a hook with a shape its
36
+ connector never declared. Both of those are the fallback order that *was* the
37
+ bug, so there is no version of this fix that preserves them. Named `fields`
38
+ with `testCredentials`, or with both hooks, behave exactly as they did.
39
+
40
+ One textual delta reaches every deployment and no configuration turns it off, so
41
+ anything snapshotting agent-facing error text will diff: `resolveSkill`'s two
42
+ `Available:` branches now say `Available skills:` like the other three (issue
43
+ #50).
44
+
45
+ The docs are substantially reorganized, and one of the changes ships to npm:
46
+ readers of the package page get a completely different README. See Changed. The
47
+ next intentional breaking release stays reserved for issue #28.
48
+
49
+ ### Changed
50
+
51
+ - **The README is rewritten around the problem connecta solves** (345 lines to
52
+ 196). It had become a condensed reference manual — roughly 60% code blocks and
53
+ option tables, every one of them duplicated in `docs/documentation.md` or
54
+ `examples/` — with a single paragraph explaining why connecta exists. npm
55
+ readers see only this file, so it was the worst place for the duplication and
56
+ the best place for the argument. It now runs as prose: the context-window and
57
+ per-client configuration costs of connecting an agent to N MCP servers, what
58
+ connecta does about them, why it is shaped the way it is (config as code, a
59
+ server-side credential vault with liveness checks, fail-closed read-only,
60
+ toolkits and who is admitted to them, payload-free activity, a read-only
61
+ operator dashboard), when *not* to use it, one minimal Node quickstart, and
62
+ links out. Removed from it and unchanged in `docs/` and `examples/`: the
63
+ nine-row signature-level meta-tool table (the nine names now appear in prose
64
+ with their purposes), the Worker quickstart, and the toolkits, code-mode,
65
+ credentials, activity and branding blocks with their option semantics.
66
+ **Links out of the README are now absolute GitHub URLs**, because `docs/` does
67
+ not ship in the package and relative links are dead on npmjs.com. This is the
68
+ only change in the release that reaches npm as content rather than code.
69
+ - **`docs/design.md` is retired into a new `docs/decisions.md`.** design.md was
70
+ mostly a worse copy of documentation.md, and it had gone stale in at least one
71
+ load-bearing way: it claimed the Clerk adapter passes `authorizedParties:
72
+ [connectaOrigin]` to `authenticateRequest`. It does not, and must not — OAuth
73
+ access tokens may be JWTs with no `azp` claim, and Clerk rejects `azp ===
74
+ undefined` when that option is set, so passing it would reject every MCP
75
+ client; the `azp` pin is applied by hand after verification, for session tokens
76
+ only. decisions.md records that as a **rejected alternative** rather than
77
+ repeating the wrong version. It answers two questions documentation.md does
78
+ not: "may I build X?" (non-goals, rejected alternatives) and "must my change
79
+ preserve Y?" (invariants) — collecting the invariants documentation.md states
80
+ but never gathers as pointers rather than duplicated prose, alongside the four
81
+ that lived only in design.md: no runtime admin, nothing request-bound surviving
82
+ a request, single tenant, and fail-closed read-only. Two facts moved into
83
+ documentation.md §13 instead, next to the code they explain. Inbound references
84
+ in the README, documentation.md and the Docker example follow; **any external
85
+ link to `docs/design.md` is now dead**, and the 0.x CHANGELOG mentions of it
86
+ stay as historical record. The credential-test invariant added by issue #55
87
+ also gained the qualifier §17 already carried: a connector whose shape and hook
88
+ mismatch carries no verdict *from a credential hook*, but is still probed
89
+ through `status()` if it declares one, since that question never involves the
90
+ shape.
91
+ - **CLAUDE.md is an agent brief rather than a policy stub.** It now states what
92
+ gates "done" (`npm run check`, and what that runs), the two-document map
93
+ (documentation.md as reference manual, decisions.md as non-goals and
94
+ invariants) with the warning that its section numbers are linked from source
95
+ comments, the two CI-enforced boundaries that are invisible from inside a
96
+ single file (import-graph purity, the published surface) as where-new-code-goes
97
+ guidance, the `WORKERS_SUITES` allowlist trap that silently skips a portable
98
+ suite left out of it, and the commit, CHANGELOG and release conventions. The
99
+ roadmap-lives-in-GitHub-issues policy is unchanged, verbatim.
100
+
101
+ ### Fixed
102
+
103
+ - **One rule decides how a credential is tested, and three copies of it no
104
+ longer disagree** (issue #55). `/ui` offered a Test button from the mere
105
+ presence of a hook (`testCredential || testCredentials`); the test route made
106
+ its own different choice — prefer `testCredentials`, else `testCredential` on
107
+ the vault's reserved `value` field; and 0.6.0's credential-health prober added
108
+ a third copy of that same preference order. They disagreed in both mismatch
109
+ shapes, and the visible bug was the ugliest one: a connector declaring named
110
+ `credential.fields` with only `testCredential` answered **409 "configure the
111
+ credential before testing it" on a fully configured credential**, blaming the
112
+ operator for connecta's own hook selection. `credentialTestRule` in
113
+ `src/credentials.ts` is now the single source of truth — `buildUiData`'s
114
+ `testable` flag, the test route's hook selection, the credential-health
115
+ prober's `testHookFor`/`isCheckableConnector`, and a new construction-time
116
+ warning all read it, so they cannot drift apart again. **The declared shape
117
+ picks the hook and the other one is never substituted**: named fields are
118
+ tested by `testCredentials` with the whole set, a single value by
119
+ `testCredential` with the raw string. A connector implementing only the
120
+ mismatched hook is not testable rather than testable-by-accident — no button, a
121
+ 400 sharing its wording with the boot warning, and `not_checkable` to the
122
+ prober, which carries no verdict rather than an invented one (handing
123
+ `testCredential` a `value` that named fields never wrote would test the empty
124
+ string and record a confident `auth_required` about a credential nothing
125
+ examined). `status()` is unaffected: a mismatched connector that implements it
126
+ is still probed through it. The two behavior deltas this produces are stated
127
+ above. Recorded as an invariant in `docs/decisions.md`, and stated in
128
+ documentation.md §4, §7, §14 and §17.
129
+ - **`uiAuth.signInUrl` and `signUpUrl` are gated like every other URL that
130
+ reaches the browser** (issue #56). They were the residual — operator config
131
+ that arrives in the page as a *navigation target* rather than an attribute, so
132
+ no gate covered them and §14's URL-position invariant had to be read with an
133
+ exception beside it. Both are serialized into `/ui`'s inline `AUTH` object and
134
+ handed to `Clerk.load`, which navigates to them when an operator signs in. They
135
+ now pass the same gate `frontendApiUrl` does, so `isSafeScriptSrcUrl` — named
136
+ for the one position it used to guard — becomes `isSafeHttpsUrl`, one predicate
137
+ for all three `uiAuth` URLs. Absolute `https:` only, for the typed fields as
138
+ for the derived one: the loose carve-outs buy nothing real, since `http:` would
139
+ carry a sign-in over cleartext and a path relative to this origin is
140
+ meaningless because connecta hosts no sign-in page of its own. The drop warning
141
+ decides "did the operator mean to supply this?" through the same `isSetUrlValue`
142
+ helper `branding` uses, so a blank-string or falsy non-string value cannot warn
143
+ on one path and stay silent on the other. **§14 now states the invariant over
144
+ every URL position — attribute and navigation target — with no carve-out left.**
145
+ - **`resolveSkill` enumerates its list under one label** (issue #50). Three
146
+ branches said `Available skills:` and two said `Available:`, so which one an
147
+ agent saw depended on the branch it hit rather than on any difference in
148
+ meaning. Agent-facing error text is interface, and two labels read as two
149
+ concepts where there is one. Both stragglers now say `Available skills:`, and a
150
+ test walks all five error branches so reverting any one of them fails.
151
+
152
+ ## 0.6.0 — 2026-07-26
153
+
154
+ A feature release that makes two of 0.5.0's mechanisms protective rather than
155
+ merely organizational. Toolkits gain an identity binding, so a credential opens
156
+ the view its team was given and nothing else; connector status gains a proactive
157
+ credential liveness layer, so an expired downstream token shows up as
158
+ `auth_required` before an agent's call trips over it; and `clerkAuth` gains an
159
+ email-domain allowlist. Eight fixes on the result path and the diagnostic
160
+ surfaces sit underneath them. Nothing here is breaking — every new config key is
161
+ optional and every new type is additive — but this release is *not* runtime-inert
162
+ for a deployment that declares none of them, and the deltas are worth reading
163
+ before upgrading:
164
+
165
+ - **MCP-mode truncation now measures what it truncates.** A `kind: "mcp"`
166
+ connector's result whose size falls between the old text-only measure and the
167
+ serialized envelope came back inline in 0.5.0 and now truncates, pages, and
168
+ reports `totalBytes` in envelope bytes (issue #43, below). It is deliberate,
169
+ and it is the one change on the result path that a deployment can notice
170
+ without changing any configuration.
171
+ - **A handler returning `undefined` renders as the text `undefined`** under
172
+ `resultMode: "mcp"`, where 0.5.0 emitted a `TextContent` block with no `text`
173
+ at all (issue #42). Every serializable return is byte-identical.
174
+ - **The cheap status path reports more failures.** `list_connectors({ probe:
175
+ false })` now reads `error` for a connector whose *catalog* load keeps failing
176
+ (issue #46) and `auth_required` when a background liveness check found the
177
+ stored credential dead (issue #24). Both are the fix; both mean a connector
178
+ that read clean in 0.5.0 while being broken now reads broken.
179
+ - **Credential liveness checks are on by default.** A deployment holding an
180
+ operator-managed `credential` or a downstream OAuth grant begins making
181
+ liveness calls — its own `testCredential(s)` hook, or the OAuth
182
+ `status(ctx)` refresh — piggybacked on authenticated `/mcp` and `/ui/data`
183
+ traffic, at most one per connector per 15 minutes. No downstream *tool* is
184
+ ever called and no catalog is fetched. Setting `credentialHealth.onRequest`
185
+ to `false` turns that trigger off; the explicit `checkCredentials()` call
186
+ stays available.
187
+ - **Three textual deltas that no configuration turns off**, so anything
188
+ snapshotting them will diff: `get_result`'s tool description now documents the
189
+ `offset` domain and the codepoint realignment, reaching every deployment (its
190
+ JSON Schema is unchanged, `minimum: 0` either way); both favicon routes now
191
+ carry `X-Content-Type-Options` and a `Content-Security-Policy` header, with
192
+ the bodies byte-identical; and any deployment declaring toolkits gets a
193
+ rewritten startup warning, now split into three.
194
+
195
+ The next intentional breaking release stays reserved for issue #28.
196
+
197
+ ### Added
198
+
199
+ - **Toolkit ↔ identity binding — a credential opens the view its team was
200
+ given** (issue #37). Toolkits shipped in 0.5.0 with self-service selection:
201
+ any authenticated caller could name any toolkit, or omit `?toolkit=` and get
202
+ the whole registry. The binding closes that. It is declared **on the auth
203
+ adapter**, not in a deployment-level table keyed by identity id —
204
+ `bearerToken(secret, { subjectId: "support-team", toolkits: ["support"] })`,
205
+ and the same two options on `clerkAuth` — because a typo in such a key means
206
+ *unbound*, which fails open and hands over everything, while a typo in a
207
+ toolkit *name* throws at construction. `InboundAuth.toolkitBinding` is the
208
+ provider-wide declaration and `AuthResult.toolkitBinding` overrides it per
209
+ identity, the documented seam for an adapter mapping its own users to views;
210
+ the declaration is a **ceiling**, not a default — connecta intersects the two
211
+ and grants `unscoped` only if both do, so an adapter reading a user-writable
212
+ IdP claim cannot let a user widen their own binding. Enforcement is one point
213
+ (`resolveToolkitScope`, after the auth gate and before any `ScopedRegistry`
214
+ exists) deciding *whether* a scope is built; what a built scope contains stays
215
+ entirely in `ScopedRegistry`. For a bound identity, a toolkit outside the
216
+ binding, an undeclared or malformed name, and — without `unscoped: true` — a
217
+ connection with no `?toolkit=` are all **403, byte-identical in status and
218
+ body**, and the body names no toolkit: a team credential must not become a
219
+ directory of the org's other teams. The operator log is where the three
220
+ reasons are told apart, naming the identity, the reason and the binding, with
221
+ the same 64-character bounding and U+2028/U+2029 escaping the toolkit
222
+ rejection uses. A bound-but-not-`unscoped` identity is also refused the
223
+ deployment-wide operator surfaces — `/ui/data`, `/ui/activity`, and the
224
+ credential API — because those payloads describe every connector in the org
225
+ and a credential write reaches every view; `/health` and the open routes are
226
+ unchanged. **An unbound identity behaves exactly as 0.5.0 shipped**, and
227
+ bindings are per identity, so a legacy token beside two bound ones keeps
228
+ working. Bindings are validated in three places that each catch what the
229
+ others cannot: the adapter throws on a declaration that does not mean what it
230
+ says (`unscoped` with no `toolkits`, an empty `toolkits` without it, a name
231
+ outside the grammar, a non-array); `createConnecta` cross-checks every name
232
+ against the declared toolkits and throws on an unknown one; and **every
233
+ request re-validates the binding it is about to enforce**, because
234
+ `InboundAuth` is an open interface and neither half can be trusted from the
235
+ type — a binding that fails validation is a 403 with a log line, never a
236
+ silent drop, since dropping it would read as "unbound". `ToolkitBinding`,
237
+ `ToolkitBindingOptions` and `BearerTokenOptions` are exported. The startup
238
+ warning story changes with it (see Fixed).
239
+ - **Proactive credential liveness checks, so a dead credential surfaces before
240
+ a call fails** (issue #24). A connector's auth status previously flipped only
241
+ when something *observed* a failure — an agent's real call erroring
242
+ `auth_required`, or an operator running `list_connectors({ probe: true })` —
243
+ so a revoked token surfaced mid-task. Connecta now checks the credentials it
244
+ stores and serves the verdict from the cheap surfaces. Only two credential
245
+ shapes are eligible, each asked through the hook that exists to answer exactly
246
+ this question: an operator-managed `credential` via `testCredentials(values)`
247
+ / `testCredential(value)` (the same call /ui's Test button makes), and a
248
+ downstream OAuth grant via `status(ctx)`, whose refresh *is* the liveness
249
+ question for a token. **A check never calls a downstream tool and never
250
+ fetches a catalog**, so it cannot mutate downstream state and no destructive
251
+ tool is reachable from it, and **a connector with nothing stored is never
252
+ probed** — a new optional `Connector.hasStoredCredential` answers for
253
+ connectors holding their own credential (`remoteMcp` implements it for
254
+ oauth) and the vault answers for the rest, so a timer never starts DCR and
255
+ consent for a connector nobody has authorized. The core starts **no timers**,
256
+ because it has to run unchanged on Workers: instead there are two triggers
257
+ sharing one budget — an authenticated `/mcp` or `/ui/data` request hands a
258
+ *due* sweep to `ctx.waitUntil` (never awaited by the request, never triggered
259
+ by an unauthenticated or refused one), and the new
260
+ `Connecta.checkCredentials({ baseUrl?, force?, ids? })` is an ordinary awaited
261
+ call for the host's own scheduler — wired to a cron trigger in the Worker
262
+ example and a `setInterval(...).unref()` in the Node and Docker ones —
263
+ returning one `CredentialCheckResult` per connector considered, and never
264
+ rejecting on a connector failure.
265
+ Cost is bounded four ways: eligibility, a persisted freshness window
266
+ (`intervalSeconds`, default 900, shared across isolates so repeated status
267
+ reads never each produce a check), one traffic-triggered sweep per interval
268
+ per isolate, and a per-check deadline (`timeoutMs`, default 30 000) with at
269
+ most `concurrency` (default 4) in flight. Verdicts are persisted under
270
+ `credhealth:<connectorId>` rather than held in memory, because on Workers the
271
+ cron isolate is not the isolate answering status reads. `list_connectors`
272
+ gains `credentialCheck: { state, checkedAt, message?, authorizationUrl? }`,
273
+ present only for connectors holding a stored credential, and /ui's connector
274
+ card renders it. What a verdict may *decide* is deliberately narrow and lives
275
+ in one function: only `auth_required` ever sets the `probe: false` status, and
276
+ only while nothing better has happened since — a real call that succeeded
277
+ after `checkedAt` retires it, read deployment-wide so a sibling toolkit's
278
+ success counts. An `error` verdict is **reported and decides nothing**: a
279
+ check that timed out or got a 502 failed to *complete* and learned nothing
280
+ about the credential, so letting it set the status would flip a working
281
+ connector to `error` for a whole interval on a DNS blip. An `ok` verdict
282
+ upgrades `unknown` to `ok` and never downgrades an observed failure, while
283
+ `auth_required` outranks even a newer real-call failure — both say something
284
+ is wrong, only one carries the URL that fixes it, and the failure stays
285
+ visible as `lastError`. Recovery needs no restart and is fenced against a
286
+ check in flight: a completed `/oauth/callback/<id>` and a credential `PUT` or
287
+ `DELETE` drop the verdict and advance a per-connector generation counter
288
+ (`credhealth:gen:<id>`) that a check captures at its start and re-reads before
289
+ writing, so a check that began before consent finished cannot resurrect the
290
+ stale `auth_required` and its stale consent URL. `credentialHealth` on
291
+ `ConnectaConfig` tunes all of it; `CredentialHealthConfig`,
292
+ `CredentialCheckResult`, `CredentialCheckSkip`, `CredentialCheckState` and
293
+ `CredentialHealthRecord` are exported. Automatic re-authorization is out of
294
+ scope — refresh rotation is already the OAuth flow's job and interactive
295
+ re-consent stays manual through `authorize_connector`. **Known gap, filed not
296
+ fixed:** a probe leaves a downstream MCP session open, because
297
+ `remoteMcp().status()` connects a client that nothing closes. That has been
298
+ true of `list_connectors({ probe: true })` and `/ui/data` since long before
299
+ this release; the sweep makes it periodic rather than operator-triggered.
300
+ Closing it needs a teardown seam on the `Connector` contract, so it is issue
301
+ #66 and is named in the docs beside the feature.
302
+ - **`clerkAuth({ allowedDomains })` — "anyone @acme.com, nobody else" as one
303
+ option** (issue #58). The policy was expressible with `gate` already, but only
304
+ by hand-writing fetch-user / read-primary-email / compare-domain /
305
+ handle-the-lookup-failing in every deployment, a security-relevant idiom with
306
+ four ways to get subtly wrong. Matching is exact, case-insensitive and on the
307
+ whole domain: `acme.com` admits `dev@ACME.com` and rejects `evil-acme.com`,
308
+ `acme.com.evil.com`, `acme.co` and `mail.acme.com` — a subdomain must be
309
+ spelled out — and the address is split on its **last** `@`, so
310
+ `"dev@acme.com"@evil.com` resolves to `evil.com`. It **fails closed** on every
311
+ uncertainty (no primary email, an unverified one, a malformed address, a Clerk
312
+ lookup that throws), returning the same bare `403 {"error":"forbidden"}` a
313
+ `gate` rejection returns with no hint why, while the operator gets the reason
314
+ in the log with the domain bounded and escaped and never the local part of
315
+ anyone's address. The list is validated at construction — a non-domain, an
316
+ `@`, an empty list, a non-array, or a Unicode lookalike (internationalized
317
+ domains must be written in punycode) throws where the operator wrote it rather
318
+ than silently binding nothing. It composes with `gate`: each configured one
319
+ must pass, the allowlist is evaluated first so a caller outside the org never
320
+ reaches operator gate code, and one verdict per user covers both and rides the
321
+ existing identity cache, so it costs no more Clerk calls than `gate` alone. It
322
+ governs Clerk sign-in only — a co-configured `bearerToken` has no email to
323
+ read. Unset, no user lookup happens at all and any authenticated user is
324
+ admitted, exactly as before. Where this sits relative to the toolkit binding
325
+ is now documented explicitly: `allowedDomains`/`gate` decide **who gets into
326
+ the org**, the binding decides **what they see** once in. **Residual:** the
327
+ identity cache is a `Map` that never evicts, and an allowlist beside open
328
+ Clerk sign-up is the first configuration in which a stranger can populate it —
329
+ each denied user id costs a permanent entry for the life of the isolate. The
330
+ denial itself is correct and TTL'd; the footprint is issue #70.
331
+
332
+ ### Fixed
333
+
334
+ - **MCP-mode truncation measured text blocks but truncated the envelope, and
335
+ never fired on non-text content at all** (issue #43). The quantity that
336
+ *decided* truncation was the text blocks' byte length; what was truncated,
337
+ stashed and reported as `totalBytes` was `JSON.stringify(content, null, 2)`,
338
+ the strictly larger envelope. So the head and `totalBytes` described a string
339
+ the cap was never compared against (pinned by a test where 240 bytes of text
340
+ sit in a 700-plus-byte envelope under a 300-byte cap — it came back inline
341
+ before and truncates now), and an all-non-text result scored zero text bytes,
342
+ so a single 50 KB base64 `image` block was returned inline, unbounded, with no
343
+ `resultId` to page from. One `guardContent` now measures the same string it
344
+ stashes and counts every block. What truncation *means* differs by content: an
345
+ all-text envelope keeps the historical head plus notice, since a JSON prefix
346
+ is still readable, while an envelope carrying any non-text block is replaced
347
+ by the `{ truncated, resultId, totalBytes, hint }` notice **alone**, because
348
+ the head of a half-written base64 image is useless and leaves unparseable
349
+ block structure. Either way the full envelope is stashed and pages back whole
350
+ through `get_result`, and the ~170-byte notice sits outside the cap as it
351
+ always has on the truncated-head path. Under the cap, blocks pass through
352
+ untouched and in order — including a result whose measurement itself is
353
+ impossible: a `BigInt` or a cycle makes the envelope stringify throw, which is
354
+ caught and falls back to returning the content untouched rather than failing a
355
+ call that used to pass. **The one thing an existing `kind: "mcp"` deployment
356
+ will notice** is stated above: results between the two measures now truncate.
357
+ - **A handler returning `undefined` emitted a `TextContent` block with no
358
+ `text`** (issue #42). `JSON.stringify(undefined, null, 2)` *is* `undefined`,
359
+ not a string; `guardText` then measured `enc.encode(undefined)` — the empty
360
+ string, per the WebIDL default — took the under-cap early return, and emitted
361
+ the non-string unchanged, so clients received `{"content":[{"type":"text"}]}`,
362
+ which `TextContent` does not permit. The three guards that answer this same
363
+ question each defended differently, so they now share one
364
+ `serializeResultText`: JSON text for whatever JSON can represent, `String(
365
+ value)` for what JSON renders as `undefined`, and `guardText` normalizes at
366
+ the door so its size logic is unreachable with a non-string. A value JSON
367
+ cannot serialize at all still throws and is reported as a failure. The
368
+ semantics are now written down where `resultMode` is documented, including the
369
+ sharp edge that only a bare `undefined` renders as `undefined` — a function
370
+ renders as its **source text** and a Symbol as `Symbol(label)`, so a handler
371
+ mistakenly returning a closure puts the function's source in front of the
372
+ model.
373
+ - **`get_result`'s `offset` had none of the defense issue #32 gave `maxBytes`**
374
+ (issue #38). An in-handler `offset: NaN` propagated through `Math.max(0,
375
+ Math.trunc(...))`, sliced to nothing, and serialized as `"offset": null` with
376
+ empty text and no `nextOffset` — the result vanished silently instead of
377
+ erroring. Validity is now one shared rule spelled once for the wire schema and
378
+ once in the handler, exactly as #32 did for the cap, and an out-of-domain
379
+ value is an ordinary input-validation error; an offset past the end of the
380
+ payload stays legal and answers with an empty final page. Separately, a legal
381
+ in-range offset landing mid-codepoint emitted U+FFFD; it is now aligned
382
+ **back** to that character's first byte — re-reading a few bytes is
383
+ recoverable, silently skipping the rest of a character is not — and the offset
384
+ actually served is what the response reports. Server-produced `nextOffset`
385
+ values are already boundaries and are served exactly as given, so paging is
386
+ byte-identical.
387
+ - **A failing catalog lookup never recorded a connector health failure** (issue
388
+ #46). `call_tool`'s catalog-lookup catch returned a failure to the caller but,
389
+ unlike the execution catch a hundred lines below, never called
390
+ `recordFailure`, and neither `getTools` nor `refreshTools` compensated. A
391
+ connector whose `listTools` fails persistently — a revoked downstream grant,
392
+ the scenario the comment at that catch already named — therefore accumulated
393
+ no `consecutiveFailures` and read **clean** from `list_connectors({ probe:
394
+ false })` while every single call against it failed, which is precisely the
395
+ cheap signal an operator or agent consults to find a broken connector. Both
396
+ catalog-load sites now record: the shared `runCall` catch behind `call_tool`,
397
+ `call_destructive_tool` and `batch_call`, and the `execute_code` path's
398
+ provider build, which previously dropped a connector's whole sandbox namespace
399
+ with nothing but a `logger.warn`. The accounting deliberately stays at the
400
+ call sites rather than inside `Registry`: `registry` there is the connection's
401
+ *view*, so a toolkit-scoped session records into its own health log as well as
402
+ the deployment-wide one, and registry-level accounting would leave scoped
403
+ sessions blind while double-counting against the probe path that already
404
+ records. A warm-cache hit still records nothing in either direction — a hit is
405
+ not evidence of health.
406
+ - **The toolkits startup warning fired for a configuration that cannot have the
407
+ risk it named** (issue #45), and now tells three different stories. The check
408
+ tested `config.toolkits` for truthiness; `{}` is truthy but resolves to no
409
+ selectable toolkit at all, so a deployment configured that way was told that
410
+ "any caller can choose any toolkit" — the kind of warning that teaches
411
+ operators to skim past the ones that matter. All warnings are now keyed off
412
+ the **resolved** toolkit map, the same one `?toolkit=` resolves against, so
413
+ they cannot drift from what is actually selectable. With the binding feature
414
+ the single warning splits into three, because the fix differs in each: toolkits
415
+ with no `auth` at all (no identity exists to bind — configure `auth` first);
416
+ toolkits with `auth` but no declared binding anywhere (the shape that
417
+ organizes without protecting); and the dangerous middle, toolkits with *some*
418
+ providers bound, which names the unbound ones — the shape where an operator
419
+ believes the deployment is separated while one forgotten credential opens every
420
+ view
421
+ and every deployment-wide surface. Declaring `toolkits: [...], unscoped: true`
422
+ is how an operator credential says so and stops appearing. The open-mode "no
423
+ inbound authentication" warning remains independent of the `toolkits` value.
424
+ - **An unknown `?toolkit=` was undiagnosable from both ends** (issue #47). The
425
+ 404 is correct and its body is well worded, but mainstream MCP clients treat a
426
+ 404 on the transport endpoint as a transport failure and discard the body, and
427
+ nothing was logged — so a one-character typo in a hand-copied MCP URL gave the
428
+ user "failed to connect" and the operator nothing at all. A rejected selection
429
+ now emits an operator `logger.warn` naming the rejected value and the
430
+ configured toolkits, while the **response is byte-for-byte unchanged**: still
431
+ no enumeration, still the same bounded echo. The new channel is bounded and
432
+ unforgeable — the value is truncated to the same 64 characters the body echoes
433
+ at and escaped with `JSON.stringify` plus a hand-rolled escape for U+2028 and
434
+ U+2029, which `JSON.stringify` leaves raw — and the line is written **after**
435
+ the auth gate, so on a deployment with `auth` configured a caller the gate
436
+ rejects cannot make it log anything.
437
+ - **The dead `maxResultBytes` parameter on the meta-tool constructors is gone**
438
+ (issue #44). Removed rather than exposed: `ConnectaConfig.maxResultBytes` and
439
+ the per-connector override are already the documented answer to where a
440
+ deployment sets the cap, a third global knob would need a precedence rule
441
+ nobody asked for, and `ServerOptions` never carried the field, so nothing
442
+ production could reach it — the constructors are internal factoring and are
443
+ not part of the exported API. Cap tests now configure the deployment cap the
444
+ way `createConnecta` does, so they also pick up its normalization, plus a new
445
+ end-to-end test that a `createConnecta({ maxResultBytes })` value truncates
446
+ and pages over the wire. The effective cap for a deployment that sets nothing
447
+ is unchanged.
448
+ - **The documented `ApiOptions` and `RemoteMcpOptions` now match the source
449
+ field for field** (issue #41). Both were presented as verbatim `export
450
+ interface` listings while silently dropping real fields, which is worse than
451
+ no listing: `ApiOptions` lacked `credential`, `testCredential`,
452
+ `testCredentials` and `strictValidation` — putting the docs in direct
453
+ contradiction with the README quick start, which uses `credential` — and
454
+ `RemoteMcpOptions` lacked `requireHttps` and `logger`. Two of the omissions
455
+ are security controls, so `strictValidation` and `requireHttps` now carry
456
+ behavior, default and threat model verified against the implementation. Same
457
+ sweep: the credential field sub-shape had dropped `description`/`placeholder`,
458
+ the `validateToolInput` example omitted `failClosed`, and the `clerkAuth`
459
+ bullet claimed `authorizedParties: [connectaOrigin]` is passed to
460
+ `authenticateRequest` when the source deliberately does not — an OAuth access
461
+ token may carry no `azp` — and pins `azp` by hand for session tokens instead.
462
+ The remaining listings were checked against source and were accurate as
463
+ written. **Residual, filed not fixed:** the review that produced this pass
464
+ also found that `/ui` offers its credential Test button from the mere presence
465
+ of a test hook, without checking it matches the credential *shape* configured,
466
+ so a fully configured named-fields credential can answer "configure the
467
+ credential before testing it" (issue #55).
468
+
469
+ ### Security
470
+
471
+ - **Favicon bodies are served inertly, completing the URL-position invariant**
472
+ (issue #31). `/favicon.svg` returned an operator-supplied SVG verbatim as
473
+ `image/svg+xml` with no `X-Content-Type-Options` and no CSP, so a `<script>`
474
+ inside a branding SVG executed **on the deployment origin** the moment anyone
475
+ navigated straight to the URL — strictly more powerful than the
476
+ `favicon.href` vector 0.5.0 closed, because the payload is same-origin. Both
477
+ favicon routes now answer with `X-Content-Type-Options: nosniff` and
478
+ `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline';
479
+ sandbox`. Neutralizing the *response* rather than inspecting the body is what
480
+ keeps every valid static SVG byte-identical, the built-in mark included, and
481
+ inline styles stay allowed because the default mark uses one to follow the OS
482
+ colour scheme while CSS cannot script. `.ico` bodies are inert bytes and are
483
+ still served verbatim, but deliberately in scope of the same headers, so the
484
+ rule reads "every favicon route is neutralized" rather than "whichever route
485
+ happened to get attention".
486
+ - **`uiAuth.frontendApiUrl` is scheme-gated.** It was the last operator-config
487
+ value reaching a URL-valued HTML position — the `<script src>` of `/ui`'s
488
+ sign-in loader — without one. It now requires an absolute `https:` URL,
489
+ deliberately stricter than the branding gates with no `http:` and no loopback
490
+ carve-out, because nobody types this value: `clerkAuth` derives it from the
491
+ publishable key and Clerk's Frontend API is always https. A rejected value
492
+ reaches neither position on the page — the loader tag is not emitted, and the
493
+ serialized `AUTH` object is enumerated field by field so the bad URL cannot
494
+ slip in through the inline script. `/ui` still renders, reports that Clerk
495
+ could not load, and `createConnecta` logs a warning naming the provider, the
496
+ same fallback-and-warn posture the branding drops established. **Residual:**
497
+ `uiAuth.signInUrl` and `signUpUrl` are operator config that reaches the
498
+ browser as a *navigation target* rather than as a rendered attribute, so the
499
+ invariant as written does not cover them and neither does any gate; the
500
+ exposure is narrow (`/ui`'s nonce CSP blocks a `javascript:` navigation on a
501
+ CSP3 browser, the `'unsafe-inline'` legacy fallback would not) but the
502
+ exception is the cost. Issue #56.
503
+ - **`/oauth/callback/<id>` still distinguishes a real connector id from an
504
+ invented one** to an unauthenticated caller — 404 for unknown, 400 for a real
505
+ connector with a bad `state`. Nothing is authorized and `verifyState` still
506
+ holds, but the connector inventory leaks, which softens the same "not a
507
+ directory" property the toolkit binding above is built to provide.
508
+ Pre-existing, found while reviewing that binding, filed as issue #62.
509
+
5
510
  ## 0.5.0 — 2026-07-26
6
511
 
7
512
  A feature release. Toolkits, per-connector usage guides, and per-connector