@zackbart/connecta 0.11.0 → 0.12.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.
@@ -368,7 +368,9 @@ guest: admission rejection (`executor_overloaded`, retryable, with
368
368
  (`executor_closed`), deadline expiry, and sandbox crashes end the run and are
369
369
  reported to the model as an error result. One seam: a host call still in flight
370
370
  when the run is cancelled fails with `cancelled`, catchable on the way out but
371
- never worth acting on (`Y3`).
371
+ never worth acting on (`Y3`). When shutdown tears down a program that had
372
+ already started, accepted blocks and UI are reported as discarded under `M4`
373
+ and `U3`; a failure before execution started carries no discard fields.
372
374
 
373
375
  **E6.** An error the program raises itself — a `TypeError`, a call to a
374
376
  `connecta` member that is not a provider function (including an inherited one
@@ -487,6 +489,97 @@ response, and `emit` resolving means "accepted," never "delivered."
487
489
  aggregate — count and serialized bytes, numbers only (`R8`), present only
488
490
  when something was emitted.
489
491
 
492
+ ## Rendered output
493
+
494
+ `connecta.emit` gave programs pixels; it did not give them a *view*. This is the
495
+ one the human looks at directly while the model keeps its cheap textual summary:
496
+ one MCP Apps view per successful run, assembled where composition already
497
+ happens. Programs supply HTML content and nothing else — the only `ui://` URI in
498
+ the system is connecta's build-time shell, so nothing a client could dereference
499
+ is derived from anything a program said. The argument, the refused shapes, and
500
+ the security posture live in the [design record](./mcp-ui-design.md)
501
+ ([#266](https://github.com/zackbart/connecta/issues/266),
502
+ [#277](https://github.com/zackbart/connecta/issues/277)); this section is the
503
+ contract, and it wins where the two disagree.
504
+
505
+ **U1.** `connecta.ui(html)` accepts exactly one argument: a non-empty string of
506
+ HTML. A non-string, an empty string, an options bag, or an MCP block object
507
+ throws catchably and nothing is accepted. There is no options parameter and no
508
+ sugar form, for `M1`'s reason: sugar is how a one-shape contract grows hair.
509
+
510
+ **U2.** At most one payload per run. A second call throws catchably, naming the
511
+ constraint; the first accepted payload stands. One tool result renders one view,
512
+ and last-wins would silently discard a payload the program deliberately
513
+ supplied.
514
+
515
+ **U3.** Delivered on success only, and out of model context: the tool result
516
+ gains `_meta["connecta/ui"] = { html }` and the JSON envelope gains `ui: true`,
517
+ so the model learns a view rendered without seeing its bytes. `structuredContent`
518
+ stays the envelope alone. The single-label `connecta/ui` prefix is deliberate —
519
+ connecta has no domain to reverse, and fabricating one to satisfy MCP's
520
+ reverse-DNS SHOULD would be a worse answer than the shape the key format's MUST
521
+ already permits. A program that never calls `connecta.ui` produces the
522
+ byte-for-byte ordinary response (`R6`). A failed program delivers nothing and
523
+ reports `uiDiscarded: true` *only* when a payload had been accepted — a field on
524
+ the structured envelope, a trailing line on the plain-text paths — coexisting
525
+ with `emittedDiscarded: N` when one failure discards both.
526
+
527
+ **U4.** The payload spends the aggregate emit byte budget
528
+ (`ConnectaConfig.execute.maxEmittedBytes`), measured at the call as the
529
+ serialized bytes of `{ html }` — `M5`'s measurement. Over budget throws
530
+ catchably, naming the budget and the room remaining, with nothing partially
531
+ accepted. It spends no block count (`maxEmittedBlocks`: it is not a block) and no
532
+ host-call budget (`L4`). One transport bound covers everything rich a program
533
+ delivers.
534
+
535
+ **U5.** One static shell: a connecta-authored HTML5 document at
536
+ `ui://connecta/program-ui/v1`, mimeType `text/html;profile=mcp-app`, declared on
537
+ `execute_code` via `_meta.ui.resourceUri` together with an explicit
538
+ `_meta.ui.visibility: ["model"]` — the default `["model","app"]` would tell hosts
539
+ the view may call `execute_code`. A `resources/read` handler answers exactly that
540
+ URI and fails on any other; `resources/list` is served and returns an empty list.
541
+ The version segment bumps whenever the shell's bytes change, because hosts cache
542
+ templates by URI.
543
+
544
+ **U6.** The shell is display-only. It renders the payload in a nested iframe
545
+ (`srcdoc`, `sandbox="allow-scripts"`, no `allow-same-origin`) and declares no CSP
546
+ domains, so the host applies its restrictive default and the `about:srcdoc` frame
547
+ inherits `default-src 'none'; connect-src 'none'`. Program UI gets scripts and
548
+ local interactivity and nothing else: no network, no tool calls, no conversation
549
+ messages, no host-mediated links. The shell participates in the Apps lifecycle —
550
+ initialize, tool-result, size-changed, resource-teardown — and forwards no
551
+ channel whatsoever from the inner frame to the host. That isolation makes
552
+ program views fixed-height by construction: with no bridge there is no
553
+ content-height signal, the shell reports only its own box, and content taller
554
+ than that scrolls inside the inner frame rather than growing the view.
555
+
556
+ **U7.** Structural executor parity, per `M8`: `connecta.ui` is a provider
557
+ function, `ExecuteResult` and the `Executor` interface are unchanged, and both
558
+ executors get it through the bridge they already have.
559
+
560
+ **U8.** Request-local and unstreamed, per `M9`. The payload exists only in the
561
+ finished response, and `connecta.ui` resolving means "accepted," never
562
+ "rendered."
563
+
564
+ **U9.** `diagnostics: true` adds a distinct `ui` aggregate — the payload's byte
565
+ size, a number and nothing else (`R8`), present only when a payload was accepted.
566
+ UI bytes are not folded into `emitted`: that aggregate pairs a block count with
567
+ the bytes those blocks cost, and bytes without a block would desync the pair.
568
+
569
+ **U10.** `_meta.ui.resourceUri` is declared unconditionally. A host without the
570
+ extension ignores unknown `_meta` and sees the ordinary envelope, which *is* the
571
+ text fallback the Apps spec mandates; `connecta.ui` never fails because a client
572
+ cannot render. A stateless aggregator cannot reliably know, and connecta is not a
573
+ nanny.
574
+
575
+ **U11.** connecta declares `io.modelcontextprotocol/ui` in its server capability
576
+ declaration, and that is the one extension it advertises. The Apps extension must
577
+ be explicitly negotiated and a conforming client acts on one only when both sides
578
+ declare it, so without this declaration no host reads `_meta.ui.resourceUri`, no
579
+ host fetches the shell, and the whole design is inert. Reading the *client's*
580
+ declaration in order to register tool metadata conditionally stays refused
581
+ (`U10`), knowingly against a spec SHOULD.
582
+
490
583
  ## Retry semantics
491
584
 
492
585
  **Y1.** Connecta retries nothing beneath a program. `call_tool` accepts an
@@ -697,9 +790,12 @@ The middle three were places where the contract described behavior the code did
697
790
  not quite have. The code moved, because the described behavior is the one worth
698
791
  having.
699
792
 
700
- One surface was added since: [emitted output](#emitted-output) (`M1`–`M10`,
701
- [#270](https://github.com/zackbart/connecta/issues/270)) additive by
702
- construction, with the byte-for-byte no-emit promise pinned by test.
793
+ Two surfaces were added since, both additive by construction and each with its
794
+ byte-for-byte no-call promise pinned by test:
795
+ [emitted output](#emitted-output) (`M1`–`M10`,
796
+ [#270](https://github.com/zackbart/connecta/issues/270)) and
797
+ [rendered output](#rendered-output) (`U1`–`U11`,
798
+ [#277](https://github.com/zackbart/connecta/issues/277)).
703
799
 
704
800
  ## Verification
705
801
 
@@ -733,7 +829,7 @@ the upstream `Executor` shape assignable.
733
829
  | `E2`, `E8` | `test/guest-api-contract.test.ts` (code → `retryable`, batch and uncaught validation recovery), `test/meta-tools.test.ts` (direct, destructive, batch, provider fallback), `test/validate.test.ts` (bounded payload-free findings), `test/errors.test.ts` |
734
830
  | `E3` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (`auth_required`) |
735
831
  | `E4` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (destructive) |
736
- | `E5` | `test/guest-api-contract.test.ts` (execution-failure channel, in-flight `cancelled`), `test/execute.test.ts` (admission), `test/executor-admission.test.ts` |
832
+ | `E5` | `test/guest-api-contract.test.ts` (execution-failure channel, in-flight `cancelled`), `test/execute.test.ts` (admission), `test/executor-admission.test.ts`, `test/quickjs-executor.test.ts` (mid-run shutdown) |
737
833
  | `E6`, `X8` | `test/guest-api-contract.test.ts` (unknown and inherited members, wrapped-message precedence), `test/quickjs-executor.test.ts` |
738
834
  | `E7` | `test/guest-api-contract.test.ts` (refusals about a `503`-named connector), `test/errors.test.ts` |
739
835
  | `R1`, `R3` | `test/guest-api-contract.test.ts` (pass-through, truncation is success) |
@@ -753,11 +849,18 @@ the upstream `Executor` shape assignable.
753
849
  | `V1`–`V4` | `test/guest-api-contract.test.ts` (dispatched calls, every refusal class including an address no connector owns, the friction each derives, no event for the execution itself), `test/activity.test.ts` (the shared code → friction table, and the identity clamp) |
754
850
  | `M1` | `test/guest-api-contract.test.ts` (invalid emits throw catchably, accept nothing), `test/execute-emit.test.ts` (every rejected shape) |
755
851
  | `M2`, `M3` | `test/guest-api-contract.test.ts` (delivery order, truncated return plus delivered blocks), `test/execute-emit.test.ts` (envelope, `structuredContent`, byte-for-byte no-emit path) |
756
- | `M4` | `test/guest-api-contract.test.ts` (discard is visible), `test/execute-emit.test.ts` (structured and plain paths) |
852
+ | `M4` | `test/guest-api-contract.test.ts` (discard is visible), `test/execute-emit.test.ts` (structured and plain paths), `test/quickjs-executor.test.ts` (mid-run shutdown) |
757
853
  | `M5`, `M7` | `test/execute-emit.test.ts` (both budgets fail the crossing block; host-call budget untouched) |
758
854
  | `M6`, `M9` | verdicts; `M1`'s strict typing and `M2`'s collect-then-deliver are their enforcement |
759
855
  | `M8` | two arms passing one case table, `test/codemode-compat.test.ts` |
760
856
  | `M10` | `test/execute-emit.test.ts` (aggregate present, numbers only, absent when nothing emitted) |
857
+ | `U1`, `U2` | `test/guest-api-contract.test.ts` (invalid and repeated calls throw catchably, first payload stands), `test/execute-ui.test.ts` (every rejected shape) |
858
+ | `U3` | `test/guest-api-contract.test.ts` (`_meta` payload and `ui: true`, identical on both executors), `test/execute-ui.test.ts` (`structuredContent`, byte-for-byte no-call path, discard structured and plain, coexistence with `emittedDiscarded`), `test/quickjs-executor.test.ts` (mid-run shutdown) |
859
+ | `U4` | `test/execute-ui.test.ts` (one shared byte aggregate crossed in either order; block count and host-call budget untouched) |
860
+ | `U5`, `U10`, `U11` | `test/server.test.ts` (the shell URI, mimeType, and body; every other URI fails; empty listing; `execute_code`'s `_meta.ui`; exactly one declared extension) |
861
+ | `U6` | `test/execute-ui.test.ts` (valid HTML5, `srcdoc` and sandbox attributes, no `allow-same-origin`, no path from the inner frame to the host) |
862
+ | `U7`, `U8` | two arms passing one case table, `test/codemode-compat.test.ts` |
863
+ | `U9` | `test/execute-ui.test.ts` (a `ui` byte aggregate distinct from `emitted`, absent when nothing was accepted) |
761
864
  | `X3` | `test/quickjs-executor.test.ts` (cancels a running child) |
762
865
  | `X4` | `test/guest-api-contract.test.ts` (string logs only) |
763
866
  | `X6` | `test/quickjs-executor.test.ts` (never-settling await) |
@@ -17,7 +17,7 @@ deliberately gated. The migration is tracked in
17
17
  | 8 | `ttlMs` and `cacheScope` on cacheable lists | adopt | `tools/list` returns `ttlMs: 3_600_000` and `cacheScope: "private"`. |
18
18
  | 9 | Honor downstream list cache hints | gated | Keep the fixed five-minute TTL plus fingerprint until refresh-churn evidence warrants complexity ([#206](https://github.com/zackbart/connecta/issues/206)). |
19
19
  | 10 | Native Tasks extension | refuse | Tasks address duration, while `get_result` addresses response size. Polling plus paging would add round trips without replacing the stash. |
20
- | 11 | Extensions capability framework | decline | No client consumes a Connecta extension declaration. |
20
+ | 11 | Extensions capability framework | decline | Decline as a general surface. The one extension Connecta declares is `io.modelcontextprotocol/ui`, which the MCP Apps extension requires before a host will use the program-UI shell template at all ([#266](https://github.com/zackbart/connecta/issues/266)). |
21
21
  | 12 | `Mcp-Method` and `Mcp-Name` POST headers | adopt | SDK v2 handles the headers; CORS allows `mcp-method, mcp-name`. |
22
22
  | 13 | RFC 9207 `iss` validation and `application_type` | adopt via SDK | The callback preserves all query parameters for SDK validation. |
23
23
  | 14 | Credentials keyed to issuer; re-register when AS changes | adopt | Version-2 credential envelopes store issuer; mismatch advances the generation epoch and invalidates registration and tokens. |
@@ -0,0 +1,382 @@
1
+ # MCP UI from programs — design record
2
+
3
+ The decision record for
4
+ [#266](https://github.com/zackbart/connecta/issues/266): whether and how an
5
+ `execute_code` program may deliver a *rendered view* to the client, and what a
6
+ program is allowed to hand the host in order to get one. The verdicts live in
7
+ [`ethos.md`](../ethos.md); this document carries the argument, the contract
8
+ precisely enough to implement, and the shapes that were considered, refused, or
9
+ gated.
10
+
11
+ It extends the [rich-output design record](./rich-output-design.md)
12
+ ([#267](https://github.com/zackbart/connecta/issues/267)) rather than restating
13
+ it. That record's contract clauses (`M1`–`M10`, now normative in
14
+ [`code-mode.md`](./code-mode.md)'s "Emitted output" section) are cited here,
15
+ not repeated; where the two overlap — provider-bridge delivery, budgets,
16
+ success-only delivery, payload-free diagnostics — this record follows the
17
+ precedent instead of inventing a parallel one. The implementation landed via
18
+ [#277](https://github.com/zackbart/connecta/issues/277): the clauses below
19
+ (`U1`–`U11`) are now normative in [`code-mode.md`](./code-mode.md)'s "Rendered
20
+ output" section, which wins where the two disagree. This document remains the
21
+ argument and the record of rejected shapes.
22
+
23
+ ## The problem
24
+
25
+ `connecta.emit` gave programs pixels. It did not give them a *view*. A program
26
+ that assembles a comparison table, a chart, or a diffed record can emit it as
27
+ text the model re-reads, or as an image it can no longer inspect; what it
28
+ cannot do is hand the client something the human looks at directly while the
29
+ model keeps its cheap textual summary. That is a real gap for the surface
30
+ connecta calls primary: the program is where composition already happens, and
31
+ composed output is exactly the output worth rendering.
32
+
33
+ MCP grew an answer to this, and the ethos anticipated it. The gated row for
34
+ guest-emitted `resource` / `resource_link` blocks reserved MCP UI as the one
35
+ possible carve-out, with the constraint written in advance: *programs supply
36
+ only content, connecta mints the `ui://` address outside the sandbox.* The
37
+ lure defense is the whole reason for the gate — a guest-minted URI is an
38
+ address a helpful client may dereference, and generated code mints nothing.
39
+
40
+ ### The spec moved
41
+
42
+ The gate was drafted against the shape mcp-ui had at the time: the server mints
43
+ a per-request `ui://` URI and embeds a `UIResource` block inline in the tool
44
+ result. That shape is now the *legacy* path, explicitly superseded upstream and
45
+ still rendered by hosts that also support the official path — Goose, Postman,
46
+ MCPJam — plus a few that do not: Nanobot, LibreChat. The claim that matters is
47
+ narrower and unaffected: Claude.ai, ChatGPT, and VS Code Copilot render only
48
+ the official shape.
49
+
50
+ The official replacement is the **MCP Apps extension**
51
+ (`io.modelcontextprotocol/ui`, launched 2026-01-26,
52
+ [specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx),
53
+ [overview](https://modelcontextprotocol.io/extensions/apps/overview)), and it
54
+ inverts the delivery model in a way that happens to suit a stateless
55
+ aggregator:
56
+
57
+ - A tool **pre-declares a static UI template** through tool metadata,
58
+ `_meta.ui.resourceUri`, pointing at a `ui://` URI. (The flat form
59
+ `_meta["ui/resourceUri"]` is deprecated.)
60
+ - The template's mimeType MUST be `text/html;profile=mcp-app` and its body MUST
61
+ be a valid HTML5 document. External URLs (`text/uri-list`) are the one type on
62
+ the spec's deferred list; remote-DOM was never adopted into the official spec
63
+ at all.
64
+ - The host MUST use `resources/read` to fetch the referenced URI, and MAY
65
+ prefetch and cache it. A permission the spec hands hosts is a permission to
66
+ assume they use, so a template change means a new URI — version the address.
67
+ - Servers MAY omit UI-only resources from `resources/list` entirely — the
68
+ omission is from the *listing*, not from the method. A handler that answers
69
+ exactly one static URI and lists nothing needs no state: the URI reaches the
70
+ host through tool metadata, so the listing has nothing to carry and there is
71
+ no session to keep.
72
+ - Per-call data reaches the rendered view over postMessage: the host delivers
73
+ the full tool result to the view via `ui/notifications/tool-result`. Result
74
+ `_meta` is delivered to the view and, per the spec's best practices, is data
75
+ "not intended for model context" — the established Apps convention, and how
76
+ ChatGPT's Apps SDK behaves too. It is guidance, not a MUST.
77
+ - Rendering is sandboxed by the host. Hosts MUST render in sandboxed iframes,
78
+ and a template that declares no CSP domains gets the host's restrictive
79
+ default: `default-src 'none'`, `connect-src 'none'`, `frame-src 'none'`. An
80
+ undeclared template has no network, and no nested frame it could load by URL.
81
+ - The extension MUST be explicitly negotiated. Servers declare it in their
82
+ capability declaration, and per the client matrix a client acts on an
83
+ extension only when both sides have declared it.
84
+ - Support for the official shape spans Claude.ai, Claude Desktop, ChatGPT, VS
85
+ Code Copilot, M365 Copilot, Goose, Postman, and Cursor, among others
86
+ ([client matrix](https://modelcontextprotocol.io/extensions/client-matrix)).
87
+
88
+ So the gate's premise dissolved on both ends. The shape it envisioned is the
89
+ one nobody renders, and the shape everyone renders does not require connecta to
90
+ mint anything per request — which turns out to be a *stricter* posture than the
91
+ gate asked for, not a looser one.
92
+
93
+ ## The shape: `connecta.ui(html)` and one static shell
94
+
95
+ One new provider function, `connecta.ui(html)`, plus one static, connecta-
96
+ authored MCP Apps shell template. The program supplies HTML content and nothing
97
+ else. The only `ui://` URI anywhere in the system is connecta's shell, minted
98
+ at build time and identical for every deployment and every request; the
99
+ program's HTML never becomes a URI at all. It rides the tool result in `_meta`,
100
+ which hosts keep out of model context, and the shell renders it inside the
101
+ client's sandboxed iframe.
102
+
103
+ That is the whole design, and it satisfies the gate's constraint more strongly
104
+ than the gate's own sketch did. The gate would have allowed connecta to mint an
105
+ address per request from program-supplied bytes; here the program's content
106
+ never becomes addressable at all. Nothing a client could dereference is derived
107
+ from anything a program said.
108
+
109
+ Two structural consequences follow, both borrowed rather than invented. First,
110
+ `connecta.ui` is a provider function in the same `fns` object as
111
+ `connecta.emit`, so `ExecuteResult` stays `{ result, error?, logs? }` and the
112
+ `Executor` contract is untouched — the parity guarantee `M8` describes applies
113
+ verbatim. Second, the shell ships as a build-time string constant in core, not
114
+ a file read at startup: import-graph purity means no `node:fs`, and the same
115
+ bytes have to serve on Workers.
116
+
117
+ ### Contract (drafts for code-mode.md)
118
+
119
+ **U1.** `connecta.ui(html)` accepts exactly one argument: a non-empty string of
120
+ HTML. Anything else — a non-string, an empty string, an options bag, an MCP
121
+ block object — throws a catchable error and nothing is accepted. There is no
122
+ options parameter and no sugar form, for `M1`'s reason: sugar is how a
123
+ one-shape contract grows hair.
124
+
125
+ **U2.** At most one payload per run. A second `connecta.ui` call throws
126
+ catchably, naming the constraint; the first accepted payload stands. One tool
127
+ result renders one view, and last-wins would silently discard a payload the
128
+ program deliberately supplied.
129
+
130
+ **U3.** The payload is delivered on success only, and out of model context: the
131
+ tool result gains `_meta["connecta/ui"] = { html }`, and the JSON envelope
132
+ gains `ui: true` so the model learns a view rendered without seeing its bytes.
133
+ `_meta` is where the Apps spec's best practices put data "not intended for
134
+ model context", and it is how shipped hosts behave — guidance plus observed
135
+ behavior, not a protocol property; nothing in the spec makes it a MUST.
136
+ `structuredContent` remains the envelope alone. The wire key is a plain
137
+ single-label prefix rather than the reverse-DNS form MCP's SHOULD prefers, and
138
+ that is a choice: connecta has no domain to reverse, and fabricating one to
139
+ satisfy a SHOULD would be a worse answer than the single-label prefix the key
140
+ format's MUST already permits. A program that never calls `connecta.ui`
141
+ produces today's byte-for-byte response (`R6`). A program that ends in an error
142
+ delivers no payload; the error envelope reports `uiDiscarded: true` only when a
143
+ payload had been accepted — a field on the structured envelope, a trailing line
144
+ on the plain-text paths — and it coexists with `emittedDiscarded: N` when one
145
+ failure discards both.
146
+
147
+ **U4.** The payload spends the existing aggregate emit byte budget
148
+ (`ConnectaConfig.execute.maxEmittedBytes`, default 4,000,000), measured at the
149
+ call as the serialized bytes of `{ html }` — the same measurement `M5` applies
150
+ to a block. Over budget throws catchably, naming the budget and the room
151
+ remaining, with nothing partially accepted. It does not spend the block-count
152
+ budget (`maxEmittedBlocks`) — it is not a block — and it does not spend the
153
+ host-call budget (`L4`). One transport bound covers everything rich a program
154
+ delivers; no new knob.
155
+
156
+ **U5.** One static shell: a connecta-authored HTML5 document at
157
+ `ui://connecta/program-ui/v1`, mimeType `text/html;profile=mcp-app`, declared
158
+ on `execute_code` via `_meta.ui.resourceUri` together with an explicit
159
+ `_meta.ui.visibility: ["model"]`, and served by a `resources/read` handler that
160
+ answers exactly that URI and fails on any other. The handler registers on the
161
+ `McpServer` in `src/routes/mcp.ts`, which is what puts the `resources`
162
+ capability into the discovery response — so `resources/list` is served too, and
163
+ returns an empty list. That is the Apps spec's permitted omission of UI-only
164
+ resources from listing, taken exactly: the capability stays honest because the
165
+ method answers, and nothing downstream is ever listed or aggregated.
166
+ `visibility` is declared rather than left to default because the default
167
+ `["model","app"]` would tell hosts the view may call `execute_code` over
168
+ `tools/call` — the thing the view-initiated-calls gate defers. The version
169
+ segment bumps whenever the shell's bytes change, because hosts cache templates
170
+ by URI.
171
+
172
+ **U6.** The shell is display-only. It renders the payload in a nested iframe
173
+ (`srcdoc`, `sandbox="allow-scripts"`, no `allow-same-origin`) and declares no
174
+ CSP domains, so the host applies its restrictive default. That default includes
175
+ `frame-src 'none'`, which forbids nested frames loaded from a *URL* — which is
176
+ exactly why the payload frame is `srcdoc` and not an address. `about:srcdoc` is
177
+ not matched by `frame-src`; it inherits the embedding document's policy
178
+ instead. So the inner frame is *permitted* by the documented behavior of the
179
+ directive that would otherwise forbid it, and *offline* by the same
180
+ inheritance: it gets the shell's `default-src 'none'; connect-src 'none'`.
181
+ Program UI therefore gets scripts and local interactivity and nothing else: no
182
+ network, no tool calls, no conversation messages, no host-mediated links. The
183
+ shell participates in the Apps lifecycle — initialize, tool-result,
184
+ size-changed — and forwards no channel whatsoever from the inner frame to the
185
+ host.
186
+
187
+ **U7.** Structural executor parity, per `M8`. `connecta.ui` is a provider
188
+ function; `ExecuteResult` and the `Executor` interface are unchanged; QuickJS
189
+ and the Dynamic Worker get it through the bridge they already have, without
190
+ modification.
191
+
192
+ **U8.** Request-local and unstreamed, per `M9`. The payload exists only in the
193
+ finished response, and `connecta.ui` resolving means "accepted," never
194
+ "rendered."
195
+
196
+ **U9.** With `diagnostics: true` the diagnostics block gains a distinct `ui`
197
+ aggregate carrying the payload's byte size — a number, nothing else — present
198
+ only when a payload was accepted. UI bytes are not folded into the `emitted`
199
+ aggregate `M10` describes: that one pairs a block count with the bytes those
200
+ blocks cost, and adding bytes without a block would desync the pair. Activity
201
+ stays payload-free by construction (`R8`, `M10`).
202
+
203
+ **U10.** `_meta.ui.resourceUri` is declared on `execute_code` unconditionally.
204
+ A host without the extension ignores unknown `_meta` and sees the ordinary
205
+ envelope, which *is* the text fallback the Apps spec mandates. `connecta.ui`
206
+ never fails because a client cannot render: a stateless aggregator cannot
207
+ reliably know, and connecta is not a nanny.
208
+
209
+ **U11.** connecta declares `io.modelcontextprotocol/ui` in its server
210
+ capability declaration — `capabilities.extensions` on the `initialize` path,
211
+ the `server/discover` response on the 2026-07-28 one — and that is the one
212
+ extension it advertises. The Apps extension must be explicitly negotiated, and
213
+ a conforming client acts on an extension only when both sides declare it: with
214
+ no declaration from connecta, no host reads `_meta.ui.resourceUri`, no host
215
+ fetches the shell, and the whole design is inert. The declaration is part of
216
+ the contract, not an implementation option.
217
+
218
+ Negotiation runs in two directions, and this design takes one and refuses the
219
+ other. **Declaring** is required — `U11` — and it is what makes the
220
+ [revision inventory](./mcp-2026-07-28.md)'s decline of the versioned extensions
221
+ framework a decline of that framework *as a general surface*, not a blanket
222
+ refusal to name an extension. **Reading the client's** declaration in order to
223
+ register tool metadata conditionally stays refused ("Capability-conditional
224
+ tool metadata", below), and that refusal declines a spec SHOULD — servers
225
+ SHOULD check client capabilities before registering UI-enabled tools — which is
226
+ worth saying out loud rather than eliding. A per-request stateless aggregator
227
+ has no dependable place to hold the check; unknown `_meta` is ignored by spec;
228
+ and `U10`'s envelope is already the fallback the spec mandates. The SHOULD
229
+ would buy a guarantee the protocol gives away.
230
+
231
+ ## Sizing rationale
232
+
233
+ The [rich-output record](./rich-output-design.md#sizing-rationale) draws the
234
+ line that matters here: the 24,000-character return bound is a *context*
235
+ budget, and the emit byte budget is a *transport* bound. A UI payload is
236
+ transport in the purest form yet — it rides `_meta`, which hosts deliver to the
237
+ view and, following the spec's best-practice guidance, keep out of the model's
238
+ window. On a host that behaves that way, the `ui: true` marker is the entire
239
+ context cost of a rendered view: one boolean.
240
+
241
+ Which is why it does not get its own budget. The reason `emit` has one is
242
+ transport, not context, and a UI payload competes for the same wire; giving
243
+ it a second knob would ask operators to reason about a distinction that does
244
+ not exist downstream of the response body. It spends the aggregate and leaves
245
+ the block count alone, because a payload that is not a block should not consume
246
+ a block.
247
+
248
+ ## Security posture
249
+
250
+ - **Nothing is minted — more strictly than the gate demanded.** The gate would
251
+ have permitted connecta to mint a per-request URI over program bytes. Here
252
+ the program's content never becomes an addressable resource at all. The one
253
+ `ui://` URI in the system is a build-time constant with no program input in
254
+ it, so there is no address a program can influence and nothing for a client
255
+ to dereference on a program's behalf.
256
+ - **No bridge means no capability path.** `U6`'s shell forwards nothing from
257
+ the inner frame to the host. A program-authored UI cannot call tools, reach
258
+ the network, post conversation messages, or open host-mediated links, so
259
+ hostile emitted HTML is inert beyond its own pixels. The invariant holds by
260
+ construction rather than by validation: there is no channel to abuse.
261
+ - **The restrictive default CSP is the offline guarantee, and the inner frame
262
+ lives inside it rather than around it.** Declaring no CSP domains means
263
+ `default-src 'none'`, `connect-src 'none'`, and `frame-src 'none'` — that
264
+ last directive forbids the nested frame the design depends on if the frame
265
+ has a URL, which is why it has none. `about:srcdoc` is not matched by
266
+ `frame-src` and inherits the embedding document's policy, so the payload
267
+ frame is permitted and offline by the same rule. That is a dependency on
268
+ documented behavior, not something connecta enforces: hosts MAY tighten
269
+ further, and MAY override the inner `sandbox` attribute, either of which
270
+ could close the gap the shell rides through. If one does, the `v1` segment in
271
+ the shell URI is the escape hatch — a new shell at a new address is a version
272
+ bump, not a redesign. Program UI is offline because the host makes it
273
+ offline, not because connecta scanned the HTML for `fetch`.
274
+ - **Model-invisibility is host behavior, not a protocol guarantee.** The spec's
275
+ only statement on it is a non-normative best practice — `_meta` carries data
276
+ "not intended for model context" — with no MUST and no enforcement. Shipped
277
+ hosts honor it. A host that does not receives up to the full byte budget of
278
+ program-authored HTML as model context, which is an expensive turn rather
279
+ than a capability leak, but it is the honest failure mode of `U3` and the
280
+ reason the sizing argument rests on transport rather than on a promise of
281
+ free context.
282
+ - **HTML-borne injection is the existing class.** Hostile text inside a
283
+ rendered view is the same hazard as hostile text inside an emitted
284
+ screenshot (`M6`, and `call_tool`'s block passthrough before it). All program
285
+ output is untrusted input to the client; connecta claims no provenance and
286
+ adds no claim otherwise.
287
+ - **The lure defense stands.** Guest emission of `resource` and
288
+ `resource_link` blocks becomes refused — it was gated on exactly this
289
+ carve-out, and the carve-out did not need it. This design is not the
290
+ exception that opens that door; it is the demonstration that the door was not
291
+ needed.
292
+
293
+ ## Considered, refused, and gated
294
+
295
+ **Legacy embedded `UIResource` delivery** (a per-request minted `ui://` URI
296
+ plus an inline resource block). Refused. It is superseded upstream and
297
+ unrendered by Claude.ai, ChatGPT, and VS Code Copilot — the clients connecta
298
+ deployments actually face — so it buys reach only with hosts that also support
299
+ the official path. It also fights the caching model: per-request URIs are
300
+ precisely what hosts are told they may prefetch and cache by URI. Building the
301
+ carve-out the gate imagined would mean minting addresses from guest content in
302
+ order to reach fewer clients.
303
+
304
+ **Extending `connecta.emit` with a `{ type: "ui", html }` shape.** Refused.
305
+ `M1` would need an asterisk (not an MCP block type), `M2` another (not appended
306
+ to `content`, delivered through `_meta` instead), and `M5` a third (its own
307
+ multiplicity, its own share of the budgets). Three exceptions to a settled
308
+ contract to avoid one new function is a bad trade; a separate function leaves
309
+ `M1`–`M10` exactly as they are.
310
+
311
+ **Guest-supplied external URL frames** (`text/uri-list`). Refused. A
312
+ guest-supplied URL is precisely the lure the gate exists to prevent — a program
313
+ choosing what the client loads is a program minting an address by another
314
+ route. The official spec deferred the type anyway, so the refusal costs
315
+ nothing today and would cost the whole posture tomorrow.
316
+
317
+ **Remote-DOM payloads.** Refused. Legacy-path-only, and never adopted into the
318
+ official spec at all — it survives there as background prose, not as a deferred
319
+ item with a queue position. Adopting a payload format the official spec never
320
+ took, on the path the official spec superseded, is two bets on one square.
321
+
322
+ **View-initiated tool calls from program UI.** Gated, not refused. The
323
+ host-mediated `tools/call` path exists in the Apps spec and would go through
324
+ the same admission, annotation, and consent path as any other call, so the
325
+ safety story is not the blocker. What is missing is the argument: a
326
+ program-authored UI that drives tools needs its own use case and its own
327
+ consent story, and neither exists yet. Display-only until one arrives, and
328
+ gated twice over: `U5` declares `_meta.ui.visibility: ["model"]` so no host is
329
+ ever told the view may call, and `U6`'s shell has no bridge to call with.
330
+
331
+ **Downstream MCP Apps template passthrough** (downstream connectors declaring
332
+ their own `ui://` templates, proxied through connecta's `resources/read`).
333
+ Gated. It is a coherent shape, and it earns its way in when a downstream
334
+ connector actually ships Apps templates. Until then it would be aggregation
335
+ machinery for a population of zero — and `U5`'s narrow handler is deliberately
336
+ narrow so that widening it later is a decision, not a diff.
337
+
338
+ **Delivering UI from failed programs.** Refused, for `M4`'s argument: a view
339
+ assembled before the failure may describe a world the error contradicts.
340
+ Failure delivers the error, the logs, and an honest `uiDiscarded: true`.
341
+
342
+ **Capability-conditional tool metadata** (declaring `_meta.ui.resourceUri` only
343
+ when the client negotiated the extension). Refused, and knowingly against a
344
+ spec SHOULD. Per-request statelessness makes the check unreliable, unknown
345
+ `_meta` is ignored by spec, and the JSON envelope is already the fallback the
346
+ Apps spec mandates. Conditional metadata would add a negotiation dependency to
347
+ buy a guarantee the protocol already gives away.
348
+
349
+ ## Verification sketch
350
+
351
+ - Parity: one `connecta.ui` program through both vitest projects, identical
352
+ `_meta` payload and envelope.
353
+ - Validation: non-string, empty string, options bag, and a second call each
354
+ throw catchably and accept nothing.
355
+ - Budget: an over-budget payload fails at the call, naming the budget; emitted
356
+ blocks and the UI payload draw on one aggregate.
357
+ - Byte-for-byte: a program that never calls `connecta.ui` produces today's
358
+ exact response.
359
+ - Discard: a throwing program that had an accepted payload yields the error
360
+ envelope with `uiDiscarded: true` and no `_meta` payload; a throwing program
361
+ that never called `connecta.ui` reports no `uiDiscarded` at all.
362
+ - Diagnostics: an accepted payload adds a `ui` byte aggregate distinct from
363
+ `emitted`, and no aggregate appears when nothing was accepted.
364
+ - Independence: a truncated return value, emitted blocks, and a UI payload
365
+ coexist in one response.
366
+ - `resources/read` serves exactly the shell URI with the correct mimeType and a
367
+ valid HTML5 body, and fails on every other URI; `resources/list` is served
368
+ and returns an empty list.
369
+ - The server capability declaration carries exactly one extension identifier,
370
+ `io.modelcontextprotocol/ui`.
371
+ - `execute_code`'s metadata declares `_meta.ui.resourceUri` pointing at the
372
+ shell URI and `_meta.ui.visibility: ["model"]`.
373
+ - The shell renders payload HTML in a sandboxed inner frame and exposes no
374
+ bridge — asserted however the implementation can, at minimum that the shell
375
+ source carries the `srcdoc` and sandbox attributes and contains no
376
+ message-forwarding path from the inner frame to the host.
377
+ - `U1`–`U11` fold into `code-mode.md`, the `execute_code` description documents
378
+ `connecta.ui`, and the test-map row lands in
379
+ [`operations.md`](./operations.md) with that guide.
380
+ - `npm run check` passes.
381
+
382
+ The implementation issue (#277) carries these as acceptance criteria.
package/ethos.md CHANGED
@@ -67,7 +67,7 @@ proposing one without a new argument is not.
67
67
  | Policy engine, approvals, pauses | refused | the host asks the human; connecta only annotates |
68
68
  | Runtime connector registration | refused | config-as-code is the security model |
69
69
  | Protocol sessions & server push | refused | stateless per request |
70
- | Resources & prompts aggregation | refused | tools only |
70
+ | Resources & prompts aggregation | refused | tools only; connecta's own Apps shell is the one `resources/read` carve-out ([#266](https://github.com/zackbart/connecta/issues/266)) |
71
71
  | Elicitation passthrough | refused | no route through a stateless aggregator |
72
72
  | Repository formatter | refused | style is authored, not enforced |
73
73
  | Toolkits (scoped views) | removed | never earned its keep; deploy per audience ([#178](https://github.com/zackbart/connecta/issues/178)) |
@@ -90,8 +90,13 @@ proposing one without a new argument is not.
90
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
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
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)) |
93
+ | Guest-emitted `resource` / `resource_link` blocks | refused | a program can never mint a URI a client may dereference, and the carve-out this row waited on does not need one: `connecta.ui` takes HTML content, and the only `ui://` URI is connecta's build-time shell ([design record](./documentation/mcp-ui-design.md), [#266](https://github.com/zackbart/connecta/issues/266), [#267](https://github.com/zackbart/connecta/issues/267)) |
94
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)) |
95
+ | Program-generated UI (`connecta.ui` + the Apps shell) | accepted | one MCP Apps view per successful run: the program supplies HTML only, delivered in result `_meta`, which hosts keep out of model context, and rendered by connecta's static shell inside the host's sandboxed frame ([design record](./documentation/mcp-ui-design.md), [#266](https://github.com/zackbart/connecta/issues/266)) |
96
+ | Serving connecta's own UI template via `resources/read` | accepted | a narrow carve-out from the resources-aggregation refusal, not a reversal of it: one static build-time shell at one URI, an empty `resources/list`, nothing downstream ever listed or aggregated ([#266](https://github.com/zackbart/connecta/issues/266)) |
97
+ | Downstream MCP Apps template passthrough | gated | proxying downstream `resources/read` earns its way in when a downstream connector actually ships an Apps template ([#266](https://github.com/zackbart/connecta/issues/266)) |
98
+ | View-initiated tool calls from program UI | gated | the host-mediated path exists and would take the ordinary audit and consent route, but a program-authored UI driving tools needs its own argument; display-only until one arrives ([#266](https://github.com/zackbart/connecta/issues/266)) |
99
+ | Legacy embedded `UIResource` delivery | refused | superseded upstream and rendered by none of the clients connecta faces; per-request minted URIs also fight the caching the Apps spec assumes ([#266](https://github.com/zackbart/connecta/issues/266)) |
95
100
 
96
101
  ## Invariants
97
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zackbart/connecta",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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.",