@zackbart/connecta 0.10.4 → 0.10.6
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 +135 -0
- package/dist/activity.d.ts +11 -1
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js +44 -3
- package/dist/activity.js.map +1 -1
- package/dist/catalog-service.d.ts +40 -0
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +97 -15
- package/dist/catalog-service.js.map +1 -1
- package/dist/errors.d.ts +48 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +67 -0
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +72 -0
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +163 -10
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts +9 -2
- package/dist/invocation.d.ts.map +1 -1
- package/dist/invocation.js +59 -29
- package/dist/invocation.js.map +1 -1
- package/dist/meta-tools.d.ts +12 -3
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +185 -30
- package/dist/meta-tools.js.map +1 -1
- package/dist/operator-ui/generated.d.ts +1 -1
- package/dist/operator-ui/generated.d.ts.map +1 -1
- package/dist/operator-ui/generated.js +1 -1
- package/dist/operator-ui/generated.js.map +1 -1
- package/dist/registry.d.ts +11 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +5 -2
- package/dist/registry.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +9 -0
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/shared.d.ts +4 -0
- package/dist/routes/shared.d.ts.map +1 -1
- package/dist/routes/shared.js.map +1 -1
- package/dist/skills.d.ts +1 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/code-mode.md +125 -34
- package/documentation/meta-tools.md +91 -9
- package/documentation/rich-output-design.md +212 -0
- package/ethos.md +17 -19
- package/examples/worker/README.md +11 -3
- package/examples/worker/src/d1-activity-row.ts +40 -0
- package/examples/worker/src/d1-activity.ts +3 -2
- package/package.json +1 -1
- package/src/activity.ts +64 -3
- package/src/catalog-service.ts +166 -26
- package/src/errors.ts +102 -1
- package/src/execute.ts +240 -10
- package/src/index.ts +22 -0
- package/src/invocation.ts +59 -17
- package/src/meta-tools.ts +239 -37
- package/src/operator-ui/browser.ts +10 -2
- package/src/operator-ui/generated.ts +1 -1
- package/src/registry.ts +5 -2
- package/src/routes/mcp.ts +9 -0
- package/src/routes/shared.ts +4 -0
- package/src/skills.ts +1 -1
- package/src/version.ts +1 -1
- package/templates/node/package.json +1 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Rich MCP output from programs — design record
|
|
2
|
+
|
|
3
|
+
The decision record for [#267](https://github.com/zackbart/connecta/issues/267):
|
|
4
|
+
whether and how an `execute_code` program may deliver MCP-native output — text,
|
|
5
|
+
images, audio — instead of only a single JSON return value. The verdicts live in
|
|
6
|
+
[`ethos.md`](../ethos.md); this document carries the argument, the contract
|
|
7
|
+
precisely enough to implement, and the shapes that were considered and refused.
|
|
8
|
+
The implementation landed via
|
|
9
|
+
[#270](https://github.com/zackbart/connecta/issues/270): the contract clauses
|
|
10
|
+
below (`M1`–`M10`) are now normative in [`code-mode.md`](./code-mode.md)'s
|
|
11
|
+
"Emitted output" section, which wins where the two disagree. This document
|
|
12
|
+
remains the argument and the record of rejected shapes.
|
|
13
|
+
|
|
14
|
+
## The problem
|
|
15
|
+
|
|
16
|
+
A program returns one JSON value, bounded at 24,000 serialized characters
|
|
17
|
+
(`R2`). That is the right contract for data: compose, project, return only what
|
|
18
|
+
you need. It is no contract at all for an image. Base64 is not projectable — a
|
|
19
|
+
program cannot "return less" of a screenshot — so any rich block a downstream
|
|
20
|
+
tool produces dies at the exit guard, converted to a truncation envelope whose
|
|
21
|
+
preview is the head of a base64 string, which is of use to no one.
|
|
22
|
+
|
|
23
|
+
The asymmetry is already visible in the classic surface. `call_tool` forwards
|
|
24
|
+
non-text downstream blocks to the client untouched when they fit the result cap
|
|
25
|
+
(the fix for issue #43 made the guard measure every block, then pass them
|
|
26
|
+
through in original order). And on the intake side, a host call **inside** a
|
|
27
|
+
program is not size-capped at all: `unwrapMcpResult` hands mixed content
|
|
28
|
+
through as a raw object, base64 and all. So today a downstream image survives
|
|
29
|
+
the trip *into* the sandbox and survives the classic surface entirely — the one
|
|
30
|
+
place it cannot survive is the exit of the surface connecta calls primary.
|
|
31
|
+
|
|
32
|
+
## The shape: `connecta.emit(block)`
|
|
33
|
+
|
|
34
|
+
One new provider function. A program emits zero or more validated MCP content
|
|
35
|
+
blocks during its run; the host collects them request-locally and appends them
|
|
36
|
+
to the final `execute_code` result, after the JSON envelope, in emission order.
|
|
37
|
+
|
|
38
|
+
The single most load-bearing fact about this shape: **it does not touch the
|
|
39
|
+
executor contract.** `Executor.execute()` still returns
|
|
40
|
+
`{ result, error?, logs? }`, which is what keeps the interface structurally
|
|
41
|
+
compatible with `DynamicWorkerExecutor` from `@cloudflare/codemode` — a class
|
|
42
|
+
connecta does not control and must not fork. `emit` is a provider function like
|
|
43
|
+
`connecta.call`: both executors already bridge provider calls (QuickJS over
|
|
44
|
+
child-process IPC, the Dynamic Worker over isolate RPC), so emitted bytes cross
|
|
45
|
+
the guest boundary exactly once, as an argument, and are never seen again by
|
|
46
|
+
the sandbox, the `ExecuteResult`, or the `R2` guard. Executor parity is not a
|
|
47
|
+
test obligation bolted on afterward; it is structural. A third-party executor
|
|
48
|
+
that correctly bridges provider functions gets emission without writing a line.
|
|
49
|
+
|
|
50
|
+
### Contract (drafts for code-mode.md)
|
|
51
|
+
|
|
52
|
+
**M1.** `connecta.emit(block)` accepts exactly one MCP content block of type
|
|
53
|
+
`text`, `image`, or `audio`:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
{ type: "text", text: string }
|
|
57
|
+
{ type: "image", data: string /* base64 */, mimeType: string }
|
|
58
|
+
{ type: "audio", data: string /* base64 */, mimeType: string }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The shape is validated strictly at the call — required fields present, no
|
|
62
|
+
extra fields, no `annotations`, no `_meta`. An invalid block throws a
|
|
63
|
+
catchable error and nothing is accepted. Rejected, not stripped: silently
|
|
64
|
+
deleting fields would deliver something the program did not ask to emit.
|
|
65
|
+
There is no sugar form (`emit("text")` is invalid); sugar is how a
|
|
66
|
+
one-shape contract grows hair.
|
|
67
|
+
|
|
68
|
+
**M2.** Emitted blocks are collected on the host in emission order and
|
|
69
|
+
delivered only with a successful result: the response's `content` array is the
|
|
70
|
+
JSON envelope text block first, then the emitted blocks. When at least one
|
|
71
|
+
block was emitted the envelope gains `emitted: N`; when none were, the
|
|
72
|
+
response is byte-for-byte the ordinary path (the `R6` discipline).
|
|
73
|
+
`structuredContent` remains the JSON envelope alone — emission is a
|
|
74
|
+
presentation channel, not a second data channel.
|
|
75
|
+
|
|
76
|
+
**M3.** The return value and emission are independent. `R2` applies to the
|
|
77
|
+
return value exactly as before and never measures emitted bytes; a truncated
|
|
78
|
+
return value does not suppress emitted blocks, and emitted blocks do not
|
|
79
|
+
shrink the return budget.
|
|
80
|
+
|
|
81
|
+
**M4.** A program that ends in an error delivers no emitted blocks. The error
|
|
82
|
+
envelope reports `emittedDiscarded: N` when N > 0, so the discard is visible
|
|
83
|
+
rather than silent. Partial rich output from a failed program is ambiguity,
|
|
84
|
+
not a deliverable.
|
|
85
|
+
|
|
86
|
+
**M5.** Two budgets, both deployment-configurable, both failing loudly at the
|
|
87
|
+
`emit` call: an aggregate serialized-byte budget (default 4,000,000 bytes) and
|
|
88
|
+
a block count (default 32). An emit that would exceed either throws a
|
|
89
|
+
catchable error naming the budget and the room remaining; the block is not
|
|
90
|
+
partially accepted, and prior accepted blocks are unaffected. There is no
|
|
91
|
+
`get_result` stash and no paging: the program learns it is over budget while
|
|
92
|
+
it can still choose differently, which is `R4`'s spirit applied to bytes that
|
|
93
|
+
genuinely cannot be shrunk — refuse at the door instead of paging after the
|
|
94
|
+
fact.
|
|
95
|
+
|
|
96
|
+
**M6.** Connecta claims no provenance. Every emitted block is program output,
|
|
97
|
+
trusted exactly as much as the program's return value — no more because its
|
|
98
|
+
bytes happen to have come from a downstream call, no less because they were
|
|
99
|
+
assembled in the sandbox. Preserving a downstream image means re-emitting it:
|
|
100
|
+
the program selects the block from the raw downstream result (which reaches it
|
|
101
|
+
uncapped) and emits it. The host attaches no attribution.
|
|
102
|
+
|
|
103
|
+
**M7.** `emit` does not spend the host-call budget (`L4`). Its bounds are
|
|
104
|
+
`M5`'s and only `M5`'s.
|
|
105
|
+
|
|
106
|
+
**M8.** `ExecuteResult` is unchanged. No executor — QuickJS, Dynamic Worker,
|
|
107
|
+
or third-party — needs modification, and the parity suite runs the same
|
|
108
|
+
emitting program through both vitest projects and asserts identical delivered
|
|
109
|
+
content.
|
|
110
|
+
|
|
111
|
+
**M9.** Emission is request-local and unstreamed. Blocks exist only in the
|
|
112
|
+
finished response; nothing is pushed early, nothing survives the request, and
|
|
113
|
+
`emit` resolving means "accepted into the collection," never "delivered."
|
|
114
|
+
|
|
115
|
+
**M10.** Activity remains payload-free by construction. With
|
|
116
|
+
`diagnostics: true` the diagnostics block gains one aggregate — emitted block
|
|
117
|
+
count and serialized bytes — numbers only, per `R8`.
|
|
118
|
+
|
|
119
|
+
### Intake, specified
|
|
120
|
+
|
|
121
|
+
`unwrapMcpResult`'s current behavior becomes contract rather than accident: an
|
|
122
|
+
all-text downstream result is JSON-parsed when possible (as today), and a
|
|
123
|
+
result carrying non-text blocks passes through as the raw object, `content`
|
|
124
|
+
array intact, uncapped. That raw fallthrough is the preservation path `M6`
|
|
125
|
+
depends on. Bounding it would kill re-emission at intake; projecting it would
|
|
126
|
+
repeat the mistake `R1` refuses.
|
|
127
|
+
|
|
128
|
+
## Sizing rationale
|
|
129
|
+
|
|
130
|
+
The 24,000-character return boundary is a *context* budget — the return value
|
|
131
|
+
lands in the model's window as text. Emitted image and audio blocks do not:
|
|
132
|
+
MCP-aware hosts deliver them as media, which models ingest at media prices,
|
|
133
|
+
not base64-text prices. So the emission budget is a *transport* bound, not a
|
|
134
|
+
context bound, and 4,000,000 serialized bytes (roughly a 3 MB binary after
|
|
135
|
+
base64's 4/3 inflation — two or three real screenshots) is deliberately far
|
|
136
|
+
above `R2` without being a file-hosting ambition. Deployments that know their
|
|
137
|
+
client's limits tune it; the default just has to make `emit` useful for the
|
|
138
|
+
motivating case without inviting anyone to ship video through an MCP response.
|
|
139
|
+
|
|
140
|
+
## Security posture
|
|
141
|
+
|
|
142
|
+
- **Nothing is minted.** `emit` grants no authority: it cannot cause a fetch,
|
|
143
|
+
reference a credential, or make the host serve anything. It moves bytes the
|
|
144
|
+
program already had into the response.
|
|
145
|
+
- **Strict typing is the lure defense.** The block validator accepting only
|
|
146
|
+
`text`, `image`, and `audio` is what keeps a program from emitting a
|
|
147
|
+
`resource_link` whose URI a helpful client might dereference. The refused
|
|
148
|
+
types are refused precisely because they are pointers, and pointers get
|
|
149
|
+
followed.
|
|
150
|
+
- **Image-borne injection is the existing class.** A downstream screenshot
|
|
151
|
+
containing hostile text is the same hazard through `emit` as through
|
|
152
|
+
`call_tool`'s block passthrough today; connecta's posture is unchanged — all
|
|
153
|
+
tool output is untrusted input to the client, and connecta adds no claim
|
|
154
|
+
otherwise.
|
|
155
|
+
|
|
156
|
+
## Considered and refused
|
|
157
|
+
|
|
158
|
+
**A sentinel return shape** (`return { $mcpContent: [...] }`). Overloads the
|
|
159
|
+
one data channel with a magic key that collides with honest data, subjects
|
|
160
|
+
rich blocks to the `R2` guard they cannot survive, and turns "what did this
|
|
161
|
+
program return" into a parse question. The return value stays a value.
|
|
162
|
+
|
|
163
|
+
**Widening `ExecuteResult` or the `Executor` interface.** The obvious place
|
|
164
|
+
and the wrong one: the interface's value is that `@cloudflare/codemode`'s
|
|
165
|
+
executor already satisfies it. A `content` field connecta added would either
|
|
166
|
+
fork the Workers executor or wait on a vendor; and every third-party executor
|
|
167
|
+
would need matching surgery. Refused as a class, not just deferred.
|
|
168
|
+
|
|
169
|
+
**Provenance-preserving handles** (downstream blocks stay host-side; the guest
|
|
170
|
+
gets `{ $ref }` tokens to pass to `emit`). Honest attribution, but it creates
|
|
171
|
+
host-side object identity that generated code holds references to —
|
|
172
|
+
capability-shaped machinery in the one place the ethos says generated code
|
|
173
|
+
mints nothing. And the label buys nothing: no MCP client trusts a tool result
|
|
174
|
+
more because an aggregator vouches for its lineage. Re-emission delivers the
|
|
175
|
+
same bytes with a simpler story: everything a program emits is program output.
|
|
176
|
+
|
|
177
|
+
**`resource` and `resource_link` emission.** Connecta already refuses to
|
|
178
|
+
aggregate resources; letting programs emit them would introduce through the
|
|
179
|
+
back door a surface the front door refused, and a guest-minted URI is a lure
|
|
180
|
+
(see the posture above). A future argument would have to be a new one.
|
|
181
|
+
|
|
182
|
+
**A `get_result` stash for over-budget emissions.** Paging exists for
|
|
183
|
+
downstream payloads a model could not shrink; an over-budget emission is a
|
|
184
|
+
program decision that has not happened yet. Failing the `emit` call while the
|
|
185
|
+
program can still adapt beats stashing megabytes nobody may page.
|
|
186
|
+
|
|
187
|
+
**Streaming or partial delivery.** Standing invariant — no server push,
|
|
188
|
+
nothing request-bound survives the request. Emission is collect-then-deliver
|
|
189
|
+
by construction (`M9`).
|
|
190
|
+
|
|
191
|
+
**Delivering emissions from failed programs.** The blocks may describe a world
|
|
192
|
+
the error contradicts. Failure delivers the error, the logs, and an honest
|
|
193
|
+
count of what was discarded (`M4`).
|
|
194
|
+
|
|
195
|
+
## Verification sketch
|
|
196
|
+
|
|
197
|
+
- Parity: one emitting program, both vitest projects (`WORKERS_SUITES`),
|
|
198
|
+
identical delivered content arrays.
|
|
199
|
+
- Validation: each rejected shape (missing field, extra field, `annotations`,
|
|
200
|
+
unknown type, bare string) throws catchably and accepts nothing.
|
|
201
|
+
- Budgets: byte and count budgets fail at the crossing call, prior blocks
|
|
202
|
+
intact; error names the budget.
|
|
203
|
+
- Independence: over-`R2` return value + emitted blocks → truncation envelope
|
|
204
|
+
*and* delivered blocks in one response.
|
|
205
|
+
- Discard: throwing program with prior emits → error envelope with
|
|
206
|
+
`emittedDiscarded`, no blocks in `content`.
|
|
207
|
+
- Byte-for-byte: a program that never emits produces today's exact response.
|
|
208
|
+
|
|
209
|
+
The implementation issue carries these as acceptance criteria; the clauses
|
|
210
|
+
fold into `code-mode.md` under a new "Emitted output" section when it closes,
|
|
211
|
+
and the test-map row in [`operations.md`](./operations.md) lands with the
|
|
212
|
+
suite.
|
package/ethos.md
CHANGED
|
@@ -18,13 +18,15 @@ order, and amending it is a design decision, not a drive-by edit.
|
|
|
18
18
|
- **Two equal ways in.** `remoteMcp()` proxies a downstream MCP server;
|
|
19
19
|
`api()` hand-writes a deliberate tool surface over a plain HTTP API. Both
|
|
20
20
|
come out identical: same addresses, same catalog, same safety rules.
|
|
21
|
-
- **
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
- **Seven tools, an executor required.** The primary surface is a program, so
|
|
22
|
+
every deployment runs an executor — a Dynamic Worker on Cloudflare, QuickJS
|
|
23
|
+
behind its optional-peer subpath on Node — and one without refuses to boot
|
|
24
|
+
([#273](https://github.com/zackbart/connecta/issues/273)). Every meta-tool
|
|
25
|
+
earns its keep: the default answer to "agents need X" is the program
|
|
26
|
+
surface, and a capability has to be shown inexpressible through it before
|
|
27
|
+
it earns a top-level tool
|
|
28
|
+
([#224](https://github.com/zackbart/connecta/issues/224)). Packaging is
|
|
29
|
+
unchanged: required describes the deployment, never the dependencies.
|
|
28
30
|
- **Safe by default.** Only tools explicitly annotated read-only are callable
|
|
29
31
|
without crossing the destructive boundary — directly or from generated code;
|
|
30
32
|
everything else goes through `call_destructive_tool`, where the MCP host can
|
|
@@ -33,12 +35,6 @@ order, and amending it is a design decision, not a drive-by edit.
|
|
|
33
35
|
- **One fetch-native core, two runtimes.** The same code runs unchanged on
|
|
34
36
|
Cloudflare Workers and in Node — a Worker or a Docker stack, your pick. Web
|
|
35
37
|
APIs only in the core; Node touches live behind explicit subpaths.
|
|
36
|
-
- **An executor is the assumed posture.** The primary surface is a program, so
|
|
37
|
-
the deployment connecta is written toward has one — a Dynamic Worker on
|
|
38
|
-
Cloudflare, QuickJS behind its optional-peer subpath on Node. Configuring one
|
|
39
|
-
is what selects the code-first surface; executor-free stays supported as
|
|
40
|
-
compatibility, not as an equal citizen. Packaging is unchanged: assumed is
|
|
41
|
-
about defaults, never about dependencies.
|
|
42
38
|
- **Observable, never administrable.** Operator pages show connector status,
|
|
43
39
|
masked credentials, and payload-free activity. They can rotate a secret;
|
|
44
40
|
they cannot add a connector, change policy, or alter what an agent can call.
|
|
@@ -79,12 +75,10 @@ proposing one without a new argument is not.
|
|
|
79
75
|
| Agent credential recovery | accepted | one `auth_required` route through `authorize_connector`; only an operator handles secrets ([#192](https://github.com/zackbart/connecta/issues/192)) |
|
|
80
76
|
| Operator-issued MCP access tokens | accepted | named, revocable authentication gives header-capable clients a small alternative to OAuth; tokens identify callers but never scope tools or become operator credentials |
|
|
81
77
|
| Structured result surface | accepted | canonical `structuredContent` plus complete compact `content`; summary-only text is gated on host-forwarding evidence ([#191](https://github.com/zackbart/connecta/issues/191)) |
|
|
82
|
-
| Code mode (`execute_code`) | accepted | the primary read, discovery, and composition surface: smaller serialized definitions
|
|
83
|
-
| Code-first as the
|
|
84
|
-
|
|
|
85
|
-
|
|
|
86
|
-
| Executor-assumed posture | accepted | the primary surface is a program, so a deployment without an executor can only ever be the compatibility shape; packaging invariants are untouched ([#224](https://github.com/zackbart/connecta/issues/224)) |
|
|
87
|
-
| Classic surface retention | accepted | what an executor-free deployment necessarily serves, the rollback path, and the eval's control arm; `surface: "classic"` alongside an executor is the only knob, and whether classic is ever removed is a separate future decision ([#224](https://github.com/zackbart/connecta/issues/224)) |
|
|
78
|
+
| Code mode (`execute_code`) | accepted | the primary read, discovery, and composition surface: smaller serialized definitions, far smaller results once composition and projection happen before the model sees them, and a cold-start model that read the interface without help ([exploration](./documentation/code-first-exploration.md), [#224](https://github.com/zackbart/connecta/issues/224)) |
|
|
79
|
+
| Code-first as the default; the eval gate retired | accepted | owner decision, 2026-07-30: one operator, no deploy-time flip; [`eval/code-first-gate`](./eval/code-first-gate/README.md) survives as measurement, but nothing waits on its verdict ([#222](https://github.com/zackbart/connecta/issues/222), [#224](https://github.com/zackbart/connecta/issues/224)) |
|
|
80
|
+
| Surface consolidation to seven tools | accepted | `list_connectors`, `describe_tools`, and `batch_call` fold into the program surface, deleting the routing choice between direct calls, batches, discovery, and execution; `call_tool` stays because a simple call is not cheaper through code ([#224](https://github.com/zackbart/connecta/issues/224)) |
|
|
81
|
+
| Classic (executor-free) surface | removed | supersedes its provisional retention under [#224](https://github.com/zackbart/connecta/issues/224) — an executor is mandatory, and a deployment without one refuses to boot rather than serving a fallback shape ([#273](https://github.com/zackbart/connecta/issues/273)) |
|
|
88
82
|
| Connector shortcut namespaces in programs | accepted | sugar over canonical addressing, kept but frozen — every expansion invents a collision class `<connectorId>.<toolName>` already solved ([#223](https://github.com/zackbart/connecta/issues/223)) |
|
|
89
83
|
| Automatic host-side projection of program results | refused | the measured win was program-authored projection; a host heuristic drops fields a program chose to return and is invisible in the transcript ([#223](https://github.com/zackbart/connecta/issues/223)) |
|
|
90
84
|
| Caller-visible execution diagnostics | accepted | optional request-local timing and size aggregates make catalog, connector, and executor costs distinguishable without persisting payloads, adding a tool, or charging normal responses context ([#247](https://github.com/zackbart/connecta/issues/247)) |
|
|
@@ -94,6 +88,10 @@ proposing one without a new argument is not.
|
|
|
94
88
|
| MRTR / `input_required` passthrough | gated | statelessly relayable via `requestState`, but no host or downstream emits it yet; fails loudly until adoption evidence ([#176](https://github.com/zackbart/connecta/issues/176)) |
|
|
95
89
|
| Native Tasks for oversized results | refused | tasks solve duration, `get_result` solves size; paging on a polling extension adds round trips for nothing ([#176](https://github.com/zackbart/connecta/issues/176)) |
|
|
96
90
|
| Downstream `ttlMs` cache hints | gated | fixed TTL + fingerprint is battle-tested and catalog reads are ~3 ms; earns its way in with refresh-churn evidence ([#176](https://github.com/zackbart/connecta/issues/176)) |
|
|
91
|
+
| Rich program output (`connecta.emit`) | accepted | one host-collected emission channel: programs emit strictly validated text/image/audio blocks, delivered after the result envelope on success only; budgets fail loudly at the emit call ([design record](./documentation/rich-output-design.md), [#267](https://github.com/zackbart/connecta/issues/267)) |
|
|
92
|
+
| Result-channel widening of the `Executor` contract | refused | `ExecuteResult` stays `{ result, error?, logs? }` — structural compatibility with `@cloudflare/codemode` is the parity guarantee; emission rides the provider bridge instead ([#267](https://github.com/zackbart/connecta/issues/267)) |
|
|
93
|
+
| Guest-emitted `resource` / `resource_link` blocks | gated | a program can never mint a URI a client may dereference; MCP UI ([#266](https://github.com/zackbart/connecta/issues/266)) may design the one carve-out — programs supply only content, connecta mints the `ui://` address outside the sandbox ([#267](https://github.com/zackbart/connecta/issues/267)) |
|
|
94
|
+
| Provenance tracking for emitted content | refused | everything a program emits is program output; handles or attribution labels are capability-shaped machinery that changes no client's trust posture ([#267](https://github.com/zackbart/connecta/issues/267)) |
|
|
97
95
|
|
|
98
96
|
## Invariants
|
|
99
97
|
|
|
@@ -111,6 +111,7 @@ into `src/index.ts`**, so the example deploys without a database. To enable it:
|
|
|
111
111
|
duration_ms INTEGER NOT NULL,
|
|
112
112
|
attempts INTEGER NOT NULL,
|
|
113
113
|
error_code TEXT,
|
|
114
|
+
friction TEXT,
|
|
114
115
|
server_name TEXT NOT NULL,
|
|
115
116
|
server_version TEXT NOT NULL,
|
|
116
117
|
deployment_id TEXT
|
|
@@ -120,14 +121,21 @@ into `src/index.ts`**, so the example deploys without a database. To enable it:
|
|
|
120
121
|
ON tool_call_activity (occurred_at_ms DESC, id DESC);
|
|
121
122
|
```
|
|
122
123
|
|
|
123
|
-
**Already have this table?** `actor_namespace`
|
|
124
|
-
example, and `CREATE TABLE IF NOT EXISTS` will not add
|
|
125
|
-
already exists. Add
|
|
124
|
+
**Already have this table?** `actor_namespace` and `friction` were added
|
|
125
|
+
after the original example, and `CREATE TABLE IF NOT EXISTS` will not add
|
|
126
|
+
them to a table that already exists. Add them as migrations:
|
|
126
127
|
|
|
127
128
|
```sql
|
|
128
129
|
ALTER TABLE tool_call_activity ADD COLUMN actor_namespace TEXT;
|
|
130
|
+
ALTER TABLE tool_call_activity ADD COLUMN friction TEXT;
|
|
129
131
|
```
|
|
130
132
|
|
|
133
|
+
`friction` is stored rather than derived because one of its classes belongs
|
|
134
|
+
to a call that *succeeded*: a result too large to return inline is friction
|
|
135
|
+
for the agent and carries no error code. Rows written before the column keep
|
|
136
|
+
working — the mapping module derives their friction from `error_code` — and
|
|
137
|
+
`error_code IS NOT NULL` remains an honest count of failures.
|
|
138
|
+
|
|
131
139
|
Do this **before** deploying the updated `d1-activity.ts`: its `INSERT`
|
|
132
140
|
names the column, so against an un-migrated table every write fails with
|
|
133
141
|
`no such column`. Activity writes are best-effort by design — connecta logs
|
|
@@ -23,11 +23,44 @@ export interface ActivityEvent {
|
|
|
23
23
|
durationMs: number;
|
|
24
24
|
attempts: number;
|
|
25
25
|
errorCode?: string;
|
|
26
|
+
friction?:
|
|
27
|
+
| "tool_not_found"
|
|
28
|
+
| "schema_retry"
|
|
29
|
+
| "destructive_reroute"
|
|
30
|
+
| "auth_required"
|
|
31
|
+
| "result_too_large";
|
|
26
32
|
serverName: string;
|
|
27
33
|
serverVersion: string;
|
|
28
34
|
deploymentId?: string;
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Backfill for rows written before `friction` had a column of its own. Every
|
|
39
|
+
* arm must agree with the package's own `agentFrictionForCode`; the repository
|
|
40
|
+
* test suite pins that, because a dashboard that groups by friction should not
|
|
41
|
+
* change its answer depending on which side of a migration a row landed on.
|
|
42
|
+
* Friction that never had an error code — an oversized but successful result —
|
|
43
|
+
* is only recoverable from the column, which is why the column exists.
|
|
44
|
+
*/
|
|
45
|
+
function frictionForCode(code: string | null): ActivityEvent["friction"] {
|
|
46
|
+
switch (code) {
|
|
47
|
+
case "unknown_address":
|
|
48
|
+
case "unknown_tool":
|
|
49
|
+
case "ambiguous_tool_alias":
|
|
50
|
+
return "tool_not_found";
|
|
51
|
+
case "invalid_args":
|
|
52
|
+
return "schema_retry";
|
|
53
|
+
case "destructive_tool_requires_approval":
|
|
54
|
+
return "destructive_reroute";
|
|
55
|
+
case "auth_required":
|
|
56
|
+
return "auth_required";
|
|
57
|
+
case "result_too_large":
|
|
58
|
+
return "result_too_large";
|
|
59
|
+
default:
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
31
64
|
export interface ActivityRow {
|
|
32
65
|
id: string;
|
|
33
66
|
occurred_at_ms: number;
|
|
@@ -42,6 +75,7 @@ export interface ActivityRow {
|
|
|
42
75
|
duration_ms: number;
|
|
43
76
|
attempts: number;
|
|
44
77
|
error_code: string | null;
|
|
78
|
+
friction: ActivityEvent["friction"] | null;
|
|
45
79
|
server_name: string;
|
|
46
80
|
server_version: string;
|
|
47
81
|
deployment_id: string | null;
|
|
@@ -64,6 +98,10 @@ export function activityEventToRow(
|
|
|
64
98
|
duration_ms: event.durationMs,
|
|
65
99
|
attempts: event.attempts,
|
|
66
100
|
error_code: event.errorCode ?? null,
|
|
101
|
+
// Stored beside the code rather than derived from it: a truncated result is
|
|
102
|
+
// friction on a call that succeeded, so it has no error code to derive from
|
|
103
|
+
// — and `error_code IS NOT NULL` stays an honest count of failures.
|
|
104
|
+
friction: event.friction ?? null,
|
|
67
105
|
server_name: event.serverName,
|
|
68
106
|
server_version: event.serverVersion,
|
|
69
107
|
deployment_id: event.deploymentId ?? null,
|
|
@@ -73,6 +111,7 @@ export function activityEventToRow(
|
|
|
73
111
|
export function activityRowToEvent(
|
|
74
112
|
row: ActivityRow,
|
|
75
113
|
): ActivityEvent {
|
|
114
|
+
const friction = row.friction ?? frictionForCode(row.error_code);
|
|
76
115
|
return {
|
|
77
116
|
schemaVersion: 1,
|
|
78
117
|
id: row.id,
|
|
@@ -91,6 +130,7 @@ export function activityRowToEvent(
|
|
|
91
130
|
durationMs: row.duration_ms,
|
|
92
131
|
attempts: row.attempts,
|
|
93
132
|
...(row.error_code ? { errorCode: row.error_code } : {}),
|
|
133
|
+
...(friction ? { friction } : {}),
|
|
94
134
|
serverName: row.server_name,
|
|
95
135
|
serverVersion: row.server_version,
|
|
96
136
|
...(row.deployment_id
|
|
@@ -50,8 +50,8 @@ export function d1ActivityStore(db: D1Database): ActivityStore {
|
|
|
50
50
|
id, occurred_at_ms, request_id, actor_kind, actor_id,
|
|
51
51
|
actor_namespace,
|
|
52
52
|
connector_id, tool_name, source, outcome, duration_ms, attempts,
|
|
53
|
-
error_code, server_name, server_version, deployment_id
|
|
54
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
53
|
+
error_code, friction, server_name, server_version, deployment_id
|
|
54
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
55
55
|
)
|
|
56
56
|
.bind(
|
|
57
57
|
row.id,
|
|
@@ -67,6 +67,7 @@ export function d1ActivityStore(db: D1Database): ActivityStore {
|
|
|
67
67
|
row.duration_ms,
|
|
68
68
|
row.attempts,
|
|
69
69
|
row.error_code,
|
|
70
|
+
row.friction ?? null,
|
|
70
71
|
row.server_name,
|
|
71
72
|
row.server_version,
|
|
72
73
|
row.deployment_id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zackbart/connecta",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
|
package/src/activity.ts
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
|
+
import { boundedEchoText } from "./errors.js";
|
|
1
2
|
import type { Logger } from "./types.js";
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* How long an identity field may be before the store stops believing it.
|
|
6
|
+
*
|
|
7
|
+
* `connectorId` and `toolName` are ordinarily operator- and connector-authored,
|
|
8
|
+
* and 128 bytes is far past any real one. But an address that resolved to
|
|
9
|
+
* nothing is recorded *as written*, which puts a caller-authored string in both
|
|
10
|
+
* fields — and "payload-free by construction" has to mean the event type has
|
|
11
|
+
* nowhere to put a payload, not merely that connecta declines to. A 40 KB
|
|
12
|
+
* invented connector id is a payload wearing an id's clothing.
|
|
13
|
+
*
|
|
14
|
+
* Clamped rather than dropped: the invented id is precisely what an operator
|
|
15
|
+
* needs to see, and its first 128 bytes identify the mistake as well as all
|
|
16
|
+
* 40,000 would. The `…` marker keeps a clamped value from reading as a real one.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_ACTIVITY_NAME_BYTES = 128;
|
|
19
|
+
|
|
20
|
+
/** Two names and the dot between them. */
|
|
21
|
+
const MAX_ACTIVITY_ADDRESS_BYTES = MAX_ACTIVITY_NAME_BYTES * 2 + 1;
|
|
22
|
+
|
|
3
23
|
export type ActivityCallSource =
|
|
4
24
|
| "call_tool"
|
|
5
25
|
| "call_destructive_tool"
|
|
@@ -12,6 +32,35 @@ export type ActivityOutcome =
|
|
|
12
32
|
| "timeout"
|
|
13
33
|
| "cancelled";
|
|
14
34
|
|
|
35
|
+
export type AgentFriction =
|
|
36
|
+
| "tool_not_found"
|
|
37
|
+
| "schema_retry"
|
|
38
|
+
| "destructive_reroute"
|
|
39
|
+
| "auth_required"
|
|
40
|
+
| "result_too_large";
|
|
41
|
+
|
|
42
|
+
/** Coarse recovery class derived without inspecting payloads or error prose. */
|
|
43
|
+
export function agentFrictionForCode(
|
|
44
|
+
code: string | undefined,
|
|
45
|
+
): AgentFriction | undefined {
|
|
46
|
+
switch (code) {
|
|
47
|
+
case "unknown_address":
|
|
48
|
+
case "unknown_tool":
|
|
49
|
+
case "ambiguous_tool_alias":
|
|
50
|
+
return "tool_not_found";
|
|
51
|
+
case "invalid_args":
|
|
52
|
+
return "schema_retry";
|
|
53
|
+
case "destructive_tool_requires_approval":
|
|
54
|
+
return "destructive_reroute";
|
|
55
|
+
case "auth_required":
|
|
56
|
+
return "auth_required";
|
|
57
|
+
case "result_too_large":
|
|
58
|
+
return "result_too_large";
|
|
59
|
+
default:
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
15
64
|
/**
|
|
16
65
|
* Authenticated identity attached to an activity event. `id` is intentionally
|
|
17
66
|
* optional: open deployments and shared bearer tokens cannot honestly identify
|
|
@@ -48,7 +97,14 @@ export interface ToolCallActivityEvent {
|
|
|
48
97
|
outcome: ActivityOutcome;
|
|
49
98
|
durationMs: number;
|
|
50
99
|
attempts: number;
|
|
100
|
+
/** Set only when the call actually failed; a truncated success has none. */
|
|
51
101
|
errorCode?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Payload-free recovery class. Usually derived from `errorCode`, but it can
|
|
104
|
+
* also stand alone: a result too large to return inline is friction for the
|
|
105
|
+
* agent while remaining an `outcome: "success"` call with no error code.
|
|
106
|
+
*/
|
|
107
|
+
friction?: AgentFriction;
|
|
52
108
|
serverName: string;
|
|
53
109
|
serverVersion: string;
|
|
54
110
|
deploymentId?: string;
|
|
@@ -121,6 +177,7 @@ export type ActivityEventInput = Pick<
|
|
|
121
177
|
| "durationMs"
|
|
122
178
|
| "attempts"
|
|
123
179
|
| "errorCode"
|
|
180
|
+
| "friction"
|
|
124
181
|
>;
|
|
125
182
|
|
|
126
183
|
/**
|
|
@@ -133,20 +190,24 @@ export function recordToolActivity(
|
|
|
133
190
|
input: ActivityEventInput,
|
|
134
191
|
): void {
|
|
135
192
|
if (!context) return;
|
|
193
|
+
// A caller-supplied class wins because it knows something the code table
|
|
194
|
+
// cannot: friction that belongs to a call which did not fail.
|
|
195
|
+
const friction = input.friction ?? agentFrictionForCode(input.errorCode);
|
|
136
196
|
const event: ToolCallActivityEvent = {
|
|
137
197
|
schemaVersion: 1,
|
|
138
198
|
id: crypto.randomUUID(),
|
|
139
199
|
occurredAt: new Date().toISOString(),
|
|
140
200
|
requestId: context.requestId,
|
|
141
201
|
actor: context.actor,
|
|
142
|
-
connectorId: input.connectorId,
|
|
143
|
-
toolName: input.toolName,
|
|
144
|
-
address: input.address,
|
|
202
|
+
connectorId: boundedEchoText(input.connectorId, MAX_ACTIVITY_NAME_BYTES),
|
|
203
|
+
toolName: boundedEchoText(input.toolName, MAX_ACTIVITY_NAME_BYTES),
|
|
204
|
+
address: boundedEchoText(input.address, MAX_ACTIVITY_ADDRESS_BYTES),
|
|
145
205
|
source: input.source,
|
|
146
206
|
outcome: input.outcome,
|
|
147
207
|
durationMs: Math.max(0, Math.trunc(input.durationMs)),
|
|
148
208
|
attempts: Math.max(1, Math.trunc(input.attempts)),
|
|
149
209
|
...(input.errorCode ? { errorCode: input.errorCode } : {}),
|
|
210
|
+
...(friction ? { friction } : {}),
|
|
150
211
|
serverName: context.serverInfo.name,
|
|
151
212
|
serverVersion: context.serverInfo.version,
|
|
152
213
|
...(context.deploymentId
|