@zackbart/connecta 0.12.1 → 0.12.2

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/src/apps-shell.ts CHANGED
@@ -3,17 +3,17 @@
3
3
  *
4
4
  * A build-time string constant, not a file read at startup: the core is
5
5
  * Web-API-only so it runs unchanged on Workers, and the same bytes have to
6
- * serve everywhere. The shell is display-only — it renders whatever HTML a
7
- * program handed `connecta.ui` inside a nested `srcdoc` frame and forwards no
8
- * channel back from that frame to the host, so program-authored markup is
9
- * inert beyond its own pixels.
6
+ * serve everywhere. It renders whatever HTML a program handed `connecta.ui`
7
+ * inside a nested `srcdoc` frame. The one-argument form forwards no channel;
8
+ * an explicitly bound view gets only named read calls through the trusted
9
+ * shell, never a raw host channel.
10
10
  *
11
11
  * The address carries a version segment because hosts are permitted to
12
- * prefetch and cache templates by URI: change these bytes, bump `v1`.
12
+ * prefetch and cache templates by URI: change these bytes, bump the version.
13
13
  */
14
14
 
15
15
  /** The only `ui://` URI in the system. No program input reaches it. */
16
- export const PROGRAM_UI_RESOURCE_URI = "ui://connecta/program-ui/v1";
16
+ export const PROGRAM_UI_RESOURCE_URI = "ui://connecta/program-ui/v2";
17
17
 
18
18
  /** The mimeType the Apps spec requires of an HTML template. */
19
19
  export const PROGRAM_UI_MIME_TYPE = "text/html;profile=mcp-app";
@@ -34,10 +34,12 @@ export const MCP_APPS_EXTENSION = "io.modelcontextprotocol/ui";
34
34
  * Apps postMessage dialect (`ui/initialize`, `ui/notifications/initialized`,
35
35
  * `ui/notifications/tool-result`, `ui/notifications/size-changed`,
36
36
  * `ui/resource-teardown`), lifts `_meta["connecta/ui"].html` out of the
37
- * delivered tool result, and puts it in a frame. It declares no CSP domains,
38
- * so the host applies its restrictive default and the `srcdoc` frame inherits
39
- * `default-src 'none'` the payload gets scripts and local interactivity,
40
- * and no network.
37
+ * delivered tool result, and puts it in a frame. An optional read manifest
38
+ * installs one narrow `connecta.read(name, args)` bridge in that inner frame;
39
+ * the outer shell maps declared names to the existing `call_tool` meta-tool.
40
+ * It declares no CSP domains, so the host applies its restrictive default and
41
+ * the `srcdoc` frame inherits `default-src 'none'` — program markup still gets
42
+ * no direct network.
41
43
  */
42
44
  export const PROGRAM_UI_SHELL_HTML = `<!doctype html>
43
45
  <html lang="en">
@@ -70,17 +72,21 @@ export const PROGRAM_UI_SHELL_HTML = `<!doctype html>
70
72
  <script>
71
73
  (function () {
72
74
  "use strict";
73
- // The host frame is the only peer this shell speaks to, in either
74
- // direction. The payload frame below is sandboxed to scripts alone,
75
- // with no same-origin escape, and is never handed a reply path:
76
- // anything it posts fails the source check and is dropped. There is
77
- // no bridge from program HTML to the host, by construction rather
78
- // than by validation.
75
+ // The outer shell is the only host peer. The payload frame is
76
+ // sandboxed to scripts alone, with no same-origin escape. Its one
77
+ // optional message dialect is handled below and translated into
78
+ // bounded call_tool requests; raw JSON-RPC is never forwarded.
79
79
  var host = window.parent;
80
80
  var view = document.getElementById("program-view");
81
81
  var initializeId = "connecta-ui-initialize";
82
82
  var lastWidth = 0;
83
83
  var lastHeight = 0;
84
+ var reads = null;
85
+ var hostCanCallTools = false;
86
+ var nextHostRequestId = 0;
87
+ var pendingHostReads = Object.create(null);
88
+ var activeHostReads = 0;
89
+ var maxActiveHostReads = 8;
84
90
 
85
91
  function send(message) {
86
92
  if (!host || host === window) return;
@@ -92,8 +98,8 @@ export const PROGRAM_UI_SHELL_HTML = `<!doctype html>
92
98
  }
93
99
 
94
100
  // Program views are fixed-height by construction. The shell has no
95
- // bridge to the payload frame that is the security posture, not an
96
- // omission — so it can never learn the payload's content height, and
101
+ // content-height bridge to the payload frame, so it can never learn
102
+ // the payload's content height, and
97
103
  // what it reports here is its own box: the min-height above, unless
98
104
  // the host has given it more. Taller content scrolls inside the inner
99
105
  // frame rather than growing the view. Raising the min-height is the
@@ -110,30 +116,213 @@ export const PROGRAM_UI_SHELL_HTML = `<!doctype html>
110
116
  });
111
117
  }
112
118
 
113
- function payloadHtml(result) {
119
+ function payload(result) {
114
120
  if (!result || typeof result !== "object") return null;
115
121
  var meta = result._meta;
116
122
  if (!meta || typeof meta !== "object") return null;
117
- var payload = meta["connecta/ui"];
118
- if (!payload || typeof payload !== "object") return null;
119
- var html = payload.html;
120
- return typeof html === "string" && html.length > 0 ? html : null;
123
+ var value = meta["connecta/ui"];
124
+ if (!value || typeof value !== "object") return null;
125
+ return typeof value.html === "string" && value.html.length > 0
126
+ ? value
127
+ : null;
128
+ }
129
+
130
+ // This function is serialized into the opaque-origin payload frame.
131
+ // It knows no addresses and has no host channel of its own: one named
132
+ // read request goes to the trusted outer shell and one correlated
133
+ // result comes back.
134
+ function payloadReadBridge() {
135
+ "use strict";
136
+ var pending = Object.create(null);
137
+ var nextId = 0;
138
+
139
+ function read(name, args) {
140
+ return new Promise(function (resolve, reject) {
141
+ var id = String(++nextId);
142
+ pending[id] = { resolve: resolve, reject: reject };
143
+ try {
144
+ window.parent.postMessage({
145
+ type: "connecta/read",
146
+ id: id,
147
+ name: name,
148
+ args: args === undefined ? {} : args
149
+ }, "*");
150
+ } catch (error) {
151
+ delete pending[id];
152
+ reject(error);
153
+ }
154
+ });
155
+ }
156
+
157
+ Object.defineProperty(globalThis, "connecta", {
158
+ value: Object.freeze({ read: read }),
159
+ configurable: false,
160
+ enumerable: true,
161
+ writable: false
162
+ });
163
+
164
+ window.addEventListener("message", function (event) {
165
+ if (event.source !== window.parent) return;
166
+ var message = event.data;
167
+ if (!message || message.type !== "connecta/read-result") return;
168
+ var waiter = pending[message.id];
169
+ if (!waiter) return;
170
+ delete pending[message.id];
171
+ if (message.ok) waiter.resolve(message.value);
172
+ else waiter.reject(new Error(message.error || "Read failed"));
173
+ });
174
+ }
175
+
176
+ function htmlWithReadBridge(html) {
177
+ var script =
178
+ "<scr" + "ipt>(" + payloadReadBridge.toString() + ")();</scr" + "ipt>";
179
+ var head = /<head(?:\\s[^>]*)?>/i.exec(html);
180
+ if (head) {
181
+ var at = (head.index || 0) + head[0].length;
182
+ return html.slice(0, at) + script + html.slice(at);
183
+ }
184
+ var document = /<html(?:\\s[^>]*)?>/i.exec(html);
185
+ if (document) {
186
+ var afterHtml = (document.index || 0) + document[0].length;
187
+ return html.slice(0, afterHtml) + "<head>" + script + "</head>" + html.slice(afterHtml);
188
+ }
189
+ return script + html;
121
190
  }
122
191
 
123
192
  function render(params) {
124
- var html =
125
- payloadHtml(params) ||
126
- payloadHtml(params && params.result) ||
127
- payloadHtml(params && params.toolResult);
128
- if (html === null) return;
129
- view.srcdoc = html;
193
+ var value =
194
+ payload(params) ||
195
+ payload(params && params.result) ||
196
+ payload(params && params.toolResult);
197
+ if (value === null) return;
198
+ reads = value.reads && typeof value.reads === "object"
199
+ ? value.reads
200
+ : null;
201
+ view.srcdoc = reads
202
+ ? htmlWithReadBridge(value.html)
203
+ : value.html;
130
204
  reportSize();
131
205
  }
132
206
 
207
+ function readError(message, fallback) {
208
+ if (message && typeof message.message === "string") return message.message;
209
+ if (message && message.data && typeof message.data.message === "string") {
210
+ return message.data.message;
211
+ }
212
+ return fallback;
213
+ }
214
+
215
+ function finishInnerRead(innerId, ok, value) {
216
+ if (!view.contentWindow) return;
217
+ view.contentWindow.postMessage(ok
218
+ ? { type: "connecta/read-result", id: innerId, ok: true, value: value }
219
+ : { type: "connecta/read-result", id: innerId, ok: false, error: value }, "*");
220
+ }
221
+
222
+ function beginInnerRead(message) {
223
+ if (!hostCanCallTools) {
224
+ finishInnerRead(message && message.id, false, "This host does not support app-initiated server tool calls");
225
+ return;
226
+ }
227
+ if (!message || typeof message.id !== "string" || typeof message.name !== "string") return;
228
+ if (!reads || !Object.prototype.hasOwnProperty.call(reads, message.name)) {
229
+ finishInnerRead(message.id, false, "Unknown read binding");
230
+ return;
231
+ }
232
+ if (activeHostReads >= maxActiveHostReads) {
233
+ finishInnerRead(message.id, false, "Too many concurrent reads");
234
+ return;
235
+ }
236
+ var binding = reads[message.name];
237
+ if (!binding || typeof binding !== "object" || typeof binding.address !== "string") {
238
+ finishInnerRead(message.id, false, "Invalid read binding");
239
+ return;
240
+ }
241
+ var supplied = message.args;
242
+ if (!supplied || typeof supplied !== "object" || Array.isArray(supplied)) {
243
+ finishInnerRead(message.id, false, "Read arguments must be an object");
244
+ return;
245
+ }
246
+ var allowed = Array.isArray(binding.viewArgs) ? binding.viewArgs : [];
247
+ var suppliedKeys = Object.keys(supplied);
248
+ for (var i = 0; i < suppliedKeys.length; i++) {
249
+ var key = suppliedKeys[i];
250
+ if (allowed.indexOf(key) === -1) {
251
+ finishInnerRead(message.id, false, "Undeclared read argument " + JSON.stringify(key));
252
+ return;
253
+ }
254
+ }
255
+ var args = Object.create(null);
256
+ var fixed = binding.fixedArgs && typeof binding.fixedArgs === "object"
257
+ ? binding.fixedArgs
258
+ : {};
259
+ Object.keys(fixed).forEach(function (key) { args[key] = fixed[key]; });
260
+ suppliedKeys.forEach(function (key) { args[key] = supplied[key]; });
261
+
262
+ var hostId = "connecta-ui-read-" + String(++nextHostRequestId);
263
+ pendingHostReads[hostId] = { innerId: message.id };
264
+ activeHostReads++;
265
+ send({
266
+ jsonrpc: "2.0",
267
+ id: hostId,
268
+ method: "tools/call",
269
+ params: {
270
+ name: "call_tool",
271
+ arguments: {
272
+ address: binding.address,
273
+ args: args,
274
+ resultMode: "value"
275
+ }
276
+ }
277
+ });
278
+ }
279
+
280
+ function finishHostRead(message) {
281
+ var pending = pendingHostReads[message.id];
282
+ if (!pending) return false;
283
+ delete pendingHostReads[message.id];
284
+ activeHostReads--;
285
+ if (message.error) {
286
+ finishInnerRead(pending.innerId, false, readError(message.error, "Host rejected read"));
287
+ return true;
288
+ }
289
+ var toolResult = message.result;
290
+ if (!toolResult || typeof toolResult !== "object") {
291
+ finishInnerRead(pending.innerId, false, "Host returned an invalid tool result");
292
+ return true;
293
+ }
294
+ var structured = toolResult.structuredContent;
295
+ if (toolResult.isError || (structured && structured.ok === false)) {
296
+ var detail = structured && structured.error;
297
+ var content = Array.isArray(toolResult.content)
298
+ ? toolResult.content.find(function (block) { return block && block.type === "text"; })
299
+ : null;
300
+ finishInnerRead(
301
+ pending.innerId,
302
+ false,
303
+ readError(detail, content && content.text ? content.text : "Read failed")
304
+ );
305
+ return true;
306
+ }
307
+ var value = structured && structured.ok === true &&
308
+ Object.prototype.hasOwnProperty.call(structured, "data")
309
+ ? structured.data
310
+ : structured !== undefined
311
+ ? structured
312
+ : toolResult;
313
+ finishInnerRead(pending.innerId, true, value);
314
+ return true;
315
+ }
316
+
133
317
  window.addEventListener("message", function (event) {
134
- if (event.source !== host) return;
135
318
  var message = event.data;
319
+ if (event.source === view.contentWindow) {
320
+ if (message && message.type === "connecta/read") beginInnerRead(message);
321
+ return;
322
+ }
323
+ if (event.source !== host) return;
136
324
  if (!message || message.jsonrpc !== "2.0") return;
325
+ if (message.id !== undefined && finishHostRead(message)) return;
137
326
  if (message.method === "ui/notifications/tool-result") {
138
327
  render(message.params);
139
328
  return;
@@ -151,6 +340,8 @@ export const PROGRAM_UI_SHELL_HTML = `<!doctype html>
151
340
  // response carries the same id, and announcing initialization on one
152
341
  // would assert a handshake that never happened.
153
342
  if (message.id === initializeId && message.result !== undefined) {
343
+ var capabilities = message.result.hostCapabilities;
344
+ hostCanCallTools = Boolean(capabilities && capabilities.serverTools);
154
345
  notify("ui/notifications/initialized", {});
155
346
  }
156
347
  });
package/src/execute.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  InvocationService,
29
29
  } from "./invocation.js";
30
30
  import type { RegistryView } from "./registry.js";
31
+ import { isExplicitlyReadOnly } from "./tool-safety.js";
31
32
  import type {
32
33
  Executor,
33
34
  ExecutorProvider,
@@ -249,7 +250,23 @@ function requireEmittedBlock(raw: unknown): EmittedBlock {
249
250
  }
250
251
 
251
252
  const UI_SHAPE_HINT =
252
- "connecta.ui accepts exactly one argument: a non-empty string of HTML";
253
+ "connecta.ui accepts exactly one HTML argument and, optionally, one read-binding options object";
254
+
255
+ const MAX_UI_READ_BINDINGS = 32;
256
+ const MAX_UI_VIEW_ARGS = 32;
257
+ const UI_READ_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
258
+ const FORBIDDEN_UI_KEY = new Set(["__proto__", "constructor", "prototype"]);
259
+
260
+ interface UiReadBinding {
261
+ address: string;
262
+ fixedArgs: Record<string, unknown>;
263
+ viewArgs: string[];
264
+ }
265
+
266
+ interface UiPayload {
267
+ html: string;
268
+ reads?: Record<string, UiReadBinding>;
269
+ }
253
270
 
254
271
  /** What the argument was, named the way the emit validator names a bad field. */
255
272
  function describeUiArgument(raw: unknown): string {
@@ -262,9 +279,9 @@ function describeUiArgument(raw: unknown): string {
262
279
  }
263
280
 
264
281
  /**
265
- * Strict U1 validation. There is no options parameter and no sugar form, for
266
- * M1's reason: sugar is how a one-shape contract grows hair. An options bag or
267
- * an MCP block object is just a non-string, and fails as one.
282
+ * Strict U1/V1 validation. The first argument remains HTML; the only second
283
+ * argument is one read-binding manifest. There are no alternate object or MCP
284
+ * block forms.
268
285
  */
269
286
  function requireUiHtml(raw: unknown): string {
270
287
  if (typeof raw !== "string" || raw.length === 0) {
@@ -273,6 +290,125 @@ function requireUiHtml(raw: unknown): string {
273
290
  return raw;
274
291
  }
275
292
 
293
+ function requireRecord(raw: unknown, label: string): Record<string, unknown> {
294
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
295
+ throw new Error(`${label} must be an object`);
296
+ }
297
+ return raw as Record<string, unknown>;
298
+ }
299
+
300
+ function requireExactKeys(
301
+ value: Record<string, unknown>,
302
+ allowed: readonly string[],
303
+ label: string,
304
+ ): void {
305
+ const extras = Object.keys(value).filter((key) => !allowed.includes(key));
306
+ if (extras.length > 0) {
307
+ throw new Error(
308
+ `${label} carries unsupported field(s) ${extras.map((key) => JSON.stringify(key)).join(", ")}`,
309
+ );
310
+ }
311
+ }
312
+
313
+ function requireUiReadKey(raw: unknown, label: string): string {
314
+ if (
315
+ typeof raw !== "string" ||
316
+ raw.length === 0 ||
317
+ raw.length > 128 ||
318
+ FORBIDDEN_UI_KEY.has(raw)
319
+ ) {
320
+ throw new Error(
321
+ `${label} must be a non-empty string of at most 128 characters and cannot be __proto__, constructor, or prototype`,
322
+ );
323
+ }
324
+ return raw;
325
+ }
326
+
327
+ function requireUiReads(raw: unknown): Record<string, UiReadBinding> {
328
+ const record = requireRecord(raw, "connecta.ui options.reads");
329
+ const names = Object.keys(record);
330
+ if (names.length === 0 || names.length > MAX_UI_READ_BINDINGS) {
331
+ throw new Error(
332
+ `connecta.ui options.reads must contain from 1 through ${MAX_UI_READ_BINDINGS} named bindings`,
333
+ );
334
+ }
335
+ const reads = Object.create(null) as Record<string, UiReadBinding>;
336
+ for (const name of names) {
337
+ if (!UI_READ_NAME.test(name) || FORBIDDEN_UI_KEY.has(name)) {
338
+ throw new Error(
339
+ `connecta.ui read binding name ${JSON.stringify(name)} must match ${UI_READ_NAME}`,
340
+ );
341
+ }
342
+ const value = requireRecord(
343
+ record[name],
344
+ `connecta.ui read binding ${JSON.stringify(name)}`,
345
+ );
346
+ requireExactKeys(
347
+ value,
348
+ ["address", "fixedArgs", "viewArgs"],
349
+ `connecta.ui read binding ${JSON.stringify(name)}`,
350
+ );
351
+ if (typeof value.address !== "string" || value.address.length === 0) {
352
+ throw new Error(
353
+ `connecta.ui read binding ${JSON.stringify(name)} address must be a non-empty string`,
354
+ );
355
+ }
356
+ const fixedArgs =
357
+ value.fixedArgs === undefined
358
+ ? {}
359
+ : requireRecord(
360
+ value.fixedArgs,
361
+ `connecta.ui read binding ${JSON.stringify(name)} fixedArgs`,
362
+ );
363
+ const rawViewArgs = value.viewArgs ?? [];
364
+ if (!Array.isArray(rawViewArgs) || rawViewArgs.length > MAX_UI_VIEW_ARGS) {
365
+ throw new Error(
366
+ `connecta.ui read binding ${JSON.stringify(name)} viewArgs must be an array of at most ${MAX_UI_VIEW_ARGS} strings`,
367
+ );
368
+ }
369
+ const viewArgs = rawViewArgs.map((key) =>
370
+ requireUiReadKey(
371
+ key,
372
+ `connecta.ui read binding ${JSON.stringify(name)} viewArgs entry`,
373
+ )
374
+ );
375
+ if (new Set(viewArgs).size !== viewArgs.length) {
376
+ throw new Error(
377
+ `connecta.ui read binding ${JSON.stringify(name)} viewArgs must not repeat a key`,
378
+ );
379
+ }
380
+ for (const key of viewArgs) {
381
+ if (Object.prototype.hasOwnProperty.call(fixedArgs, key)) {
382
+ throw new Error(
383
+ `connecta.ui read binding ${JSON.stringify(name)} view argument ${JSON.stringify(key)} cannot override a fixed argument`,
384
+ );
385
+ }
386
+ }
387
+ reads[name] = {
388
+ address: value.address,
389
+ fixedArgs,
390
+ viewArgs,
391
+ };
392
+ }
393
+ return reads;
394
+ }
395
+
396
+ function requireUiPayload(values: unknown[]): UiPayload {
397
+ if (values.length !== 1 && values.length !== 2) {
398
+ throw new Error(
399
+ `${UI_SHAPE_HINT}; got ${values.length} arguments`,
400
+ );
401
+ }
402
+ const html = requireUiHtml(values[0]);
403
+ if (values.length === 1) return { html };
404
+ const options = requireRecord(values[1], "connecta.ui options");
405
+ requireExactKeys(options, ["reads"], "connecta.ui options");
406
+ if (!Object.prototype.hasOwnProperty.call(options, "reads")) {
407
+ throw new Error("connecta.ui options must contain reads");
408
+ }
409
+ return { html, reads: requireUiReads(options.reads) };
410
+ }
411
+
276
412
  /**
277
413
  * Request-local collection for `connecta.emit` and `connecta.ui`. Budgets fail
278
414
  * loudly at the crossing call — nothing is partially accepted and prior blocks
@@ -288,7 +424,7 @@ export class EmitCollector {
288
424
  /** The shared transport aggregate: emitted blocks plus the UI payload. */
289
425
  bytes = 0;
290
426
  /** The one accepted UI payload (U2), delivered in result `_meta` on success. */
291
- ui?: { html: string };
427
+ ui?: UiPayload;
292
428
  /** What the blocks alone cost, so the `emitted` aggregate stays a true pair. */
293
429
  private blockBytes = 0;
294
430
  constructor(
@@ -328,14 +464,28 @@ export class EmitCollector {
328
464
  * problem worth naming — that there is a second payload at all — and a
329
465
  * complaint about its type would send the author to fix the wrong thing.
330
466
  */
331
- acceptUi(raw: unknown): void {
467
+ acceptUi(...values: unknown[]): void {
332
468
  if (this.ui) {
333
469
  throw new Error(
334
470
  "connecta.ui accepts at most one payload per run: a view was already accepted and stands",
335
471
  );
336
472
  }
337
- const payload = { html: requireUiHtml(raw) };
338
- const size = diagnosticsEncoder.encode(JSON.stringify(payload)).byteLength;
473
+ this.acceptUiPayload(requireUiPayload(values));
474
+ }
475
+
476
+ acceptUiPayload(payload: UiPayload): void {
477
+ if (this.ui) {
478
+ throw new Error(
479
+ "connecta.ui accepts at most one payload per run: a view was already accepted and stands",
480
+ );
481
+ }
482
+ let serialized: string;
483
+ try {
484
+ serialized = JSON.stringify(payload);
485
+ } catch {
486
+ throw new Error("connecta.ui payload must be JSON-serializable");
487
+ }
488
+ const size = diagnosticsEncoder.encode(serialized).byteLength;
339
489
  if (this.bytes + size > this.maxBytes) {
340
490
  throw new Error(
341
491
  `connecta.ui byte budget exceeded: payload is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`,
@@ -619,6 +769,41 @@ export async function buildSandboxProviders(
619
769
  return outcome.value;
620
770
  };
621
771
 
772
+ /**
773
+ * A read binding is admitted while the program still owns the request. The
774
+ * shell later calls the ordinary `call_tool`, which repeats this same
775
+ * fail-closed check against the then-current catalog; validating here keeps
776
+ * a typo or destructive address from producing a view whose controls can
777
+ * never work, while validation at use keeps a stale view from retaining old
778
+ * authority.
779
+ */
780
+ const validateUiReads = async (payload: UiPayload): Promise<UiPayload> => {
781
+ if (!payload.reads) return payload;
782
+ const reads = Object.create(null) as Record<string, UiReadBinding>;
783
+ for (const [name, binding] of Object.entries(payload.reads)) {
784
+ const resolution = await catalog.resolveTool(
785
+ binding.address,
786
+ limits.signal !== undefined ? { signal: limits.signal } : {},
787
+ );
788
+ if (!resolution.ok) {
789
+ throw new Error(
790
+ `connecta.ui read binding ${JSON.stringify(name)} could not resolve ${JSON.stringify(binding.address)}: ${resolution.error.message}`,
791
+ );
792
+ }
793
+ if (!isExplicitlyReadOnly(resolution.resolved.definition)) {
794
+ throw new Error(
795
+ `connecta.ui read binding ${JSON.stringify(name)} refuses ${JSON.stringify(binding.address)}: the tool is not explicitly read-only`,
796
+ );
797
+ }
798
+ reads[name] = {
799
+ ...binding,
800
+ address:
801
+ `${resolution.resolved.connector.id}.${resolution.resolved.toolName}`,
802
+ };
803
+ }
804
+ return { html: payload.html, reads };
805
+ };
806
+
622
807
  return [
623
808
  {
624
809
  name: "connecta",
@@ -643,13 +828,14 @@ export async function buildSandboxProviders(
643
828
  // reason (U7): one more provider fn, no change to ExecuteResult or
644
829
  // the Executor contract. Delivery is the handler's job, not the
645
830
  // guest's — nothing here becomes addressable.
646
- ui: async (html: unknown) => {
831
+ ui: async (...values: unknown[]) => {
647
832
  if (!limits.emitCollector) {
648
833
  throw new Error(
649
834
  "connecta.ui is unavailable: no emission collector was configured for this execution",
650
835
  );
651
836
  }
652
- limits.emitCollector.acceptUi(html);
837
+ const payload = await validateUiReads(requireUiPayload(values));
838
+ limits.emitCollector.acceptUiPayload(payload);
653
839
  },
654
840
  batch: async (calls: unknown) => {
655
841
  const started = Date.now();
@@ -1043,7 +1229,7 @@ export function createExecuteTool(
1043
1229
  // _meta is where the Apps spec's best practices put data "not intended
1044
1230
  // for model context", and how shipped hosts behave. The shell reads
1045
1231
  // exactly this key out of the tool result the host delivers to it.
1046
- response._meta = { [PROGRAM_UI_META_KEY]: { html: emitted.ui.html } };
1232
+ response._meta = { [PROGRAM_UI_META_KEY]: emitted.ui };
1047
1233
  }
1048
1234
  if (emitted.blocks.length > 0) {
1049
1235
  // Emitted image/audio blocks are valid MCP content that ToolResult's
@@ -1093,7 +1279,7 @@ Write an async arrow function. It runs with NO network, filesystem, timers, or i
1093
1279
  - connecta.call(address, args) and connecta.batch(calls) — call raw addresses. Every batch entry is { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }; destructure that, not a bare result.
1094
1280
  - connecta.search(args) and connecta.describe, taking { address: "<connectorId>.<toolName>" } or { addresses: [...] } — load and inspect request-local catalogs on demand. Use safety: "readOnly" to avoid advertising calls this sandbox cannot execute; it changes results, not authority. Matches carrying schemas also list inputKeys, requiredInputKeys, and outputKeys — the schema's own names, checkable before building args. A missing list means the schema is not a plain object shape, not that the tool has no fields — read the schema.
1095
1281
  - connecta.emit(block) — deliver MCP content beside the JSON return: exactly { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }, nothing else. Blocks are appended on success only, spend no host calls, and are budgeted per run (${emitBudgets.maxBlocks} blocks, ${emitBudgets.maxBytes} serialized bytes); an over-budget or invalid emit throws catchably and accepts nothing.
1096
- - connecta.ui(html) — hand the client one rendered view: exactly one argument, a non-empty HTML string, no options, no block object. Delivered on success only, spends no host calls, and draws on the same ${emitBudgets.maxBytes}-byte budget connecta.emit does one budget, not two; a second, over-budget, or invalid call throws catchably and accepts nothing. The view is display-only (no network, no tool calls, no links) and out of model context — the envelope reports only ui: true, so the model reads the return value, not the view: return the summary it should reason over, built from the same variables the view renders.
1282
+ - connecta.ui(html, options?) — deliver one view. One argument is display-only; for live reads pass { reads: { name: { address, fixedArgs?, viewArgs? } } }, then markup calls connecta.read(name, args). Read-only is validated; fixed keys cannot be overridden, undeclared keys fail, and discovery, writes, and network stay unavailable. Success-only, no binding call cost, and one budget, not two (${emitBudgets.maxBytes} shared emit bytes); a second, over-budget, or invalid call throws catchably. The bytes stay out of context, so the model reads the return value, not the view: return the initial summary from its variables; later reads update only the view.
1097
1283
  - console.log(...) — captured and returned with the result.
1098
1284
 
1099
1285
  Tool calls return plain values (MCP text is JSON-parsed when possible) and throw on downstream errors — use try/catch. A thrown error carries only a message, so use connecta.batch when a program must tell a policy refusal from a transient failure. Never retry a failure whose retryable is false, and never retry a rate_limited one immediately — the sandbox has no timers. Return a JSON-serializable value; large results are truncated, so reduce data in code rather than return raw payloads.
package/src/meta-tools.ts CHANGED
@@ -1484,6 +1484,7 @@ export function registerMetaTools(
1484
1484
  description: describedFor(registry, SKILLS_DESC, "skills"),
1485
1485
  inputSchema: z.object({ name: z.string().optional() }),
1486
1486
  annotations: READ_ONLY_LOCAL,
1487
+ _meta: { ui: { visibility: ["model"] } },
1487
1488
  },
1488
1489
  async (args) => mt.skills(args as SkillArgs),
1489
1490
  );
@@ -1508,6 +1509,7 @@ export function registerMetaTools(
1508
1509
  includeSchemas: z.enum(["compact", "json"]).optional(),
1509
1510
  }),
1510
1511
  annotations: READ_ONLY_REMOTE,
1512
+ _meta: { ui: { visibility: ["model"] } },
1511
1513
  },
1512
1514
  async (args) => mt.searchTools(args as SearchArgs),
1513
1515
  );
@@ -1520,6 +1522,10 @@ export function registerMetaTools(
1520
1522
  // call_tool admits only tools that are themselves explicitly read-only;
1521
1523
  // anything else is refused and routed to call_destructive_tool.
1522
1524
  annotations: READ_ONLY_REMOTE,
1525
+ // The trusted program-view shell delegates bounded named reads here.
1526
+ // It is already one of the seven model tools; app visibility adds no
1527
+ // tool and this handler repeats ordinary fail-closed read admission.
1528
+ _meta: { ui: { visibility: ["model", "app"] } },
1523
1529
  },
1524
1530
  async (args) => mt.callTool(args as CallArgs),
1525
1531
  );
@@ -1541,6 +1547,7 @@ export function registerMetaTools(
1541
1547
  readOnlyHint: false,
1542
1548
  openWorldHint: true,
1543
1549
  },
1550
+ _meta: { ui: { visibility: ["model"] } },
1544
1551
  },
1545
1552
  async (args) => {
1546
1553
  // `reason` is the host's to display and connecta's to keep out of the
@@ -1568,6 +1575,7 @@ export function registerMetaTools(
1568
1575
  destructiveHint: false,
1569
1576
  openWorldHint: true,
1570
1577
  },
1578
+ _meta: { ui: { visibility: ["model"] } },
1571
1579
  },
1572
1580
  async (args) => mt.authorizeConnector(args as AuthorizeArgs),
1573
1581
  );
@@ -1586,6 +1594,7 @@ export function registerMetaTools(
1586
1594
  maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
1587
1595
  }),
1588
1596
  annotations: READ_ONLY_LOCAL,
1597
+ _meta: { ui: { visibility: ["model"] } },
1589
1598
  },
1590
1599
  async (args) => mt.getResult(args as GetResultArgs),
1591
1600
  );