@unoverse-platform/studio 0.1.34 → 0.1.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -373,6 +373,16 @@ export function TemplatesView({
373
373
  [canConnect, rawConn],
374
374
  );
375
375
 
376
+ // The workflow can OVERRIDE the manual pick (WORKFLOW_STARTED metadata.template →
377
+ // store.activeTemplate) with a BARE name — so the org must be resolved for whichever
378
+ // template is actually loading, not the picked one, or the resources/read goes out
379
+ // org-less and the server refuses it ("template address needs its org").
380
+ const activeName = conn.activeTemplate ?? selected;
381
+ const activeDef = useMemo(
382
+ () => (activeName === selected ? selectedDef : (templates.find((t) => t.name === activeName) ?? selectedDef)),
383
+ [templates, activeName, selected, selectedDef],
384
+ );
385
+
376
386
  // The `voice` native service (§2e-1) now lives INSIDE the SDK renderer (shared by every
377
387
  // host — see StreamedUnoverseTemplate). The host only DECLARES the app is voice (off its
378
388
  // manifest `service`) and hands the renderer the connection identity it already holds;
@@ -1005,6 +1015,11 @@ export function TemplatesView({
1005
1015
  conversationId={session.conversationId}
1006
1016
  onStatus={setNativeStatus}
1007
1017
  />
1018
+ ) : !activeDef && templates.length === 0 ? (
1019
+ /* The workflow can announce its template BEFORE /dev/templates answers.
1020
+ Reading it org-less is a guaranteed server refusal, so hold one beat
1021
+ until the list arrives and the org can be resolved. */
1022
+ null
1008
1023
  ) : (
1009
1024
  <StreamedUnoverseTemplate
1010
1025
  client={client}
@@ -1012,7 +1027,7 @@ export function TemplatesView({
1012
1027
  // The workflow can SELECT the template (WORKFLOW_STARTED metadata.template →
1013
1028
  // store.activeTemplate); that overrides the manual pick. Loaded via the
1014
1029
  // official resources/read unoverse://templates/{name} (templates = MCP apps).
1015
- uri={`unoverse://templates/${selectedDef?.org ? `${selectedDef.org}/` : ""}${conn.activeTemplate ?? selected}`}
1030
+ uri={`unoverse://templates/${activeDef?.org ? `${activeDef.org}/` : ""}${activeName}`}
1016
1031
  theme={theme}
1017
1032
  // Voice state arrives via TEMPLATE STATE (the service is a producer inside
1018
1033
  // the renderer); startCall/endCall/toggleMute are answered there too.
package/src/host.ts CHANGED
@@ -113,6 +113,20 @@ class LocalUnoverseClient {
113
113
  return def;
114
114
  }
115
115
 
116
+ /**
117
+ * The SYNCHRONOUS cached-definition peek (see the SDK client). Studio's `readDefinition`
118
+ * fetches every time and keeps no cache — deliberately, so an edit on disk shows up on
119
+ * the next read — so there is nothing to peek and the honest answer is `null`.
120
+ *
121
+ * `null` means "no opinion", never "no": the one caller (the latch derivation on send)
122
+ * treats it as "no latch", which is right here. Studio previews a definition; it has no
123
+ * live conversation to address a component from. Present so the structural contract holds
124
+ * — the SDK takes this client by shape, so a missing method is a crash, not a type error.
125
+ */
126
+ peekDefinition(_uri: string): UnoverseDefinition | null {
127
+ return null;
128
+ }
129
+
116
130
  async readTheme(name = "light"): Promise<ResolvedTheme> {
117
131
  const r = await fetch(`/local/theme?ref=${encodeURIComponent(name)}`);
118
132
  if (!r.ok) throw new Error(`theme ${name}: ${r.status}`);
@@ -9,6 +9,7 @@
9
9
  * vendored SDK (./lib), not the npm package.
10
10
  */
11
11
  import { useEffect, useMemo, useRef, useState } from "react";
12
+ import { postToEmbedHost } from "./mcpAppBridge";
12
13
  import {
13
14
  UnoverseClient,
14
15
  ComponentStore,
@@ -112,7 +113,7 @@ export function AppEngine(cfg: AppConfig) {
112
113
  setAnalyticsDebug(!!analyticsDebug);
113
114
  setAnalyticsContext({ surface, conversation_id: conversationId, user_id: userId });
114
115
  setAnalyticsSink((event) => {
115
- window.parent?.postMessage({ type: "unoverse:event", ...event }, "*");
116
+ postToEmbedHost({ type: "unoverse:event", ...event });
116
117
  });
117
118
  return () => setAnalyticsSink(null);
118
119
  }, [surface, conversationId, userId, analyticsDebug]);
@@ -124,7 +125,7 @@ export function AppEngine(cfg: AppConfig) {
124
125
  const w = computeAppWidth(resolveActiveLayout(def, store), store, theme?.appSize, def);
125
126
  if (w && w !== lastWidthRef.current) {
126
127
  lastWidthRef.current = w;
127
- window.parent?.postMessage({ type: "unoverse:size", width: w }, "*");
128
+ postToEmbedHost({ type: "unoverse:size", width: w });
128
129
  }
129
130
  };
130
131
  report();
@@ -153,10 +154,7 @@ export function AppEngine(cfg: AppConfig) {
153
154
  // reads "offline" forever while this session streams (observed live). Report the
154
155
  // truth the same way size is reported; hosts that don't listen lose nothing.
155
156
  useEffect(() => {
156
- window.parent?.postMessage(
157
- { type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected },
158
- "*",
159
- );
157
+ postToEmbedHost({ type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected });
160
158
  }, [conn.isReady, conn.isConnected]);
161
159
 
162
160
  // VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
@@ -198,7 +196,7 @@ export function AppEngine(cfg: AppConfig) {
198
196
  // The app owns its close chrome (its own top-right X). Tell the embedding host to
199
197
  // dismiss the surface (twin of `unoverse:config`/`unoverse:size`). The host decides
200
198
  // what "close" means for its frame (slide the drawer shut); we don't assume.
201
- window.parent?.postMessage({ type: "unoverse:close" }, "*");
199
+ postToEmbedHost({ type: "unoverse:close" });
202
200
  return;
203
201
  }
204
202
  if (t === "sendMessage") {
@@ -12,7 +12,8 @@
12
12
  * gateway relay and no Redis in the component path.
13
13
  */
14
14
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
15
- import { applyServerMessage, type ComponentStore, type ServerMessage } from "./core";
15
+ import { emitAnalytics } from "./core/analytics";
16
+ import { applyServerMessage, resolveLatch, type ComponentStore, type ServerMessage } from "./core";
16
17
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
18
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18
19
  // Interpreter-based JSON-schema validator (no `new Function`). The SDK's default Ajv
@@ -169,6 +170,16 @@ export function useUnoverseConnection(
169
170
  setIsReady(true);
170
171
  return;
171
172
  }
173
+ // A manifest-declared analytics moment, resolved SERVER-side beside the lifecycle
174
+ // hook that shares it (componentLifecycle.ts). The SDK adds nothing and decides
175
+ // nothing here: it hands the event to the same sink every node-attached emit uses,
176
+ // and the host realm delivers or drops it. News, never instruction — it does not
177
+ // touch the store.
178
+ const evt = msg as unknown as { type?: string; event?: string; params?: Record<string, unknown> };
179
+ if (evt.type === "ANALYTICS_EVENT") {
180
+ if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
181
+ return;
182
+ }
172
183
  applyServerMessage(store, msg, seenRef.current);
173
184
  };
174
185
 
@@ -270,7 +281,52 @@ export function useUnoverseConnection(
270
281
  // WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
271
282
  // (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
272
283
  store.startResponse(turn);
273
- trigger({ message }, turn);
284
+ /**
285
+ * THE LATCH RIDES THE ORDINARY SEND (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
286
+ *
287
+ * Resolved HERE rather than in a host, because both hosts call this one function and
288
+ * two hosts each deciding "what happens on send" is how the composer regression
289
+ * happened. The user turn is still minted normally — the guest said something and it
290
+ * belongs in the timeline; the latch only tells the workflow WHERE the answer lands.
291
+ *
292
+ * `state` is the component's live slice, so the workflow is told what the page
293
+ * currently holds without a round trip, and it stays right after local `setValue`
294
+ * writes the server never saw. Sent as one `latch` object so an unlatched send is
295
+ * byte-for-byte what it was before.
296
+ */
297
+ /**
298
+ * UNFINISHED, and deliberately shipped inert rather than guessed at.
299
+ *
300
+ * `peekDefinition` is the SYNCHRONOUS cached read the send path needs (a send cannot
301
+ * await a resources/read), and it lives on `UnoverseClient` — the wrapper holding the
302
+ * definition cache, constructed in appEngine.tsx. THIS file holds a raw MCP `Client`
303
+ * (line 137), which has no cache and no such method, so the lookup finds nothing here.
304
+ *
305
+ * Optional-called through a narrow type so the shape is honest: null is already a
306
+ * legitimate answer meaning "no opinion", `resolveLatch` is not called for it, and an
307
+ * unlatched send is byte-for-byte what it was before. The day this ref carries the
308
+ * wrapper, latching starts working with no change here.
309
+ */
310
+ const cache = clientRef.current as unknown as
311
+ | { peekDefinition?: (uri: string) => Parameters<typeof resolveLatch>[0] }
312
+ | null;
313
+ const tmpl = store.getActiveTemplate().name;
314
+ const def = tmpl ? (cache?.peekDefinition?.(`unoverse://templates/${tmpl}`) ?? null) : null;
315
+ const held = def ? resolveLatch(def, store) : null;
316
+ trigger(
317
+ held
318
+ ? {
319
+ message,
320
+ latch: {
321
+ chatId: held.chatId,
322
+ nodeId: held.nodeId,
323
+ view: held.view,
324
+ state: store.get(held.chatId, held.nodeId) ?? {},
325
+ },
326
+ }
327
+ : { message },
328
+ turn,
329
+ );
274
330
  },
275
331
  [mintTurn, store, trigger],
276
332
  );
@@ -125,6 +125,19 @@ export class UnoverseClient {
125
125
  return this.withSession(() => this.client.callTool({ name, arguments: args }));
126
126
  }
127
127
 
128
+ /**
129
+ * The cached definition for a URI, or null — SYNCHRONOUS, and it never fetches.
130
+ *
131
+ * For derivations that must answer INSIDE an event, where awaiting is not an option: the
132
+ * latch resolves on send (`sendMessage`), and a send cannot wait on a resources/read.
133
+ * Safe because the only caller asks about the template it is already rendering, which
134
+ * means `readDefinition` resolved before the first frame. Null is a legitimate answer
135
+ * (nothing rendered yet) and every caller must treat it as "no opinion", never as "no".
136
+ */
137
+ peekDefinition(uri: string): UnoverseDefinition | null {
138
+ return this.cache.get(uri) ?? null;
139
+ }
140
+
128
141
  /** resources/read a definition by URI (e.g. unoverse://components/card). Cached. */
129
142
  async readDefinition(uri: string): Promise<UnoverseDefinition> {
130
143
  const cached = this.cache.get(uri);
@@ -141,8 +154,10 @@ export class UnoverseClient {
141
154
  })();
142
155
  this.inflight.set(uri, read);
143
156
  // Cleared either way: a failed read must not be remembered as pending forever, or the
144
- // next asker waits on a promise that will never resolve again.
145
- read.finally(() => this.inflight.delete(uri));
157
+ // next asker waits on a promise that will never resolve again. The catch is REQUIRED:
158
+ // a bare `read.finally()` forks a second promise chain that nobody awaits, so every
159
+ // failed read also surfaced as an Uncaught (in promise) even when the caller handled it.
160
+ read.catch(() => {}).finally(() => this.inflight.delete(uri));
146
161
  return read;
147
162
  }
148
163
 
@@ -164,7 +179,7 @@ export class UnoverseClient {
164
179
  return theme;
165
180
  })();
166
181
  this.themeInflight.set(name, read);
167
- read.finally(() => this.themeInflight.delete(name));
182
+ read.catch(() => {}).finally(() => this.themeInflight.delete(name));
168
183
  return read;
169
184
  }
170
185
 
@@ -42,6 +42,7 @@ export {
42
42
  propDefaults,
43
43
  resolveActiveLayout,
44
44
  resolveActiveView,
45
+ resolveLatch,
45
46
  selectPointers,
46
47
  viewOf,
47
48
  } from "./templates";
@@ -242,6 +242,61 @@ export function resolveActiveView(
242
242
  return { view, byGuest };
243
243
  }
244
244
 
245
+ /**
246
+ * THE LATCH (docs/MCP_COMPLETE_GUIDE.md §The Component Latch) — which component the
247
+ * conversation is currently ADDRESSED AT, or none.
248
+ *
249
+ * DERIVED, never stored, for the same reason `lifecycle` is (STATE_MODEL §3): nothing
250
+ * registers a latch and nothing unregisters one. A component becomes latchable by carrying
251
+ * `latch` on its slice (seeded from its manifest with the render); it stops holding the
252
+ * latch when its slice evicts or leaves the surface. There is no set, no stack, and no
253
+ * "latch this one too" — this function returns ONE holder or null, so two latches are not
254
+ * a bug to prevent but a state that cannot be represented.
255
+ *
256
+ * SAME WALK AS `resolveActiveView`, deliberately: the ladder is the ladder. A latchable
257
+ * slice must be sitting in one of the template's declared states to hold the latch, and
258
+ * the highest-ranked one wins, ties going to the most recent write. So a page retracted to
259
+ * `inline` (superseded by an arrival, released by `cancelBelow`, closed by the guest)
260
+ * silently stops holding it rather than leaving a pill pointing at something off-surface.
261
+ *
262
+ * Returns the FULL key. `chatId` alone reaches the turn, not the component inside it: a
263
+ * turn carrying two components would hydrate the wrong one.
264
+ */
265
+ export function resolveLatch(
266
+ def: UnoverseDefinition | null | undefined,
267
+ store: ComponentStore,
268
+ dismissed?: string,
269
+ ): { key: string; chatId: string; nodeId: string; latch: Record<string, unknown>; view: string } | null {
270
+ const order = def?.stateOrder?.length ? def.stateOrder : undefined;
271
+ if (!order) return null; // no ladder, no ranking — abstain rather than guess a holder
272
+ // `key` is the component key this candidate came from, carried so the return can name the
273
+ // FULL key (line 269) rather than re-deriving it from chatId, which reaches the turn and
274
+ // not the component inside it.
275
+ let best:
276
+ | { key: string; chatId: string; nodeId: string; latch: Record<string, unknown>; view: string; rank: number; at: number }
277
+ | null = null;
278
+ for (const turn of store.getResponses())
279
+ for (const p of turn.components) {
280
+ // THE ✕ IS THE ONLY EXIT, and this is it: the guest dismissed THIS holder by key, so
281
+ // it is passed over and the walk continues to the next candidate. Keyed rather than a
282
+ // bare flag so the dismissal cannot outlive the thing dismissed — a different
283
+ // component taking the latch is a different key and is not affected.
284
+ if (dismissed && dismissed === p) continue;
285
+ const { chatId, nodeId } = store.split(p);
286
+ const slice = store.get(chatId, nodeId) as Record<string, unknown>;
287
+ const latch = slice?.latch;
288
+ if (!latch || typeof latch !== "object") continue;
289
+ const view = viewOf(slice);
290
+ if (!view) continue;
291
+ const rank = order.indexOf(view);
292
+ if (rank < 0) continue; // not on the surface (inline/retracted): holds nothing
293
+ const at = store.touchOf(p);
294
+ if (!best || rank < best.rank || (rank === best.rank && at > best.at))
295
+ best = { key: p, chatId, nodeId, latch: latch as Record<string, unknown>, view, rank, at };
296
+ }
297
+ return best ? { key: best.key, chatId: best.chatId, nodeId: best.nodeId, latch: best.latch, view: best.view } : null;
298
+ }
299
+
245
300
  /** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
246
301
  export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
247
302
  const sel = node.select ?? {};
@@ -23,6 +23,7 @@ import {
23
23
  propDefaults,
24
24
  resolveActiveLayout,
25
25
  resolveActiveView,
26
+ resolveLatch,
26
27
  selectPointers,
27
28
  viewOf,
28
29
  type ComponentStore,
@@ -174,6 +175,52 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
174
175
  // only bumps when it changed something, so this settles in one extra pass.
175
176
  }, [ladder.view, ladder.byGuest, def, store, structureVersion]);
176
177
 
178
+ /**
179
+ * THE LATCH, PUBLISHED FOR CHROME (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
180
+ *
181
+ * `resolveLatch` derives WHICH instance the conversation addresses; the composer's pill
182
+ * is ordinary rx chrome, so it needs those facts as template state to bind against. Same
183
+ * shape as the voice service (§4 "services feed the state"): a producer writes derived
184
+ * values into the normal bucket, and `core` learns no UI concept — nothing here knows a
185
+ * pill exists.
186
+ *
187
+ * `latchDismissed` is written as bare `true` by the ✕ (chrome cannot name a key it does
188
+ * not know) and NORMALISED here to the dismissed holder's key. Keyed, so the dismissal
189
+ * dies with the thing dismissed: a different component taking the latch is a different
190
+ * key, and its pill comes up as it should rather than being suppressed by a stale flag.
191
+ */
192
+ const tstate = store.getTemplateState() as Record<string, unknown>;
193
+ const dismissedRaw = tstate.latchDismissed;
194
+ const held = def ? resolveLatch(def, store, typeof dismissedRaw === "string" ? dismissedRaw : undefined) : null;
195
+ useEffect(() => {
196
+ if (dismissedRaw === true) {
197
+ // Resolve the bare flag against whoever holds it RIGHT NOW, then re-derive.
198
+ const current = def ? resolveLatch(def, store) : null;
199
+ store.mergeTemplateState({ latchDismissed: current ? current.key : "" });
200
+ return;
201
+ }
202
+ const l = held?.latch as { title?: unknown } | undefined;
203
+ const title = typeof l?.title === "string" ? l.title : "";
204
+ // `latched` is the DISCRIMINANT chrome branches on, exactly as the voice service
205
+ // publishes `callState`: ONE field for the axis, never a truthy title plus an inverse
206
+ // guard on its sibling, which is the boolean soup design/state.md rules out ("a case
207
+ // never re-guards its own discriminant"). Title and icon are the held case's DATA, not
208
+ // its condition — which is why they are separate keys and not the condition itself.
209
+ const latched = held != null;
210
+ if (tstate.latched !== latched || tstate.latchTitle !== title || tstate.latchKey !== (held?.key ?? ""))
211
+ store.mergeTemplateState({ latched, latchTitle: title, latchKey: held?.key ?? "" });
212
+ }, [
213
+ held?.key,
214
+ held?.latch,
215
+ dismissedRaw,
216
+ def,
217
+ store,
218
+ tstate.latched,
219
+ tstate.latchTitle,
220
+ tstate.latchKey,
221
+ structureVersion,
222
+ ]);
223
+
177
224
  // Loading/error hints carry NO styling — the SDK owns zero styles.
178
225
  if (error) return <div>Unoverse error: {error}</div>;
179
226
  if (!def || !theme) return <div>Loading {uri}…</div>;
@@ -52,12 +52,12 @@ export interface UseVoiceServiceConfig {
52
52
  /** The single call-phase VALUE a voice template branches its states on — the standard voice
53
53
  * state vocabulary (`visibleWhen: { field: "callState", eq: … }`). Derived from the neutral
54
54
  * facts below so every voice template keys off one field, exactly like `mode` for focus. */
55
- export type VoiceCallState = "idle" | "active" | "agentSpeaking" | "userSpeaking";
55
+ export type VoiceCallState = "idle" | "active" | "speaking" | "listening" | "thinking";
56
56
 
57
57
  /** NEUTRAL voice projections — flat, so the channel can spread them into the template scope. */
58
58
  export interface VoiceServiceState {
59
59
  connectionStatus: VoiceConnectionStatus;
60
- /** The single phase the template's states key off (idle | active | agentSpeaking | userSpeaking). */
60
+ /** The single phase the template's states key off (idle | active | listening | thinking | speaking). */
61
61
  callState: VoiceCallState;
62
62
  isIdle: boolean;
63
63
  isCallActive: boolean;
@@ -259,14 +259,28 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
259
259
  () => ({
260
260
  connectionStatus,
261
261
  // The one phase the template branches on — derived from the neutral facts. Order matters:
262
- // agent speaking wins over user (barge-in), both win over plain "active".
262
+ // agent speaking wins over user (barge-in), both win over a lookup, and all three win
263
+ // over plain "active".
264
+ //
265
+ // `thinking` sits BELOW both speaking checks because someone actually talking is the more
266
+ // important fact: a lookup can still be running while the assistant narrates it ("let me
267
+ // check that for you"), and the surface should show the speech.
268
+ //
269
+ // It sits ABOVE "active" because that is the bug it fixes. A lookup takes 8 to 9 seconds,
270
+ // and for all of it callState read "active" — which every voice template draws as a calm
271
+ // halo meaning "the floor is yours". The orb is the only progress indicator a call has,
272
+ // and it was saying the opposite of what was happening. `isLookingUp` was already right
273
+ // here in this hook, feeding the small lookup caption; it simply never reached the one
274
+ // field the templates branch on.
263
275
  callState: (!isCallActive
264
276
  ? "idle"
265
277
  : isAssistantSpeaking
266
- ? "agentSpeaking"
278
+ ? "speaking"
267
279
  : isUserSpeaking
268
- ? "userSpeaking"
269
- : "active") as VoiceCallState,
280
+ ? "listening"
281
+ : isLookingUp
282
+ ? "thinking"
283
+ : "active") as VoiceCallState,
270
284
  isIdle: !isCallActive,
271
285
  isCallActive,
272
286
  isConnecting: connectionStatus === "connecting",
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { createRoot, type Root } from "react-dom/client";
8
8
  import { AppEngine, type AppConfig } from "./appEngine";
9
+ import { connectMcpAppHost } from "./mcpAppBridge";
9
10
 
10
11
  type HostConfig = Omit<AppConfig, "getAccessToken"> & { token?: string };
11
12
 
@@ -65,51 +66,37 @@ window.addEventListener("message", (e) => {
65
66
  if (msg?.type === "unoverse:config" && msg.config) apply(msg.config);
66
67
  });
67
68
 
68
- // ── ChatGPT (OpenAI Apps) bootstrap ────────────────────────────────────────
69
- // ChatGPT does NOT send our host's `unoverse:config` postMessage; it exposes a
70
- // `window.openai` bridge. Without this the SDK never receives config → #root stays
71
- // empty → blank widget (observed live). `toolOutput` = the tool's structuredContent
72
- // (app id + conversationId); `toolResponseMetadata` = the tool result `_meta`
73
- // (server-forwarded serverUrl + token + userId — widget-only, never seen by the model).
74
- // DIAGNOSTIC-FIRST: log the real runtime shape so we wire against ground truth, not the
75
- // docs' assumed names. `window.openai` may arrive after load, so also listen for its ready
76
- // event. This branch is inert in our own hosts (no window.openai there).
77
- function bootstrapFromOpenAI(): boolean {
78
- const oai = (window as unknown as { openai?: Record<string, unknown> }).openai;
79
- if (!oai) return false;
80
- const out = (oai.toolOutput ?? {}) as Record<string, unknown>;
81
- const meta = (oai.toolResponseMetadata ?? {}) as Record<string, unknown>;
82
- // eslint-disable-next-line no-console
83
- console.log("[unoverse:boot] window.openai present", {
84
- keys: Object.keys(oai),
85
- toolOutput: out,
86
- toolResponseMetadataKeys: Object.keys(meta),
87
- hasToken: Boolean(meta["unoverse/token"]),
88
- });
89
- // serverUrl is SERVER-PROVIDED (the tool result's `_meta.unoverse/serverUrl` = the origin
90
- // that served this bundle). NEVER hardcode/guess it — a baked URL ties the bundle to one
91
- // environment and hides a missing config. If it's absent, fail loudly and don't mount.
92
- const serverUrl = meta["unoverse/serverUrl"] as string;
69
+ // ── MCP Apps host bootstrap (SEP-1865, the STANDARD door) ──────────────────
70
+ // An MCP Apps host (ChatGPT, Claude Desktop, Goose, …) speaks JSON-RPC over postMessage:
71
+ // we send `ui/initialize`, the host pushes `ui/notifications/tool-result`, and the boot
72
+ // facts ride the result's `_meta` (`unoverse/serverUrl` + app + token — widget-only,
73
+ // never seen by the model) with `structuredContent` as the belt-and-braces copy.
74
+ // The legacy `window.openai` config path is REMOVED (2026-08-13): ChatGPT delivers
75
+ // `toolResponseMetadata` empty now, non-OpenAI hosts never had the global, and two boot
76
+ // paths for one host class meant every failure had two explanations. One standard door;
77
+ // our own hosts keep `unoverse:config` / `__UNOVERSE_APP_CONFIG` above.
78
+ connectMcpAppHost((r) => {
79
+ const meta = (r._meta ?? {}) as Record<string, unknown>;
80
+ const out = (r.structuredContent ?? {}) as Record<string, unknown>;
81
+ // serverUrl is SERVER-PROVIDED (`_meta` first, structuredContent as fallback). NEVER
82
+ // hardcode/guess it — a baked URL ties the bundle to one environment and hides a
83
+ // missing config. If it's absent, fail loudly and don't mount.
84
+ const serverUrl = (meta["unoverse/serverUrl"] as string) || (out.serverUrl as string);
93
85
  const templateId = (out.app as string) || (meta["unoverse/app"] as string);
94
86
  const conversationId = (out.conversationId as string) || (meta["unoverse/conversationId"] as string);
95
87
  const token = (meta["unoverse/token"] as string) || undefined;
96
88
  const userId = (meta["unoverse/userId"] as string) || undefined;
97
89
  if (!templateId || !serverUrl) {
98
90
  // eslint-disable-next-line no-console
99
- console.warn("[unoverse:boot] cannot mount — missing serverUrl or app id from window.openai", {
91
+ console.warn("[unoverse:boot] cannot mount — tool result carried no serverUrl/app", {
100
92
  serverUrl,
101
93
  templateId,
102
- toolOutput: out,
103
- toolResponseMetadataKeys: Object.keys(meta),
94
+ structuredContent: out,
95
+ metaKeys: Object.keys(meta),
104
96
  });
105
- return false;
97
+ return;
106
98
  }
107
99
  // eslint-disable-next-line no-console
108
- console.log("[unoverse:boot] mounting", { serverUrl, templateId, conversationId, userId, hasToken: Boolean(token) });
100
+ console.log("[unoverse:boot] mounting from MCP host", { serverUrl, templateId, conversationId, userId, hasToken: Boolean(token) });
109
101
  apply({ serverUrl, templateId, conversationId, userId, token });
110
- return true;
111
- }
112
- if (!bootstrapFromOpenAI()) {
113
- // window.openai can be injected after the bundle loads — retry on its ready signal.
114
- window.addEventListener("openai:set_globals", () => bootstrapFromOpenAI());
115
- }
102
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The MCP Apps host bridge (SEP-1865, spec 2026-01-26) — the STANDARD door.
3
+ *
4
+ * An MCP Apps host (ChatGPT, Claude Desktop, Goose, …) renders this bundle in a sandboxed
5
+ * iframe and speaks JSON-RPC over postMessage: the app sends `ui/initialize`, the host
6
+ * answers with `hostContext`, then pushes `ui/notifications/tool-input` and
7
+ * `ui/notifications/tool-result`. The RESULT notification carries the tool call's full
8
+ * `CallToolResult` — content, structuredContent and `_meta` — and `_meta` is where our
9
+ * server puts the boot facts (`unoverse/serverUrl`, `unoverse/app`, `unoverse/token`).
10
+ * This is the lane that still works everywhere: ChatGPT's legacy `window.openai` globals
11
+ * now arrive with `toolResponseMetadata` EMPTY (observed live 2026-08-13), and non-OpenAI
12
+ * hosts never had them.
13
+ *
14
+ * Deliberately MINIMAL: initialize + the two tool notifications. No display-mode calls, no
15
+ * host-proxied tools/call — the SDK keeps its own /mcp + /stream connections (the CSP's
16
+ * connectDomains exist exactly for this). Extend only against the spec:
17
+ * https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
18
+ */
19
+
20
+ /** Spec revision we speak. Hosts negotiate; the field is required on ui/initialize. */
21
+ const UI_PROTOCOL_VERSION = "2026-01-26";
22
+
23
+ type ToolResult = {
24
+ content?: unknown;
25
+ structuredContent?: Record<string, unknown>;
26
+ _meta?: Record<string, unknown>;
27
+ };
28
+
29
+ export type McpAppHost = {
30
+ /** True once the host answered ui/initialize — the signal that a STANDARD host owns this
31
+ * iframe, used to silence the `unoverse:*` embed-host messages (a standard host validates
32
+ * every inbound postMessage as JSON-RPC and logs ours as errors). */
33
+ connected: boolean;
34
+ hostContext?: Record<string, unknown>;
35
+ };
36
+
37
+ /** Whether ANY MCP app host owns this iframe — standard bridge or legacy window.openai.
38
+ * The embed-host messages (`unoverse:status`/`size`/`event`/`close`) are for OUR OWN
39
+ * parent page and must not be posted at an MCP host. */
40
+ export function hasMcpHost(): boolean {
41
+ return bridge.connected || Boolean((window as { openai?: unknown }).openai);
42
+ }
43
+
44
+ const bridge: McpAppHost = { connected: false };
45
+
46
+ /** Post an `unoverse:*` message to OUR OWN embed host's parent page (status badge, size,
47
+ * close, analytics). At an MCP host these are noise — the sandbox validates every inbound
48
+ * postMessage as JSON-RPC and logs each of ours as an error (observed live in ChatGPT) —
49
+ * so the post is skipped entirely there. `window.openai` counts as host detection ONLY;
50
+ * no config is read from it (the legacy boot path was removed 2026-08-13). */
51
+ export function postToEmbedHost(msg: Record<string, unknown>): void {
52
+ if (hasMcpHost()) return;
53
+ window.parent?.postMessage(msg, "*");
54
+ }
55
+
56
+ /**
57
+ * Open the standard door: send ui/initialize, resolve boot facts from the first
58
+ * tool-result. `onToolResult` fires for EVERY result the host pushes (the first one
59
+ * carries the boot facts; later ones are re-renders of the same app instance).
60
+ */
61
+ export function connectMcpAppHost(onToolResult: (r: ToolResult) => void): McpAppHost {
62
+ const INIT_ID = "unoverse-ui-init";
63
+ // DIAGNOSTIC-FIRST (main.tsx doctrine): we implement the SPEC's dialect, but the live
64
+ // hosts are the ground truth — log a compact line for EVERY inbound message and every
65
+ // handshake attempt so one browser reproduction shows exactly what the host speaks.
66
+ const log = (label: string, extra?: unknown) =>
67
+ // eslint-disable-next-line no-console
68
+ console.log(`[unoverse:bridge] ${label}`, extra ?? "");
69
+ window.addEventListener("message", (e: MessageEvent) => {
70
+ const m = e?.data as
71
+ | { jsonrpc?: string; id?: unknown; result?: unknown; error?: unknown; method?: string; params?: unknown }
72
+ | undefined;
73
+ if (m && typeof m === "object") {
74
+ log("inbound", {
75
+ jsonrpc: m.jsonrpc,
76
+ method: m.method,
77
+ id: m.id,
78
+ // FULL bodies, stringified: the first diagnostics run summarized these to booleans
79
+ // and the one fact that mattered — WHY the host rejected ui/initialize — was in the
80
+ // unprinted error object. Never summarize the field you are debugging.
81
+ result: m.result !== undefined ? JSON.stringify(m.result).slice(0, 600) : undefined,
82
+ error: m.error !== undefined ? JSON.stringify(m.error).slice(0, 600) : undefined,
83
+ params: m.params !== undefined ? JSON.stringify(m.params).slice(0, 300) : undefined,
84
+ ports: e.ports?.length ?? 0,
85
+ origin: e.origin,
86
+ });
87
+ }
88
+ if (m?.jsonrpc !== "2.0") return; // not the host bridge (our own unoverse:config etc.)
89
+ if (m.id === INIT_ID && m.result) {
90
+ bridge.connected = true;
91
+ bridge.hostContext = (m.result as { hostContext?: Record<string, unknown> }).hostContext;
92
+ log("ui/initialize answered", bridge.hostContext);
93
+ // Complete the handshake — the reference App sends this notification after the
94
+ // result (ext-apps src/app.ts `connect`); hosts may hold tool-input/result until it.
95
+ window.parent?.postMessage({ jsonrpc: "2.0", method: "ui/notifications/initialized" }, "*");
96
+ return;
97
+ }
98
+ // Answer pings so the host's liveness check never times the app out.
99
+ if (m.method === "ping" && m.id !== undefined) {
100
+ (e.source as WindowProxy | null)?.postMessage({ jsonrpc: "2.0", id: m.id, result: {} }, "*");
101
+ return;
102
+ }
103
+ if (m.method === "ui/notifications/tool-result") {
104
+ // Per spec the result rides params (CallToolResult); tolerate a bare shape.
105
+ const p = (m.params ?? {}) as { result?: ToolResult } & ToolResult;
106
+ log("tool-result", { metaKeys: Object.keys((p.result ?? p)._meta ?? {}) });
107
+ onToolResult(p.result ?? p);
108
+ }
109
+ });
110
+ const sendInit = () => {
111
+ if (bridge.connected) return;
112
+ log("ui/initialize →");
113
+ window.parent?.postMessage(
114
+ {
115
+ jsonrpc: "2.0",
116
+ id: INIT_ID,
117
+ method: "ui/initialize",
118
+ // Field names per the REFERENCE App (ext-apps src/app.ts), not the docs' prose:
119
+ // `appInfo` + `appCapabilities`. The first cut sent MCP-core's `clientInfo` +
120
+ // `capabilities` and ChatGPT's Zod schema answered -32603 "Invalid input".
121
+ params: {
122
+ protocolVersion: UI_PROTOCOL_VERSION,
123
+ appCapabilities: {},
124
+ appInfo: { name: "unoverse-websdk", version: "0.0.1" },
125
+ },
126
+ },
127
+ "*",
128
+ );
129
+ };
130
+ // The host's listener can attach after our module runs (web hosts boot a sandbox proxy
131
+ // first — `ui/notifications/sandbox-*`), so retry until answered.
132
+ sendInit();
133
+ for (const ms of [250, 1000, 3000]) setTimeout(sendInit, ms);
134
+ return bridge;
135
+ }