@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.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The one MCP Apps template connecta serves (`U5`, `U6`).
3
+ *
4
+ * A build-time string constant, not a file read at startup: the core is
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.
10
+ *
11
+ * The address carries a version segment because hosts are permitted to
12
+ * prefetch and cache templates by URI: change these bytes, bump `v1`.
13
+ */
14
+
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";
17
+
18
+ /** The mimeType the Apps spec requires of an HTML template. */
19
+ export const PROGRAM_UI_MIME_TYPE = "text/html;profile=mcp-app";
20
+
21
+ /**
22
+ * The result `_meta` key carrying the payload (`U3`). A plain single-label
23
+ * prefix rather than the reverse-DNS form MCP's SHOULD prefers: connecta has
24
+ * no domain to reverse, and fabricating one to satisfy a SHOULD is a worse
25
+ * answer than the shape the key format's MUST already permits.
26
+ */
27
+ export const PROGRAM_UI_META_KEY = "connecta/ui";
28
+
29
+ /** The one extension identifier connecta advertises (`U11`). */
30
+ export const MCP_APPS_EXTENSION = "io.modelcontextprotocol/ui";
31
+
32
+ /**
33
+ * The shell document. Dependency-free and deliberately small: it speaks the
34
+ * Apps postMessage dialect (`ui/initialize`, `ui/notifications/initialized`,
35
+ * `ui/notifications/tool-result`, `ui/notifications/size-changed`,
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.
41
+ */
42
+ export const PROGRAM_UI_SHELL_HTML = `<!doctype html>
43
+ <html lang="en">
44
+ <head>
45
+ <meta charset="utf-8" />
46
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
47
+ <title>connecta program view</title>
48
+ <style>
49
+ html,
50
+ body {
51
+ margin: 0;
52
+ padding: 0;
53
+ background: transparent;
54
+ }
55
+ #program-view {
56
+ display: block;
57
+ width: 100%;
58
+ min-height: 480px;
59
+ border: 0;
60
+ }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <iframe
65
+ id="program-view"
66
+ title="Program-rendered view"
67
+ sandbox="allow-scripts"
68
+ srcdoc=""
69
+ ></iframe>
70
+ <script>
71
+ (function () {
72
+ "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.
79
+ var host = window.parent;
80
+ var view = document.getElementById("program-view");
81
+ var initializeId = "connecta-ui-initialize";
82
+ var lastWidth = 0;
83
+ var lastHeight = 0;
84
+
85
+ function send(message) {
86
+ if (!host || host === window) return;
87
+ host.postMessage(message, "*");
88
+ }
89
+
90
+ function notify(method, params) {
91
+ send({ jsonrpc: "2.0", method: method, params: params });
92
+ }
93
+
94
+ // 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
97
+ // what it reports here is its own box: the min-height above, unless
98
+ // the host has given it more. Taller content scrolls inside the inner
99
+ // frame rather than growing the view. Raising the min-height is the
100
+ // only lever; a content-height signal would cost the isolation.
101
+ function reportSize() {
102
+ var width = Math.ceil(document.documentElement.clientWidth);
103
+ var height = Math.ceil(document.documentElement.scrollHeight);
104
+ if (width === lastWidth && height === lastHeight) return;
105
+ lastWidth = width;
106
+ lastHeight = height;
107
+ notify("ui/notifications/size-changed", {
108
+ width: width,
109
+ height: height
110
+ });
111
+ }
112
+
113
+ function payloadHtml(result) {
114
+ if (!result || typeof result !== "object") return null;
115
+ var meta = result._meta;
116
+ 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;
121
+ }
122
+
123
+ 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;
130
+ reportSize();
131
+ }
132
+
133
+ window.addEventListener("message", function (event) {
134
+ if (event.source !== host) return;
135
+ var message = event.data;
136
+ if (!message || message.jsonrpc !== "2.0") return;
137
+ if (message.method === "ui/notifications/tool-result") {
138
+ render(message.params);
139
+ return;
140
+ }
141
+ if (message.method === "ui/resource-teardown") {
142
+ // A host->view request, not a notification: the host waits for
143
+ // this reply before it tears the view down. There is nothing to
144
+ // release, so answer immediately rather than make it time out.
145
+ if (message.id !== undefined && message.id !== null) {
146
+ send({ jsonrpc: "2.0", id: message.id, result: {} });
147
+ }
148
+ return;
149
+ }
150
+ // Only a completed handshake earns "initialized". A JSON-RPC error
151
+ // response carries the same id, and announcing initialization on one
152
+ // would assert a handshake that never happened.
153
+ if (message.id === initializeId && message.result !== undefined) {
154
+ notify("ui/notifications/initialized", {});
155
+ }
156
+ });
157
+
158
+ window.addEventListener("resize", reportSize);
159
+ view.addEventListener("load", reportSize);
160
+
161
+ // Every field here is required by the Apps initialize schema, and a
162
+ // conforming host rejects the request outright when one is missing —
163
+ // which would strand the shell before any tool result arrives.
164
+ send({
165
+ jsonrpc: "2.0",
166
+ id: initializeId,
167
+ method: "ui/initialize",
168
+ params: {
169
+ appInfo: { name: "connecta program view", version: "1" },
170
+ appCapabilities: {},
171
+ protocolVersion: "2026-01-26"
172
+ }
173
+ });
174
+ reportSize();
175
+ })();
176
+ </script>
177
+ </body>
178
+ </html>
179
+ `;
package/src/execute.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from "zod";
3
3
  import type { ActivityRequestContext } from "./activity.js";
4
+ import {
5
+ PROGRAM_UI_META_KEY,
6
+ PROGRAM_UI_RESOURCE_URI,
7
+ } from "./apps-shell.js";
4
8
  import {
5
9
  boundedDiscoveryText,
6
10
  CatalogService,
@@ -15,6 +19,7 @@ import {
15
19
  } from "./executor-result.js";
16
20
  import {
17
21
  ExecutorAdmissionError,
22
+ ExecutorExecutionError,
18
23
  isAdmittingExecutor,
19
24
  } from "./executor-admission.js";
20
25
  import { classifyCallError } from "./errors.js";
@@ -69,6 +74,7 @@ class ExecuteDiagnostics {
69
74
  setupMs = 0;
70
75
  executorWallMs = 0;
71
76
  private emitted?: { count: number; bytes: number };
77
+ private ui?: number;
72
78
 
73
79
  /** Numbers only, per R8 — and only once something was emitted, so a
74
80
  * non-emitting run's diagnostics stay byte-for-byte what they were. */
@@ -76,6 +82,15 @@ class ExecuteDiagnostics {
76
82
  if (count > 0) this.emitted = { count, bytes };
77
83
  }
78
84
 
85
+ /**
86
+ * U9: the UI payload gets its own aggregate — one number, the payload's
87
+ * serialized size. Folding it into `emitted` would desync that aggregate's
88
+ * pair, which reports the bytes a specific block count cost.
89
+ */
90
+ recordUi(bytes: number): void {
91
+ this.ui = bytes;
92
+ }
93
+
79
94
  private stats(operation: ExecuteDiagnosticOperation) {
80
95
  let stats = this.operations.get(operation);
81
96
  if (!stats) {
@@ -158,6 +173,7 @@ class ExecuteDiagnostics {
158
173
  };
159
174
  operations: ExecuteOperationDiagnostics[];
160
175
  emitted?: { count: number; bytes: number };
176
+ ui?: number;
161
177
  } {
162
178
  const operations = [...this.operations.values()];
163
179
  return {
@@ -174,6 +190,7 @@ class ExecuteDiagnostics {
174
190
  },
175
191
  operations,
176
192
  ...(this.emitted ? { emitted: this.emitted } : {}),
193
+ ...(this.ui !== undefined ? { ui: this.ui } : {}),
177
194
  };
178
195
  }
179
196
  }
@@ -231,16 +248,49 @@ function requireEmittedBlock(raw: unknown): EmittedBlock {
231
248
  return raw as EmittedBlock;
232
249
  }
233
250
 
251
+ const UI_SHAPE_HINT =
252
+ "connecta.ui accepts exactly one argument: a non-empty string of HTML";
253
+
254
+ /** What the argument was, named the way the emit validator names a bad field. */
255
+ function describeUiArgument(raw: unknown): string {
256
+ if (raw === null) return "null";
257
+ if (raw === undefined) return "undefined";
258
+ if (Array.isArray(raw)) return "an array";
259
+ if (typeof raw === "string") return "an empty string";
260
+ const kind = typeof raw;
261
+ return `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}`;
262
+ }
263
+
234
264
  /**
235
- * Request-local collection for `connecta.emit`. Budgets fail loudly at the
236
- * crossing call the block is not partially accepted and prior blocks are
237
- * unaffected so a program learns it is over budget while it can still
238
- * choose differently (M5). Accepted blocks never ride `ExecuteResult`; the
239
- * handler that owns this collector appends them to the final tool result.
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.
268
+ */
269
+ function requireUiHtml(raw: unknown): string {
270
+ if (typeof raw !== "string" || raw.length === 0) {
271
+ throw new Error(`${UI_SHAPE_HINT}; got ${describeUiArgument(raw)}`);
272
+ }
273
+ return raw;
274
+ }
275
+
276
+ /**
277
+ * Request-local collection for `connecta.emit` and `connecta.ui`. Budgets fail
278
+ * loudly at the crossing call — nothing is partially accepted and prior blocks
279
+ * are unaffected — so a program learns it is over budget while it can still
280
+ * choose differently (M5, U4). Accepted output never rides `ExecuteResult`; the
281
+ * handler that owns this collector delivers it on the final tool result.
282
+ *
283
+ * The two channels share the byte aggregate and nothing else: a UI payload is
284
+ * not a block, so it spends no block count, and at most one is ever accepted.
240
285
  */
241
286
  export class EmitCollector {
242
287
  readonly blocks: EmittedBlock[] = [];
288
+ /** The shared transport aggregate: emitted blocks plus the UI payload. */
243
289
  bytes = 0;
290
+ /** The one accepted UI payload (U2), delivered in result `_meta` on success. */
291
+ ui?: { html: string };
292
+ /** What the blocks alone cost, so the `emitted` aggregate stays a true pair. */
293
+ private blockBytes = 0;
244
294
  constructor(
245
295
  private readonly maxBytes: number,
246
296
  private readonly maxBlocks: number,
@@ -262,7 +312,38 @@ export class EmitCollector {
262
312
  }
263
313
  this.blocks.push(block);
264
314
  this.bytes += size;
265
- this.diagnostics?.recordEmitted(this.blocks.length, this.bytes);
315
+ this.blockBytes += size;
316
+ this.diagnostics?.recordEmitted(this.blocks.length, this.blockBytes);
317
+ }
318
+
319
+ /**
320
+ * U2 and U4: one payload per run, measured as the serialized bytes of
321
+ * `{ html }` against the same aggregate emit spends. A second call throws
322
+ * naming the constraint rather than replacing the first — one tool result
323
+ * renders one view, and last-wins would silently discard a payload the
324
+ * program deliberately supplied.
325
+ *
326
+ * Multiplicity is checked before shape, so the second call is told what it
327
+ * actually broke. A program whose second payload is also malformed has one
328
+ * problem worth naming — that there is a second payload at all — and a
329
+ * complaint about its type would send the author to fix the wrong thing.
330
+ */
331
+ acceptUi(raw: unknown): void {
332
+ if (this.ui) {
333
+ throw new Error(
334
+ "connecta.ui accepts at most one payload per run: a view was already accepted and stands",
335
+ );
336
+ }
337
+ const payload = { html: requireUiHtml(raw) };
338
+ const size = diagnosticsEncoder.encode(JSON.stringify(payload)).byteLength;
339
+ if (this.bytes + size > this.maxBytes) {
340
+ throw new Error(
341
+ `connecta.ui byte budget exceeded: payload is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`,
342
+ );
343
+ }
344
+ this.ui = payload;
345
+ this.bytes += size;
346
+ this.diagnostics?.recordUi(size);
266
347
  }
267
348
  }
268
349
 
@@ -558,6 +639,18 @@ export async function buildSandboxProviders(
558
639
  }
559
640
  limits.emitCollector.accept(block);
560
641
  },
642
+ // The rendered-output channel rides the same bridge for the same
643
+ // reason (U7): one more provider fn, no change to ExecuteResult or
644
+ // the Executor contract. Delivery is the handler's job, not the
645
+ // guest's — nothing here becomes addressable.
646
+ ui: async (html: unknown) => {
647
+ if (!limits.emitCollector) {
648
+ throw new Error(
649
+ "connecta.ui is unavailable: no emission collector was configured for this execution",
650
+ );
651
+ }
652
+ limits.emitCollector.acceptUi(html);
653
+ },
561
654
  batch: async (calls: unknown) => {
562
655
  const started = Date.now();
563
656
  const callCount = Array.isArray(calls) ? calls.length : 0;
@@ -812,6 +905,9 @@ export function createExecuteTool(
812
905
  ? { retryAfterMs: err.retryAfterMs }
813
906
  : {}),
814
907
  },
908
+ ...(err instanceof ExecutorExecutionError
909
+ ? discardedEmits(emitted)
910
+ : {}),
815
911
  ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
816
912
  });
817
913
  result.isError = true;
@@ -936,9 +1032,19 @@ export function createExecuteTool(
936
1032
  const response = jsonResult({
937
1033
  result,
938
1034
  ...(emitted.blocks.length > 0 ? { emitted: emitted.blocks.length } : {}),
1035
+ // U3: the model learns a view rendered without seeing its bytes. Spread
1036
+ // conditionally so a program that never called connecta.ui produces
1037
+ // today's byte-for-byte response.
1038
+ ...(emitted.ui ? { ui: true } : {}),
939
1039
  ...(logs ? { logs } : {}),
940
1040
  ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
941
1041
  });
1042
+ if (emitted.ui) {
1043
+ // _meta is where the Apps spec's best practices put data "not intended
1044
+ // for model context", and how shipped hosts behave. The shell reads
1045
+ // exactly this key out of the tool result the host delivers to it.
1046
+ response._meta = { [PROGRAM_UI_META_KEY]: { html: emitted.ui.html } };
1047
+ }
942
1048
  if (emitted.blocks.length > 0) {
943
1049
  // Emitted image/audio blocks are valid MCP content that ToolResult's
944
1050
  // text-only typing does not model — the same acknowledged gap
@@ -951,20 +1057,31 @@ export function createExecuteTool(
951
1057
  };
952
1058
  }
953
1059
 
954
- /** M4: a failed program delivers no blocks, but the discard is visible. */
1060
+ /**
1061
+ * M4 and U3: a failed program delivers no blocks and no view, but each
1062
+ * discard is visible — and one failure can discard both.
1063
+ */
955
1064
  function discardedEmits(emitted: EmitCollector): {
956
1065
  emittedDiscarded?: number;
1066
+ uiDiscarded?: true;
957
1067
  } {
958
- return emitted.blocks.length > 0
959
- ? { emittedDiscarded: emitted.blocks.length }
960
- : {};
1068
+ return {
1069
+ ...(emitted.blocks.length > 0
1070
+ ? { emittedDiscarded: emitted.blocks.length }
1071
+ : {}),
1072
+ ...(emitted.ui ? { uiDiscarded: true as const } : {}),
1073
+ };
961
1074
  }
962
1075
 
963
1076
  /** The same visibility for the plain-text error paths. */
964
1077
  function discardedEmitsText(emitted: EmitCollector): string {
965
- return emitted.blocks.length > 0
966
- ? `\n\nemittedDiscarded: ${emitted.blocks.length}`
967
- : "";
1078
+ const lines = [
1079
+ ...(emitted.blocks.length > 0
1080
+ ? [`emittedDiscarded: ${emitted.blocks.length}`]
1081
+ : []),
1082
+ ...(emitted.ui ? ["uiDiscarded: true"] : []),
1083
+ ];
1084
+ return lines.length > 0 ? `\n\n${lines.join("\n")}` : "";
968
1085
  }
969
1086
 
970
1087
  const executeDescription = (
@@ -976,6 +1093,7 @@ Write an async arrow function. It runs with NO network, filesystem, timers, or i
976
1093
  - connecta.call(address, args) and connecta.batch(calls) — call raw addresses.
977
1094
  - connecta.search(args), connecta.describe({ address: "<connectorId>.<toolName>" }), and connecta.describe({ addresses: [...] }) — load and inspect request-local catalogs on demand. Use safety: "readOnly" to avoid advertising calls this sandbox cannot execute; the filter changes results, not authority. Matches carrying schemas also list inputKeys, requiredInputKeys, and outputKeys — the same names the schema shows, ready to check against before building args. They are absent when a schema is not a plain object shape, so read the schema itself rather than assuming a missing list means no fields.
978
1095
  - connecta.emit(block) — deliver rich MCP content alongside the JSON return: exactly { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }, no other fields. Blocks are appended to the result 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 of this run: exactly one argument, a non-empty HTML string, no options and no block object. At most one per run, delivered on success only, out of model context (the envelope just reports ui: true), and spending no host calls. It draws on the same ${emitBudgets.maxBytes}-byte budget as connecta.emit; a second, over-budget, or invalid call throws catchably and accepts nothing. The view is display-only — no network, no tool calls, no links.
979
1097
  - console.log(...) — captured and returned alongside the result.
980
1098
 
981
1099
  Tool calls return plain values (MCP text content is JSON-parsed when possible) and throw on downstream errors — use try/catch to handle them. A thrown error carries only a message; connecta.batch reports each call as { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }, so use it when the 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 instead of returning raw payloads.
@@ -1055,6 +1173,18 @@ export function registerExecuteTool(
1055
1173
  destructiveHint: false,
1056
1174
  openWorldHint: true,
1057
1175
  },
1176
+ // U5 and U10: declared unconditionally. A host without the Apps
1177
+ // extension ignores unknown _meta and sees the ordinary envelope, which
1178
+ // is the text fallback the spec mandates — and a stateless aggregator
1179
+ // has nowhere dependable to hold a negotiation check anyway. The
1180
+ // explicit visibility keeps hosts from being told the view may call
1181
+ // execute_code; the default ["model","app"] would say exactly that.
1182
+ _meta: {
1183
+ ui: {
1184
+ resourceUri: PROGRAM_UI_RESOURCE_URI,
1185
+ visibility: ["model"],
1186
+ },
1187
+ },
1058
1188
  },
1059
1189
  async (args, extra) => {
1060
1190
  const controller = new AbortController();
@@ -36,6 +36,18 @@ export class ExecutorAdmissionError extends Error {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * A lifecycle failure after the sandbox started running. It keeps the stable
41
+ * admission-error envelope while letting response assembly distinguish work
42
+ * torn down in flight from work that never entered the executor.
43
+ */
44
+ export class ExecutorExecutionError extends ExecutorAdmissionError {
45
+ constructor(code: ExecutorAdmissionErrorCode, message: string) {
46
+ super(code, message);
47
+ this.name = "ExecutorExecutionError";
48
+ }
49
+ }
50
+
39
51
  export interface AdmissionLease {
40
52
  /** Time spent waiting behind active work. Zero for immediate admission. */
41
53
  readonly waitMs: number;
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import {
10
10
  AdmissionController,
11
11
  ExecutorAdmissionError,
12
+ ExecutorExecutionError,
12
13
  } from "../executor-admission.js";
13
14
  import type {
14
15
  AdmittingExecutor,
@@ -737,7 +738,7 @@ class QuickJsChildPool implements AdmittingExecutor {
737
738
  if (!child) return Promise.resolve();
738
739
  this.rejectActive(
739
740
  slot,
740
- new ExecutorAdmissionError(
741
+ new ExecutorExecutionError(
741
742
  "executor_closed",
742
743
  "Executor is shutting down.",
743
744
  ),
package/src/routes/mcp.ts CHANGED
@@ -5,6 +5,12 @@ import {
5
5
  WebStandardStreamableHTTPServerTransport,
6
6
  } from "@modelcontextprotocol/server";
7
7
  import type { ActivityActor, ActivityRequestContext } from "../activity.js";
8
+ import {
9
+ MCP_APPS_EXTENSION,
10
+ PROGRAM_UI_MIME_TYPE,
11
+ PROGRAM_UI_RESOURCE_URI,
12
+ PROGRAM_UI_SHELL_HTML,
13
+ } from "../apps-shell.js";
8
14
  import { registerExecuteTool } from "../execute.js";
9
15
  import {
10
16
  ExecutorAdmissionError,
@@ -184,6 +190,42 @@ function toolkitRetired(logger: Logger): Response {
184
190
  );
185
191
  }
186
192
 
193
+ /**
194
+ * U5: one static template, served by a handler that answers exactly one URI
195
+ * and fails on every other. Registering it is also what declares the
196
+ * `resources` capability — which is why `resources/list` has to answer, and
197
+ * why it answers with nothing. That is the Apps spec's permitted omission of
198
+ * UI-only resources from listing, taken exactly: the capability stays honest
199
+ * because the method answers, and nothing downstream is ever listed or
200
+ * aggregated. Widening this handler to proxy downstream templates is a
201
+ * decision (see the design record), not a diff.
202
+ */
203
+ function registerProgramUiResource(server: McpServer): void {
204
+ server.registerResource(
205
+ "connecta-program-ui",
206
+ PROGRAM_UI_RESOURCE_URI,
207
+ {
208
+ title: "connecta program view",
209
+ description:
210
+ "The MCP Apps shell that renders HTML an execute_code program handed connecta.ui.",
211
+ mimeType: PROGRAM_UI_MIME_TYPE,
212
+ },
213
+ (uri) => ({
214
+ contents: [
215
+ {
216
+ uri: uri.href,
217
+ mimeType: PROGRAM_UI_MIME_TYPE,
218
+ text: PROGRAM_UI_SHELL_HTML,
219
+ },
220
+ ],
221
+ }),
222
+ );
223
+ // The SDK's generated listing would advertise the template it just
224
+ // registered. Replace it rather than accept that: the URI reaches the host
225
+ // through tool metadata, so the listing has nothing to carry.
226
+ server.server.setRequestHandler("resources/list", () => ({ resources: [] }));
227
+ }
228
+
187
229
  async function serveMcp(
188
230
  request: Request,
189
231
  opts: ServerOptions,
@@ -195,6 +237,23 @@ async function serveMcp(
195
237
  const createServer = (): McpServer => {
196
238
  const server = new McpServer(opts.serverInfo, {
197
239
  instructions: instructionsFor(),
240
+ // U11: the Apps extension must be explicitly negotiated, and a
241
+ // conforming client acts on an extension only when both sides declare
242
+ // it — without this line no host reads execute_code's _meta.ui, no host
243
+ // fetches the shell, and the whole design is inert. This is the one
244
+ // extension connecta advertises; the versioned extensions framework
245
+ // stays declined as a general surface (documentation/mcp-2026-07-28.md).
246
+ capabilities: {
247
+ extensions: {
248
+ [MCP_APPS_EXTENSION]: { mimeTypes: [PROGRAM_UI_MIME_TYPE] },
249
+ },
250
+ // Registering the shell below declares `resources` on its own, but it
251
+ // would default `listChanged` to true. Connecta serves one build-time
252
+ // template and never sends a list_changed notification, so say so:
253
+ // a client that subscribes on the strength of that flag would wait
254
+ // forever for an event this server has no way to produce.
255
+ resources: { listChanged: false },
256
+ },
198
257
  cacheHints: {
199
258
  "tools/list": {
200
259
  ttlMs: 3_600_000,
@@ -202,6 +261,7 @@ async function serveMcp(
202
261
  },
203
262
  },
204
263
  });
264
+ registerProgramUiResource(server);
205
265
  const activity: ActivityRequestContext | undefined = opts.activity
206
266
  ? {
207
267
  sink: opts.activity,
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.11.0";
7
+ export const CONNECTA_VERSION = "0.12.0";
@@ -12,7 +12,7 @@
12
12
  "typecheck": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@zackbart/connecta": "0.11.0",
15
+ "@zackbart/connecta": "0.12.0",
16
16
  "quickjs-emscripten": "0.32.0"
17
17
  },
18
18
  "devDependencies": {