@zackbart/connecta 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +527 -0
- package/README.md +83 -7
- package/SECURITY.md +10 -6
- package/dist/activity.d.ts +8 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js +1 -0
- package/dist/activity.js.map +1 -1
- package/dist/auth/bearer.d.ts +10 -3
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +21 -0
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +26 -1
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +161 -4
- package/dist/auth/clerk.js.map +1 -1
- package/dist/connectors/api.d.ts +13 -0
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +2 -0
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +13 -0
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +10 -0
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +212 -0
- package/dist/credential-health.d.ts.map +1 -0
- package/dist/credential-health.js +535 -0
- package/dist/credential-health.js.map +1 -0
- package/dist/execute.d.ts +4 -4
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +16 -4
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +77 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -2
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +76 -7
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +328 -98
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +245 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +377 -27
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +7 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +342 -27
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +53 -2
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +162 -2
- package/dist/skills.js.map +1 -1
- package/dist/timeout.d.ts +16 -0
- package/dist/timeout.d.ts.map +1 -0
- package/dist/timeout.js +38 -0
- package/dist/timeout.js.map +1 -0
- package/dist/toolkits.d.ts +138 -0
- package/dist/toolkits.d.ts.map +1 -0
- package/dist/toolkits.js +319 -0
- package/dist/toolkits.js.map +1 -0
- package/dist/types.d.ts +90 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +63 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +176 -11
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -2
- package/src/activity.ts +9 -0
- package/src/auth/bearer.ts +35 -1
- package/src/auth/clerk.ts +202 -5
- package/src/connectors/api.ts +15 -0
- package/src/connectors/remote-mcp.ts +24 -0
- package/src/credential-health.ts +736 -0
- package/src/execute.ts +32 -8
- package/src/index.ts +226 -2
- package/src/meta-tools.ts +397 -119
- package/src/registry.ts +540 -29
- package/src/server.ts +431 -25
- package/src/skills.ts +185 -2
- package/src/timeout.ts +49 -0
- package/src/toolkits.ts +450 -0
- package/src/types.ts +96 -2
- package/src/ui.ts +190 -11
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,533 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.6.0 — 2026-07-26
|
|
6
|
+
|
|
7
|
+
A feature release that makes two of 0.5.0's mechanisms protective rather than
|
|
8
|
+
merely organizational. Toolkits gain an identity binding, so a credential opens
|
|
9
|
+
the view its team was given and nothing else; connector status gains a proactive
|
|
10
|
+
credential liveness layer, so an expired downstream token shows up as
|
|
11
|
+
`auth_required` before an agent's call trips over it; and `clerkAuth` gains an
|
|
12
|
+
email-domain allowlist. Eight fixes on the result path and the diagnostic
|
|
13
|
+
surfaces sit underneath them. Nothing here is breaking — every new config key is
|
|
14
|
+
optional and every new type is additive — but this release is *not* runtime-inert
|
|
15
|
+
for a deployment that declares none of them, and the deltas are worth reading
|
|
16
|
+
before upgrading:
|
|
17
|
+
|
|
18
|
+
- **MCP-mode truncation now measures what it truncates.** A `kind: "mcp"`
|
|
19
|
+
connector's result whose size falls between the old text-only measure and the
|
|
20
|
+
serialized envelope came back inline in 0.5.0 and now truncates, pages, and
|
|
21
|
+
reports `totalBytes` in envelope bytes (issue #43, below). It is deliberate,
|
|
22
|
+
and it is the one change on the result path that a deployment can notice
|
|
23
|
+
without changing any configuration.
|
|
24
|
+
- **A handler returning `undefined` renders as the text `undefined`** under
|
|
25
|
+
`resultMode: "mcp"`, where 0.5.0 emitted a `TextContent` block with no `text`
|
|
26
|
+
at all (issue #42). Every serializable return is byte-identical.
|
|
27
|
+
- **The cheap status path reports more failures.** `list_connectors({ probe:
|
|
28
|
+
false })` now reads `error` for a connector whose *catalog* load keeps failing
|
|
29
|
+
(issue #46) and `auth_required` when a background liveness check found the
|
|
30
|
+
stored credential dead (issue #24). Both are the fix; both mean a connector
|
|
31
|
+
that read clean in 0.5.0 while being broken now reads broken.
|
|
32
|
+
- **Credential liveness checks are on by default.** A deployment holding an
|
|
33
|
+
operator-managed `credential` or a downstream OAuth grant begins making
|
|
34
|
+
liveness calls — its own `testCredential(s)` hook, or the OAuth
|
|
35
|
+
`status(ctx)` refresh — piggybacked on authenticated `/mcp` and `/ui/data`
|
|
36
|
+
traffic, at most one per connector per 15 minutes. No downstream *tool* is
|
|
37
|
+
ever called and no catalog is fetched. Setting `credentialHealth.onRequest`
|
|
38
|
+
to `false` turns that trigger off; the explicit `checkCredentials()` call
|
|
39
|
+
stays available.
|
|
40
|
+
- **Three textual deltas that no configuration turns off**, so anything
|
|
41
|
+
snapshotting them will diff: `get_result`'s tool description now documents the
|
|
42
|
+
`offset` domain and the codepoint realignment, reaching every deployment (its
|
|
43
|
+
JSON Schema is unchanged, `minimum: 0` either way); both favicon routes now
|
|
44
|
+
carry `X-Content-Type-Options` and a `Content-Security-Policy` header, with
|
|
45
|
+
the bodies byte-identical; and any deployment declaring toolkits gets a
|
|
46
|
+
rewritten startup warning, now split into three.
|
|
47
|
+
|
|
48
|
+
The next intentional breaking release stays reserved for issue #28.
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **Toolkit ↔ identity binding — a credential opens the view its team was
|
|
53
|
+
given** (issue #37). Toolkits shipped in 0.5.0 with self-service selection:
|
|
54
|
+
any authenticated caller could name any toolkit, or omit `?toolkit=` and get
|
|
55
|
+
the whole registry. The binding closes that. It is declared **on the auth
|
|
56
|
+
adapter**, not in a deployment-level table keyed by identity id —
|
|
57
|
+
`bearerToken(secret, { subjectId: "support-team", toolkits: ["support"] })`,
|
|
58
|
+
and the same two options on `clerkAuth` — because a typo in such a key means
|
|
59
|
+
*unbound*, which fails open and hands over everything, while a typo in a
|
|
60
|
+
toolkit *name* throws at construction. `InboundAuth.toolkitBinding` is the
|
|
61
|
+
provider-wide declaration and `AuthResult.toolkitBinding` overrides it per
|
|
62
|
+
identity, the documented seam for an adapter mapping its own users to views;
|
|
63
|
+
the declaration is a **ceiling**, not a default — connecta intersects the two
|
|
64
|
+
and grants `unscoped` only if both do, so an adapter reading a user-writable
|
|
65
|
+
IdP claim cannot let a user widen their own binding. Enforcement is one point
|
|
66
|
+
(`resolveToolkitScope`, after the auth gate and before any `ScopedRegistry`
|
|
67
|
+
exists) deciding *whether* a scope is built; what a built scope contains stays
|
|
68
|
+
entirely in `ScopedRegistry`. For a bound identity, a toolkit outside the
|
|
69
|
+
binding, an undeclared or malformed name, and — without `unscoped: true` — a
|
|
70
|
+
connection with no `?toolkit=` are all **403, byte-identical in status and
|
|
71
|
+
body**, and the body names no toolkit: a team credential must not become a
|
|
72
|
+
directory of the org's other teams. The operator log is where the three
|
|
73
|
+
reasons are told apart, naming the identity, the reason and the binding, with
|
|
74
|
+
the same 64-character bounding and U+2028/U+2029 escaping the toolkit
|
|
75
|
+
rejection uses. A bound-but-not-`unscoped` identity is also refused the
|
|
76
|
+
deployment-wide operator surfaces — `/ui/data`, `/ui/activity`, and the
|
|
77
|
+
credential API — because those payloads describe every connector in the org
|
|
78
|
+
and a credential write reaches every view; `/health` and the open routes are
|
|
79
|
+
unchanged. **An unbound identity behaves exactly as 0.5.0 shipped**, and
|
|
80
|
+
bindings are per identity, so a legacy token beside two bound ones keeps
|
|
81
|
+
working. Bindings are validated in three places that each catch what the
|
|
82
|
+
others cannot: the adapter throws on a declaration that does not mean what it
|
|
83
|
+
says (`unscoped` with no `toolkits`, an empty `toolkits` without it, a name
|
|
84
|
+
outside the grammar, a non-array); `createConnecta` cross-checks every name
|
|
85
|
+
against the declared toolkits and throws on an unknown one; and **every
|
|
86
|
+
request re-validates the binding it is about to enforce**, because
|
|
87
|
+
`InboundAuth` is an open interface and neither half can be trusted from the
|
|
88
|
+
type — a binding that fails validation is a 403 with a log line, never a
|
|
89
|
+
silent drop, since dropping it would read as "unbound". `ToolkitBinding`,
|
|
90
|
+
`ToolkitBindingOptions` and `BearerTokenOptions` are exported. The startup
|
|
91
|
+
warning story changes with it (see Fixed).
|
|
92
|
+
- **Proactive credential liveness checks, so a dead credential surfaces before
|
|
93
|
+
a call fails** (issue #24). A connector's auth status previously flipped only
|
|
94
|
+
when something *observed* a failure — an agent's real call erroring
|
|
95
|
+
`auth_required`, or an operator running `list_connectors({ probe: true })` —
|
|
96
|
+
so a revoked token surfaced mid-task. Connecta now checks the credentials it
|
|
97
|
+
stores and serves the verdict from the cheap surfaces. Only two credential
|
|
98
|
+
shapes are eligible, each asked through the hook that exists to answer exactly
|
|
99
|
+
this question: an operator-managed `credential` via `testCredentials(values)`
|
|
100
|
+
/ `testCredential(value)` (the same call /ui's Test button makes), and a
|
|
101
|
+
downstream OAuth grant via `status(ctx)`, whose refresh *is* the liveness
|
|
102
|
+
question for a token. **A check never calls a downstream tool and never
|
|
103
|
+
fetches a catalog**, so it cannot mutate downstream state and no destructive
|
|
104
|
+
tool is reachable from it, and **a connector with nothing stored is never
|
|
105
|
+
probed** — a new optional `Connector.hasStoredCredential` answers for
|
|
106
|
+
connectors holding their own credential (`remoteMcp` implements it for
|
|
107
|
+
oauth) and the vault answers for the rest, so a timer never starts DCR and
|
|
108
|
+
consent for a connector nobody has authorized. The core starts **no timers**,
|
|
109
|
+
because it has to run unchanged on Workers: instead there are two triggers
|
|
110
|
+
sharing one budget — an authenticated `/mcp` or `/ui/data` request hands a
|
|
111
|
+
*due* sweep to `ctx.waitUntil` (never awaited by the request, never triggered
|
|
112
|
+
by an unauthenticated or refused one), and the new
|
|
113
|
+
`Connecta.checkCredentials({ baseUrl?, force?, ids? })` is an ordinary awaited
|
|
114
|
+
call for the host's own scheduler — wired to a cron trigger in the Worker
|
|
115
|
+
example and a `setInterval(...).unref()` in the Node and Docker ones —
|
|
116
|
+
returning one `CredentialCheckResult` per connector considered, and never
|
|
117
|
+
rejecting on a connector failure.
|
|
118
|
+
Cost is bounded four ways: eligibility, a persisted freshness window
|
|
119
|
+
(`intervalSeconds`, default 900, shared across isolates so repeated status
|
|
120
|
+
reads never each produce a check), one traffic-triggered sweep per interval
|
|
121
|
+
per isolate, and a per-check deadline (`timeoutMs`, default 30 000) with at
|
|
122
|
+
most `concurrency` (default 4) in flight. Verdicts are persisted under
|
|
123
|
+
`credhealth:<connectorId>` rather than held in memory, because on Workers the
|
|
124
|
+
cron isolate is not the isolate answering status reads. `list_connectors`
|
|
125
|
+
gains `credentialCheck: { state, checkedAt, message?, authorizationUrl? }`,
|
|
126
|
+
present only for connectors holding a stored credential, and /ui's connector
|
|
127
|
+
card renders it. What a verdict may *decide* is deliberately narrow and lives
|
|
128
|
+
in one function: only `auth_required` ever sets the `probe: false` status, and
|
|
129
|
+
only while nothing better has happened since — a real call that succeeded
|
|
130
|
+
after `checkedAt` retires it, read deployment-wide so a sibling toolkit's
|
|
131
|
+
success counts. An `error` verdict is **reported and decides nothing**: a
|
|
132
|
+
check that timed out or got a 502 failed to *complete* and learned nothing
|
|
133
|
+
about the credential, so letting it set the status would flip a working
|
|
134
|
+
connector to `error` for a whole interval on a DNS blip. An `ok` verdict
|
|
135
|
+
upgrades `unknown` to `ok` and never downgrades an observed failure, while
|
|
136
|
+
`auth_required` outranks even a newer real-call failure — both say something
|
|
137
|
+
is wrong, only one carries the URL that fixes it, and the failure stays
|
|
138
|
+
visible as `lastError`. Recovery needs no restart and is fenced against a
|
|
139
|
+
check in flight: a completed `/oauth/callback/<id>` and a credential `PUT` or
|
|
140
|
+
`DELETE` drop the verdict and advance a per-connector generation counter
|
|
141
|
+
(`credhealth:gen:<id>`) that a check captures at its start and re-reads before
|
|
142
|
+
writing, so a check that began before consent finished cannot resurrect the
|
|
143
|
+
stale `auth_required` and its stale consent URL. `credentialHealth` on
|
|
144
|
+
`ConnectaConfig` tunes all of it; `CredentialHealthConfig`,
|
|
145
|
+
`CredentialCheckResult`, `CredentialCheckSkip`, `CredentialCheckState` and
|
|
146
|
+
`CredentialHealthRecord` are exported. Automatic re-authorization is out of
|
|
147
|
+
scope — refresh rotation is already the OAuth flow's job and interactive
|
|
148
|
+
re-consent stays manual through `authorize_connector`. **Known gap, filed not
|
|
149
|
+
fixed:** a probe leaves a downstream MCP session open, because
|
|
150
|
+
`remoteMcp().status()` connects a client that nothing closes. That has been
|
|
151
|
+
true of `list_connectors({ probe: true })` and `/ui/data` since long before
|
|
152
|
+
this release; the sweep makes it periodic rather than operator-triggered.
|
|
153
|
+
Closing it needs a teardown seam on the `Connector` contract, so it is issue
|
|
154
|
+
#66 and is named in the docs beside the feature.
|
|
155
|
+
- **`clerkAuth({ allowedDomains })` — "anyone @acme.com, nobody else" as one
|
|
156
|
+
option** (issue #58). The policy was expressible with `gate` already, but only
|
|
157
|
+
by hand-writing fetch-user / read-primary-email / compare-domain /
|
|
158
|
+
handle-the-lookup-failing in every deployment, a security-relevant idiom with
|
|
159
|
+
four ways to get subtly wrong. Matching is exact, case-insensitive and on the
|
|
160
|
+
whole domain: `acme.com` admits `dev@ACME.com` and rejects `evil-acme.com`,
|
|
161
|
+
`acme.com.evil.com`, `acme.co` and `mail.acme.com` — a subdomain must be
|
|
162
|
+
spelled out — and the address is split on its **last** `@`, so
|
|
163
|
+
`"dev@acme.com"@evil.com` resolves to `evil.com`. It **fails closed** on every
|
|
164
|
+
uncertainty (no primary email, an unverified one, a malformed address, a Clerk
|
|
165
|
+
lookup that throws), returning the same bare `403 {"error":"forbidden"}` a
|
|
166
|
+
`gate` rejection returns with no hint why, while the operator gets the reason
|
|
167
|
+
in the log with the domain bounded and escaped and never the local part of
|
|
168
|
+
anyone's address. The list is validated at construction — a non-domain, an
|
|
169
|
+
`@`, an empty list, a non-array, or a Unicode lookalike (internationalized
|
|
170
|
+
domains must be written in punycode) throws where the operator wrote it rather
|
|
171
|
+
than silently binding nothing. It composes with `gate`: each configured one
|
|
172
|
+
must pass, the allowlist is evaluated first so a caller outside the org never
|
|
173
|
+
reaches operator gate code, and one verdict per user covers both and rides the
|
|
174
|
+
existing identity cache, so it costs no more Clerk calls than `gate` alone. It
|
|
175
|
+
governs Clerk sign-in only — a co-configured `bearerToken` has no email to
|
|
176
|
+
read. Unset, no user lookup happens at all and any authenticated user is
|
|
177
|
+
admitted, exactly as before. Where this sits relative to the toolkit binding
|
|
178
|
+
is now documented explicitly: `allowedDomains`/`gate` decide **who gets into
|
|
179
|
+
the org**, the binding decides **what they see** once in. **Residual:** the
|
|
180
|
+
identity cache is a `Map` that never evicts, and an allowlist beside open
|
|
181
|
+
Clerk sign-up is the first configuration in which a stranger can populate it —
|
|
182
|
+
each denied user id costs a permanent entry for the life of the isolate. The
|
|
183
|
+
denial itself is correct and TTL'd; the footprint is issue #70.
|
|
184
|
+
|
|
185
|
+
### Fixed
|
|
186
|
+
|
|
187
|
+
- **MCP-mode truncation measured text blocks but truncated the envelope, and
|
|
188
|
+
never fired on non-text content at all** (issue #43). The quantity that
|
|
189
|
+
*decided* truncation was the text blocks' byte length; what was truncated,
|
|
190
|
+
stashed and reported as `totalBytes` was `JSON.stringify(content, null, 2)`,
|
|
191
|
+
the strictly larger envelope. So the head and `totalBytes` described a string
|
|
192
|
+
the cap was never compared against (pinned by a test where 240 bytes of text
|
|
193
|
+
sit in a 700-plus-byte envelope under a 300-byte cap — it came back inline
|
|
194
|
+
before and truncates now), and an all-non-text result scored zero text bytes,
|
|
195
|
+
so a single 50 KB base64 `image` block was returned inline, unbounded, with no
|
|
196
|
+
`resultId` to page from. One `guardContent` now measures the same string it
|
|
197
|
+
stashes and counts every block. What truncation *means* differs by content: an
|
|
198
|
+
all-text envelope keeps the historical head plus notice, since a JSON prefix
|
|
199
|
+
is still readable, while an envelope carrying any non-text block is replaced
|
|
200
|
+
by the `{ truncated, resultId, totalBytes, hint }` notice **alone**, because
|
|
201
|
+
the head of a half-written base64 image is useless and leaves unparseable
|
|
202
|
+
block structure. Either way the full envelope is stashed and pages back whole
|
|
203
|
+
through `get_result`, and the ~170-byte notice sits outside the cap as it
|
|
204
|
+
always has on the truncated-head path. Under the cap, blocks pass through
|
|
205
|
+
untouched and in order — including a result whose measurement itself is
|
|
206
|
+
impossible: a `BigInt` or a cycle makes the envelope stringify throw, which is
|
|
207
|
+
caught and falls back to returning the content untouched rather than failing a
|
|
208
|
+
call that used to pass. **The one thing an existing `kind: "mcp"` deployment
|
|
209
|
+
will notice** is stated above: results between the two measures now truncate.
|
|
210
|
+
- **A handler returning `undefined` emitted a `TextContent` block with no
|
|
211
|
+
`text`** (issue #42). `JSON.stringify(undefined, null, 2)` *is* `undefined`,
|
|
212
|
+
not a string; `guardText` then measured `enc.encode(undefined)` — the empty
|
|
213
|
+
string, per the WebIDL default — took the under-cap early return, and emitted
|
|
214
|
+
the non-string unchanged, so clients received `{"content":[{"type":"text"}]}`,
|
|
215
|
+
which `TextContent` does not permit. The three guards that answer this same
|
|
216
|
+
question each defended differently, so they now share one
|
|
217
|
+
`serializeResultText`: JSON text for whatever JSON can represent, `String(
|
|
218
|
+
value)` for what JSON renders as `undefined`, and `guardText` normalizes at
|
|
219
|
+
the door so its size logic is unreachable with a non-string. A value JSON
|
|
220
|
+
cannot serialize at all still throws and is reported as a failure. The
|
|
221
|
+
semantics are now written down where `resultMode` is documented, including the
|
|
222
|
+
sharp edge that only a bare `undefined` renders as `undefined` — a function
|
|
223
|
+
renders as its **source text** and a Symbol as `Symbol(label)`, so a handler
|
|
224
|
+
mistakenly returning a closure puts the function's source in front of the
|
|
225
|
+
model.
|
|
226
|
+
- **`get_result`'s `offset` had none of the defense issue #32 gave `maxBytes`**
|
|
227
|
+
(issue #38). An in-handler `offset: NaN` propagated through `Math.max(0,
|
|
228
|
+
Math.trunc(...))`, sliced to nothing, and serialized as `"offset": null` with
|
|
229
|
+
empty text and no `nextOffset` — the result vanished silently instead of
|
|
230
|
+
erroring. Validity is now one shared rule spelled once for the wire schema and
|
|
231
|
+
once in the handler, exactly as #32 did for the cap, and an out-of-domain
|
|
232
|
+
value is an ordinary input-validation error; an offset past the end of the
|
|
233
|
+
payload stays legal and answers with an empty final page. Separately, a legal
|
|
234
|
+
in-range offset landing mid-codepoint emitted U+FFFD; it is now aligned
|
|
235
|
+
**back** to that character's first byte — re-reading a few bytes is
|
|
236
|
+
recoverable, silently skipping the rest of a character is not — and the offset
|
|
237
|
+
actually served is what the response reports. Server-produced `nextOffset`
|
|
238
|
+
values are already boundaries and are served exactly as given, so paging is
|
|
239
|
+
byte-identical.
|
|
240
|
+
- **A failing catalog lookup never recorded a connector health failure** (issue
|
|
241
|
+
#46). `call_tool`'s catalog-lookup catch returned a failure to the caller but,
|
|
242
|
+
unlike the execution catch a hundred lines below, never called
|
|
243
|
+
`recordFailure`, and neither `getTools` nor `refreshTools` compensated. A
|
|
244
|
+
connector whose `listTools` fails persistently — a revoked downstream grant,
|
|
245
|
+
the scenario the comment at that catch already named — therefore accumulated
|
|
246
|
+
no `consecutiveFailures` and read **clean** from `list_connectors({ probe:
|
|
247
|
+
false })` while every single call against it failed, which is precisely the
|
|
248
|
+
cheap signal an operator or agent consults to find a broken connector. Both
|
|
249
|
+
catalog-load sites now record: the shared `runCall` catch behind `call_tool`,
|
|
250
|
+
`call_destructive_tool` and `batch_call`, and the `execute_code` path's
|
|
251
|
+
provider build, which previously dropped a connector's whole sandbox namespace
|
|
252
|
+
with nothing but a `logger.warn`. The accounting deliberately stays at the
|
|
253
|
+
call sites rather than inside `Registry`: `registry` there is the connection's
|
|
254
|
+
*view*, so a toolkit-scoped session records into its own health log as well as
|
|
255
|
+
the deployment-wide one, and registry-level accounting would leave scoped
|
|
256
|
+
sessions blind while double-counting against the probe path that already
|
|
257
|
+
records. A warm-cache hit still records nothing in either direction — a hit is
|
|
258
|
+
not evidence of health.
|
|
259
|
+
- **The toolkits startup warning fired for a configuration that cannot have the
|
|
260
|
+
risk it named** (issue #45), and now tells three different stories. The check
|
|
261
|
+
tested `config.toolkits` for truthiness; `{}` is truthy but resolves to no
|
|
262
|
+
selectable toolkit at all, so a deployment configured that way was told that
|
|
263
|
+
"any caller can choose any toolkit" — the kind of warning that teaches
|
|
264
|
+
operators to skim past the ones that matter. All warnings are now keyed off
|
|
265
|
+
the **resolved** toolkit map, the same one `?toolkit=` resolves against, so
|
|
266
|
+
they cannot drift from what is actually selectable. With the binding feature
|
|
267
|
+
the single warning splits into three, because the fix differs in each: toolkits
|
|
268
|
+
with no `auth` at all (no identity exists to bind — configure `auth` first);
|
|
269
|
+
toolkits with `auth` but no declared binding anywhere (the shape that
|
|
270
|
+
organizes without protecting); and the dangerous middle, toolkits with *some*
|
|
271
|
+
providers bound, which names the unbound ones — the shape where an operator
|
|
272
|
+
believes the deployment is separated while one forgotten credential opens every
|
|
273
|
+
view
|
|
274
|
+
and every deployment-wide surface. Declaring `toolkits: [...], unscoped: true`
|
|
275
|
+
is how an operator credential says so and stops appearing. The open-mode "no
|
|
276
|
+
inbound authentication" warning remains independent of the `toolkits` value.
|
|
277
|
+
- **An unknown `?toolkit=` was undiagnosable from both ends** (issue #47). The
|
|
278
|
+
404 is correct and its body is well worded, but mainstream MCP clients treat a
|
|
279
|
+
404 on the transport endpoint as a transport failure and discard the body, and
|
|
280
|
+
nothing was logged — so a one-character typo in a hand-copied MCP URL gave the
|
|
281
|
+
user "failed to connect" and the operator nothing at all. A rejected selection
|
|
282
|
+
now emits an operator `logger.warn` naming the rejected value and the
|
|
283
|
+
configured toolkits, while the **response is byte-for-byte unchanged**: still
|
|
284
|
+
no enumeration, still the same bounded echo. The new channel is bounded and
|
|
285
|
+
unforgeable — the value is truncated to the same 64 characters the body echoes
|
|
286
|
+
at and escaped with `JSON.stringify` plus a hand-rolled escape for U+2028 and
|
|
287
|
+
U+2029, which `JSON.stringify` leaves raw — and the line is written **after**
|
|
288
|
+
the auth gate, so on a deployment with `auth` configured a caller the gate
|
|
289
|
+
rejects cannot make it log anything.
|
|
290
|
+
- **The dead `maxResultBytes` parameter on the meta-tool constructors is gone**
|
|
291
|
+
(issue #44). Removed rather than exposed: `ConnectaConfig.maxResultBytes` and
|
|
292
|
+
the per-connector override are already the documented answer to where a
|
|
293
|
+
deployment sets the cap, a third global knob would need a precedence rule
|
|
294
|
+
nobody asked for, and `ServerOptions` never carried the field, so nothing
|
|
295
|
+
production could reach it — the constructors are internal factoring and are
|
|
296
|
+
not part of the exported API. Cap tests now configure the deployment cap the
|
|
297
|
+
way `createConnecta` does, so they also pick up its normalization, plus a new
|
|
298
|
+
end-to-end test that a `createConnecta({ maxResultBytes })` value truncates
|
|
299
|
+
and pages over the wire. The effective cap for a deployment that sets nothing
|
|
300
|
+
is unchanged.
|
|
301
|
+
- **The documented `ApiOptions` and `RemoteMcpOptions` now match the source
|
|
302
|
+
field for field** (issue #41). Both were presented as verbatim `export
|
|
303
|
+
interface` listings while silently dropping real fields, which is worse than
|
|
304
|
+
no listing: `ApiOptions` lacked `credential`, `testCredential`,
|
|
305
|
+
`testCredentials` and `strictValidation` — putting the docs in direct
|
|
306
|
+
contradiction with the README quick start, which uses `credential` — and
|
|
307
|
+
`RemoteMcpOptions` lacked `requireHttps` and `logger`. Two of the omissions
|
|
308
|
+
are security controls, so `strictValidation` and `requireHttps` now carry
|
|
309
|
+
behavior, default and threat model verified against the implementation. Same
|
|
310
|
+
sweep: the credential field sub-shape had dropped `description`/`placeholder`,
|
|
311
|
+
the `validateToolInput` example omitted `failClosed`, and the `clerkAuth`
|
|
312
|
+
bullet claimed `authorizedParties: [connectaOrigin]` is passed to
|
|
313
|
+
`authenticateRequest` when the source deliberately does not — an OAuth access
|
|
314
|
+
token may carry no `azp` — and pins `azp` by hand for session tokens instead.
|
|
315
|
+
The remaining listings were checked against source and were accurate as
|
|
316
|
+
written. **Residual, filed not fixed:** the review that produced this pass
|
|
317
|
+
also found that `/ui` offers its credential Test button from the mere presence
|
|
318
|
+
of a test hook, without checking it matches the credential *shape* configured,
|
|
319
|
+
so a fully configured named-fields credential can answer "configure the
|
|
320
|
+
credential before testing it" (issue #55).
|
|
321
|
+
|
|
322
|
+
### Security
|
|
323
|
+
|
|
324
|
+
- **Favicon bodies are served inertly, completing the URL-position invariant**
|
|
325
|
+
(issue #31). `/favicon.svg` returned an operator-supplied SVG verbatim as
|
|
326
|
+
`image/svg+xml` with no `X-Content-Type-Options` and no CSP, so a `<script>`
|
|
327
|
+
inside a branding SVG executed **on the deployment origin** the moment anyone
|
|
328
|
+
navigated straight to the URL — strictly more powerful than the
|
|
329
|
+
`favicon.href` vector 0.5.0 closed, because the payload is same-origin. Both
|
|
330
|
+
favicon routes now answer with `X-Content-Type-Options: nosniff` and
|
|
331
|
+
`Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline';
|
|
332
|
+
sandbox`. Neutralizing the *response* rather than inspecting the body is what
|
|
333
|
+
keeps every valid static SVG byte-identical, the built-in mark included, and
|
|
334
|
+
inline styles stay allowed because the default mark uses one to follow the OS
|
|
335
|
+
colour scheme while CSS cannot script. `.ico` bodies are inert bytes and are
|
|
336
|
+
still served verbatim, but deliberately in scope of the same headers, so the
|
|
337
|
+
rule reads "every favicon route is neutralized" rather than "whichever route
|
|
338
|
+
happened to get attention".
|
|
339
|
+
- **`uiAuth.frontendApiUrl` is scheme-gated.** It was the last operator-config
|
|
340
|
+
value reaching a URL-valued HTML position — the `<script src>` of `/ui`'s
|
|
341
|
+
sign-in loader — without one. It now requires an absolute `https:` URL,
|
|
342
|
+
deliberately stricter than the branding gates with no `http:` and no loopback
|
|
343
|
+
carve-out, because nobody types this value: `clerkAuth` derives it from the
|
|
344
|
+
publishable key and Clerk's Frontend API is always https. A rejected value
|
|
345
|
+
reaches neither position on the page — the loader tag is not emitted, and the
|
|
346
|
+
serialized `AUTH` object is enumerated field by field so the bad URL cannot
|
|
347
|
+
slip in through the inline script. `/ui` still renders, reports that Clerk
|
|
348
|
+
could not load, and `createConnecta` logs a warning naming the provider, the
|
|
349
|
+
same fallback-and-warn posture the branding drops established. **Residual:**
|
|
350
|
+
`uiAuth.signInUrl` and `signUpUrl` are operator config that reaches the
|
|
351
|
+
browser as a *navigation target* rather than as a rendered attribute, so the
|
|
352
|
+
invariant as written does not cover them and neither does any gate; the
|
|
353
|
+
exposure is narrow (`/ui`'s nonce CSP blocks a `javascript:` navigation on a
|
|
354
|
+
CSP3 browser, the `'unsafe-inline'` legacy fallback would not) but the
|
|
355
|
+
exception is the cost. Issue #56.
|
|
356
|
+
- **`/oauth/callback/<id>` still distinguishes a real connector id from an
|
|
357
|
+
invented one** to an unauthenticated caller — 404 for unknown, 400 for a real
|
|
358
|
+
connector with a bad `state`. Nothing is authorized and `verifyState` still
|
|
359
|
+
holds, but the connector inventory leaks, which softens the same "not a
|
|
360
|
+
directory" property the toolkit binding above is built to provide.
|
|
361
|
+
Pre-existing, found while reviewing that binding, filed as issue #62.
|
|
362
|
+
|
|
363
|
+
## 0.5.0 — 2026-07-26
|
|
364
|
+
|
|
365
|
+
A feature release. Toolkits, per-connector usage guides, and per-connector
|
|
366
|
+
result caps are the substance; a paging fix, a docs coherence pass, and a
|
|
367
|
+
cleared audit advisory round it out. Nothing here is breaking, and a deployment
|
|
368
|
+
that declares none of the new options keeps its 0.4.1 *runtime* behavior on
|
|
369
|
+
every path that matters: an unscoped connection still sees the whole registry,
|
|
370
|
+
a zero-guide catalog gains no discovery text, and an unset cap truncates where
|
|
371
|
+
it always did. One narrow exception — a request carrying `?toolkit=` against a
|
|
372
|
+
deployment that configures no toolkits is now a 404, where 0.4.1 never read the
|
|
373
|
+
parameter and served the request; no 0.4.1 client had reason to send it. Three
|
|
374
|
+
textual changes are unconditional and reach every deployment, so anything that
|
|
375
|
+
snapshots them will diff: the `get_result` tool description now documents the
|
|
376
|
+
accepted `maxBytes` range, its `maxBytes` JSON Schema tightens from
|
|
377
|
+
`exclusiveMinimum: 0` to `minimum: 1`, and the matching validation error
|
|
378
|
+
message is reworded. The next intentional breaking release stays reserved for
|
|
379
|
+
issue #28.
|
|
380
|
+
|
|
381
|
+
### Added
|
|
382
|
+
|
|
383
|
+
- **Toolkits — named scoped views over one deployment's registry.** A connecta
|
|
384
|
+
deployment belongs to an org; a toolkit is the slice one team group sees.
|
|
385
|
+
Operators declare them in the new `ConnectaConfig.toolkits` (`{ support: {
|
|
386
|
+
connectors: [...], includeTools?, excludeTools? } }`, with per-tool address
|
|
387
|
+
grain; `ToolkitConfig` and `ToolkitDefinition` are exported), and a client
|
|
388
|
+
selects one with `?toolkit=<name>` on the MCP URL. The boundary is a single
|
|
389
|
+
enforcement point: a `ScopedRegistry` built once per HTTP request — the
|
|
390
|
+
transport is stateless, so there is no longer-lived connection to hang it on
|
|
391
|
+
— with every meta-tool and the `execute_code` sandbox bridge typed against a
|
|
392
|
+
narrow `RegistryView` rather than the full `Registry`, so reaching for an
|
|
393
|
+
unfiltered method is a compile error instead of a silent leak. All nine
|
|
394
|
+
meta-tools are scoped; an out-of-scope address fails byte-identically to a
|
|
395
|
+
nonexistent one; `get_result` stashes are namespaced per toolkit so a scoped
|
|
396
|
+
session cannot page out a sibling's results; and health observations are
|
|
397
|
+
per-toolkit so one scope's `lastError` never names another's tools. A
|
|
398
|
+
monotonic has-ever-succeeded flag is the one deployment-wide *observation* a
|
|
399
|
+
scoped view can read back, so scoped sessions don't misreport live remote
|
|
400
|
+
connectors as `unknown`. Plenty of other state is shared by design and is not
|
|
401
|
+
scoped at all — the tool-catalog cache, and, worth saying out loud, the
|
|
402
|
+
`conn:<id>:` storage namespace and the credential vault: **a downstream OAuth
|
|
403
|
+
token obtained under one toolkit is usable from another.** `/ui`, `/health`,
|
|
404
|
+
and the OAuth pages stay unscoped operator surfaces. **Selection is
|
|
405
|
+
self-service: any caller who can reach the endpoint may name any toolkit, or
|
|
406
|
+
omit the parameter and see the whole registry.** A toolkit scopes visibility,
|
|
407
|
+
not identity — binding a team member to a toolkit belongs in `auth`, and
|
|
408
|
+
enforcing that binding is a deliberate follow-up (issue #37). Until it lands,
|
|
409
|
+
treat toolkits as an ergonomics and context-budget feature rather than access
|
|
410
|
+
control; connecta warns at startup when toolkits are configured without
|
|
411
|
+
inbound `auth`. `InboundAuth.authorize` already receives the full request, so
|
|
412
|
+
an adapter can refuse a mismatched `?toolkit=` today.
|
|
413
|
+
- **Per-connector usage guides, served by the existing `skills` meta-tool.** A
|
|
414
|
+
connector declaration can carry `usageGuide` — agent-facing markdown,
|
|
415
|
+
config-as-code, and a new pass-through option on both `ApiOptions` and
|
|
416
|
+
`RemoteMcpOptions`. `skills({})` then lists the built-in `usage` guide
|
|
417
|
+
alongside one summarized entry per guided connector, with `skills({ name:
|
|
418
|
+
"connector:<id>" })` returning the guide verbatim. Collision with a built-in
|
|
419
|
+
skill name is structurally impossible because the built-in names are bare
|
|
420
|
+
identifiers that never contain `:`, so no `connector:<id>` can ever match one
|
|
421
|
+
— not even when a connector's id is literally `usage`. The prefixed form is
|
|
422
|
+
also the only way to reach a guide: a bare connector id is never resolved,
|
|
423
|
+
and errors with a pointer to the prefixed name rather than shadowing
|
|
424
|
+
anything. Discovery is conditional: the extra `usage` section and the `guide`
|
|
425
|
+
hints on `search_tools`/`describe_tools`/`skills` appear only once some
|
|
426
|
+
connector declares a guide, so a zero-guide deployment's catalog is
|
|
427
|
+
byte-identical to before — with one exception: the `skills({ name })` error
|
|
428
|
+
wording for connector-shaped names. Those branches are gated on the name
|
|
429
|
+
matching a connector id, not on any guide existing, so asking for a bare
|
|
430
|
+
connector id now reports that the connector has no usage guide where 0.4.1
|
|
431
|
+
said `Unknown skill`, and `connector:<unknown>` reports an unknown connector.
|
|
432
|
+
Guides follow the connection's toolkit scope — a scoped session sees only
|
|
433
|
+
in-scope guides.
|
|
434
|
+
- **Per-connector `maxResultBytes` override.** The inline-result byte cap now
|
|
435
|
+
resolves per call as connector value → global `ConnectaConfig.maxResultBytes`
|
|
436
|
+
→ the built-in 50 000 default, so a connector that habitually returns large
|
|
437
|
+
payloads can be capped without shrinking everything else. It is a new
|
|
438
|
+
`maxResultBytes` field on the `Connector` interface with pass-through options
|
|
439
|
+
on both `ApiOptions` and `RemoteMcpOptions`. Resolution happens after address
|
|
440
|
+
resolution inside the call path, so `call_tool`, `call_destructive_tool`, and
|
|
441
|
+
each leg of a `batch_call` use their own connector's cap. `get_result`'s
|
|
442
|
+
default page size deliberately stays on the global cap — a stashed result
|
|
443
|
+
carries no connector identity — and `execute_code` host-call results never
|
|
444
|
+
flowed through this truncation path at all (they have a separate guard on the
|
|
445
|
+
final sandbox return), so they're unaffected. A toolkit can neither raise nor
|
|
446
|
+
lower a cap.
|
|
447
|
+
- **`ToolCallActivityEvent.toolkitId`** — an optional field on the exported
|
|
448
|
+
activity event, set to the toolkit a call arrived through and absent on
|
|
449
|
+
unscoped calls. Every custom `ActivityStore` now receives it, so a sink that
|
|
450
|
+
persists into a typed or column-bound schema needs a migration to keep the
|
|
451
|
+
value rather than drop it on the floor; sinks that pass the event through
|
|
452
|
+
untouched need no change.
|
|
453
|
+
|
|
454
|
+
### Fixed
|
|
455
|
+
|
|
456
|
+
- **`maxResultBytes` is validated at all three intake points, and `get_result`
|
|
457
|
+
paging can never fail to advance** (issue #32). The cap is now a whole number
|
|
458
|
+
of bytes >= 1, enforced from one shared definition. An unusable operator
|
|
459
|
+
value (zero, negative, `NaN`, non-integer, `Infinity`) emits a startup
|
|
460
|
+
warning through the existing insensible-config channel — naming the connector
|
|
461
|
+
for a per-connector override, and quoting the exact value the runtime falls
|
|
462
|
+
back to — instead of silently misbehaving: previously `0`/`NaN` served a
|
|
463
|
+
0-byte head, and a *negative* cap served a **larger** head than the default
|
|
464
|
+
(`Uint8Array.slice` counts from the end) while still claiming truncation. A
|
|
465
|
+
bad `get_result` `maxBytes` argument is now an ordinary input-validation
|
|
466
|
+
error. On top of intake validation, `alignEndToCharBoundary` widens any
|
|
467
|
+
window that would yield no bytes, so a page inside the payload always
|
|
468
|
+
advances; the end of the payload is handled separately, by only reporting a
|
|
469
|
+
`nextOffset` while the window stops short of the total. Between them, paging
|
|
470
|
+
terminates whatever effective page size reaches it. Worth recording so the
|
|
471
|
+
issue isn't closed on a false premise: the *client*-triggerable hang the
|
|
472
|
+
issue suspected was confirmed never reachable — the wire schema already
|
|
473
|
+
rejected non-positive `maxBytes` before dispatch, verified empirically
|
|
474
|
+
against the previous release. The hang was real but operator-triggered only;
|
|
475
|
+
the in-handler check is defense in depth for in-process callers. Valid values
|
|
476
|
+
behave byte-identically to 0.4.1.
|
|
477
|
+
- **Documentation coherence pass across README, `docs/`, and `examples/`.** The
|
|
478
|
+
changes above each documented themselves; nobody had reconciled the set.
|
|
479
|
+
Fixed: a TOC link to an anchor that never existed, a toolkits section that
|
|
480
|
+
opened by calling a toolkit "an access boundary" in direct contradiction of
|
|
481
|
+
the "what toolkits are *not*" text below it, a `ConnectaConfig` table missing
|
|
482
|
+
`toolkits` and `probeTimeoutMs`, design.md's silence on guides and toolkits,
|
|
483
|
+
and a `serveMcp` misattribution of where the scope is actually built.
|
|
484
|
+
**Operators running the worker example should note a schema migration**: its
|
|
485
|
+
`ActivityStore` dropped `toolkitId`, the field that shows which team's view a
|
|
486
|
+
call came through. The example now binds and reads back a `toolkit_id`
|
|
487
|
+
column, and its README carries an `ALTER TABLE` note for pre-existing D1
|
|
488
|
+
tables — without it, every insert fails with `no such column`, and because
|
|
489
|
+
activity writes are best-effort the failure is invisible to the caller (it
|
|
490
|
+
surfaces only as a warning per write in the operator log) while the activity
|
|
491
|
+
table stops filling.
|
|
492
|
+
|
|
493
|
+
### Security
|
|
494
|
+
|
|
495
|
+
- **`branding.favicon.href` is now scheme-gated**, completing the branding-URL
|
|
496
|
+
invariant begun in 0.4.1 (issue #29). All three branding URLs are validated
|
|
497
|
+
in one resolver shared by both HTML surfaces (`/ui` and the OAuth result
|
|
498
|
+
pages). A favicon href must be an absolute http(s) URL or a genuinely
|
|
499
|
+
root-relative path — enforced structurally, after stripping the tab/LF/CR
|
|
500
|
+
characters URL parsers ignore, with an origin comparison kept as defense in
|
|
501
|
+
depth. Root-relative only, because `/ui` and `/oauth/callback/<id>` sit at
|
|
502
|
+
different depths and a document-relative path would resolve differently on
|
|
503
|
+
each. A rejected value falls back silently to the default `/favicon.svg` and
|
|
504
|
+
is named in a startup warning — which also names a dropped `productUrl` or
|
|
505
|
+
`ownerUrl`, so a deployment that has been quietly carrying one of those since
|
|
506
|
+
0.4.1 will start seeing a warning line it did not see before.
|
|
507
|
+
`resolveBranding` also reads every field through a string guard, so malformed
|
|
508
|
+
branding from an untyped JavaScript caller degrades to defaults instead of
|
|
509
|
+
throwing at construction.
|
|
510
|
+
- **GHSA-frvp-7c67-39w9 cleared, and `check:security` tightened from `high` to
|
|
511
|
+
`moderate`.** The advisory (a Windows-only path traversal in
|
|
512
|
+
`@hono/node-server`'s `serve-static`) is resolved by a root `overrides` entry
|
|
513
|
+
pinning `@hono/node-server@^2.0.12`. No *declared* dependency version changes
|
|
514
|
+
— the MCP SDK stays where it was — though the override does of course move
|
|
515
|
+
the resolved transitive package across a major, 1.19.14 to 2.0.12. The
|
|
516
|
+
planned SDK bump could not work: every SDK release from 1.25.0 onward pins a
|
|
517
|
+
`@hono/node-server@^1.19.x` range that can never resolve to the patched 2.x
|
|
518
|
+
line, and it is those same releases that pull the package in at all. This is
|
|
519
|
+
audit-noise remediation rather than live vulnerability remediation — a
|
|
520
|
+
module-graph trace from every SDK entrypoint connecta uses found no reachable
|
|
521
|
+
`@hono/node-server` import, and the vulnerable `serve-static` subpath is
|
|
522
|
+
imported by nothing in the tree — which is what makes the forced major safe.
|
|
523
|
+
The old `high` gate caught high and critical findings correctly, but by
|
|
524
|
+
construction could never surface this moderate one; `SECURITY.md` is updated
|
|
525
|
+
to match, having still recorded the advisory as an accepted open finding
|
|
526
|
+
under the old threshold. **Residual, stated plainly:** npm applies
|
|
527
|
+
`overrides` only at the root project, so consumers who install this package
|
|
528
|
+
still resolve the `1.19.x` range and will keep seeing the advisory in their
|
|
529
|
+
own audits until the SDK moves to `@hono/node-server@^2`. Tracked as issue
|
|
530
|
+
#40, which also retires the override.
|
|
531
|
+
|
|
5
532
|
## 0.4.1 — 2026-07-25
|
|
6
533
|
|
|
7
534
|
A security-hardening release from a full audit of the codebase. Every change is
|