@unoverse-platform/studio 0.1.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.
Files changed (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * useUnoverseConnection — the CHANNEL's data-plane connector (MCP stream).
3
+ *
4
+ * Opens the live workflow connection to the Unoverse server's `/stream` MCP endpoint
5
+ * (Streamable-HTTP SSE) for the inbound component stream, and REST `…/execute` for
6
+ * sending messages. Inbound `notifications/unoverse/event` messages feed the single
7
+ * `ComponentStore` via the pure `applyServerMessage` mapper.
8
+ *
9
+ * Per UNOVERSE_MCP_TEMPLATE_PROTOCOL.md §5/§5a/§5b: the component stream rides MCP
10
+ * (NOT the legacy `/ws/gravity` WS — that's reserved for live audio). The node
11
+ * executes in the same Unoverse process that serves this stream, so there's no
12
+ * gateway relay and no Redis in the component path.
13
+ */
14
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
15
+ import { applyServerMessage, type ComponentStore, type ServerMessage } from "./core";
16
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18
+ // Interpreter-based JSON-schema validator (no `new Function`). The SDK's default Ajv
19
+ // provider compiles schemas with `new Function`, which ChatGPT's widget CSP (no
20
+ // unsafe-eval) blocks — that crashed the in-iframe MCP client on boot and left the
21
+ // widget blank. CfWorker is the SDK's own edge/no-eval provider.
22
+ import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
23
+ import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
24
+
25
+ export interface UnoverseConnectionConfig {
26
+ /** Gravity API base, e.g. "http://localhost:4100". REST execute lives here. */
27
+ apiUrl: string;
28
+ /** Unoverse server base, e.g. "http://localhost:4105" (or the workbench origin in
29
+ * dev). The `/stream` MCP data-plane endpoint (§5b) is appended. */
30
+ streamUrl: string;
31
+ /** Optional JWT provider — the token rides the MCP transport fetch + REST Authorization header. */
32
+ getAccessToken?: () => Promise<string | null>;
33
+ }
34
+
35
+ export interface UnoverseSessionParams {
36
+ workflowId: string;
37
+ targetTriggerNode: string;
38
+ userId: string;
39
+ conversationId: string;
40
+ /** Groups one request→response turn. Defaults to conversationId if omitted. */
41
+ chatId?: string;
42
+ }
43
+
44
+ export interface UnoverseConnection {
45
+ isConnected: boolean;
46
+ isReady: boolean;
47
+ /** Composer target: optimistic user turn + REST workflow trigger. */
48
+ sendMessage: (text: string) => void;
49
+ /** Fire the bound workflow WITHOUT a user turn — the "load → run the workflow" ping
50
+ * (its component streams back into the shell). `input` shapes the execute payload per
51
+ * the app's `inputSchema`; omit for a bare trigger. (§5b REST `/execute`; the future
52
+ * MCP-native form is a `tools/call` on the InputTrigger tool, same call site.) */
53
+ trigger: (input?: Record<string, unknown>) => void;
54
+ /** Other component actions (button clicks, etc.) → MCP `user_action` tool. */
55
+ sendAction: (action: string, data: Record<string, unknown>) => void;
56
+ /** Answer a pending ELICITATION with the submitted component state (the native
57
+ * collection path — §3.3). Returns false when no elicitation is pending (the
58
+ * channel then falls back to `sendAction`). */
59
+ resolveElicitation: (state: Record<string, unknown>) => boolean;
60
+ /** The workflow-selected template (MCP app) name, or null. The channel renders
61
+ * `unoverse://templates/{activeTemplate}` when set — the official resources/read load. */
62
+ activeTemplate: string | null;
63
+ }
64
+
65
+ /** The data-plane endpoint on the Unoverse server (§5b). */
66
+ const STREAM_ENDPOINT = "/stream";
67
+ /** Custom notification carrying one ServerMessage as `params` (§5b). */
68
+ const UNOVERSE_EVENT = "notifications/unoverse/event";
69
+
70
+ /**
71
+ * Guest identity — CONTAINED HERE so hosts never write guest logic. A token-less
72
+ * session is a GUEST to the platform's public-entry gate, which only admits
73
+ * `guest-`-prefixed ids (Run Authorization, AUTH_TOKEN_FLOW.md). Minted once per
74
+ * browser and persisted so the visitor's conversation and memory cohere across
75
+ * reloads. A host that supplies its own guest id wins (two apps in one page share
76
+ * one visitor); a host with a token is untouched.
77
+ */
78
+ const GUEST_KEY = "unoverse:guestId";
79
+ function resolveGuestId(): string {
80
+ let id: string | null = null;
81
+ try {
82
+ id = localStorage.getItem(GUEST_KEY);
83
+ } catch {
84
+ /* storage blocked → per-load guest below */
85
+ }
86
+ if (!id || !/^guest[-:]/.test(id)) {
87
+ id = `guest-${crypto.randomUUID()}`;
88
+ try {
89
+ localStorage.setItem(GUEST_KEY, id);
90
+ } catch {
91
+ /* fine: the id just won't survive a reload */
92
+ }
93
+ }
94
+ return id;
95
+ }
96
+
97
+ export function useUnoverseConnection(
98
+ config: UnoverseConnectionConfig,
99
+ session: UnoverseSessionParams,
100
+ store: ComponentStore,
101
+ options: { enabled?: boolean } = {},
102
+ ): UnoverseConnection {
103
+ const enabled = options.enabled ?? true;
104
+ const { streamUrl, getAccessToken } = config;
105
+ const { workflowId, targetTriggerNode, conversationId } = session;
106
+ // No token ⇒ the platform sees a GUEST session and requires a guest-prefixed id —
107
+ // supply one automatically (host-supplied guest ids win; with a token the host's
108
+ // real userId passes through untouched). This is the whole guest story for a host.
109
+ const userId = getAccessToken || /^guest[-:]/.test(session.userId ?? "") ? session.userId : resolveGuestId();
110
+ const chatId = session.chatId ?? conversationId;
111
+
112
+ const [isConnected, setIsConnected] = useState(false);
113
+ const [isReady, setIsReady] = useState(false);
114
+ const clientRef = useRef<Client | null>(null);
115
+ // First-seen component keys for this connection (repeat INIT → props merge).
116
+ const seenRef = useRef<Set<string>>(new Set());
117
+ // The pending elicitation's resolver (one at a time per session) — set while the
118
+ // server is asking this session for structured input; the channel's submit branch
119
+ // answers it via `resolveElicitation`.
120
+ const pendingElicitRef = useRef<((state: Record<string, unknown>) => void) | null>(null);
121
+
122
+ // ---- connection lifecycle (MCP stream) ----
123
+ useEffect(() => {
124
+ if (!enabled) return; // mock mode — no live stream
125
+ if (!workflowId || !userId || !conversationId) {
126
+ console.warn("[unoverse] connection missing workflowId/userId/conversationId");
127
+ return;
128
+ }
129
+ let cancelled = false;
130
+ seenRef.current = new Set();
131
+
132
+ // Declare the ELICITATION capability (form mode) — the native collection path
133
+ // (UNOVERSE_MCP_TEMPLATE_PROTOCOL §3.3): the server pauses inside an app tool
134
+ // call and asks THIS session for the user's structured input. The interactive
135
+ // component already on screen IS the form; its `submit` answers the elicitation.
136
+ const client = new Client(
137
+ { name: "unoverse-channel", version: "0.0.1" },
138
+ { capabilities: { elicitation: {} }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
139
+ );
140
+ client.setRequestHandler(ElicitRequestSchema, async (req) => {
141
+ // Pend until the user submits the component (the channel calls resolveElicitation
142
+ // from its submit branch). The branded UI renders the request — never a generic form.
143
+ // The accept content MUST match the requestedSchema (spec: flat primitives only) —
144
+ // project the submitted component state to the REQUESTED keys, or the SDK rejects
145
+ // the response (view-state like a `courses` array fails the primitive union).
146
+ const requested =
147
+ (req.params as unknown as { requestedSchema?: { properties?: Record<string, unknown> } }).requestedSchema?.properties ?? {};
148
+ return await new Promise((resolve) => {
149
+ pendingElicitRef.current = (state: Record<string, unknown>) => {
150
+ pendingElicitRef.current = null;
151
+ const content: Record<string, unknown> = {};
152
+ for (const k of Object.keys(requested)) if (state[k] !== undefined) content[k] = state[k];
153
+ resolve({ action: "accept", content } as never);
154
+ };
155
+ });
156
+ });
157
+ clientRef.current = client;
158
+
159
+ // The server pushes the live stream as `notifications/unoverse/event` over the
160
+ // GET SSE channel. Custom notification methods land on the fallback handler —
161
+ // route their `params` (one ServerMessage) into the store.
162
+ client.fallbackNotificationHandler = async (notification) => {
163
+ if (notification.method !== UNOVERSE_EVENT) return;
164
+ const msg = (notification.params ?? {}) as unknown as ServerMessage;
165
+ if ((msg as { type?: string }).type === "SESSION_READY") {
166
+ setIsReady(true);
167
+ return;
168
+ }
169
+ applyServerMessage(store, msg, seenRef.current);
170
+ };
171
+
172
+ (async () => {
173
+ // Inject the bearer on every underlying request (fresh per call → refresh-safe).
174
+ const authFetch = getAccessToken
175
+ ? async (url: string | URL, init?: RequestInit): Promise<Response> => {
176
+ const token = await getAccessToken();
177
+ const headers = new Headers(init?.headers);
178
+ if (token) headers.set("Authorization", `Bearer ${token}`);
179
+ return fetch(url, { ...init, headers });
180
+ }
181
+ : undefined;
182
+ try {
183
+ const transport = new StreamableHTTPClientTransport(
184
+ new URL(`${streamUrl}${STREAM_ENDPOINT}`),
185
+ authFetch ? { fetch: authFetch } : undefined,
186
+ );
187
+ await client.connect(transport);
188
+ if (cancelled) {
189
+ await client.close().catch(() => {});
190
+ return;
191
+ }
192
+ setIsConnected(true);
193
+ // Bind this stream to the session — the server then pushes the live component
194
+ // stream and an immediate SESSION_READY.
195
+ const bind = await client.callTool({
196
+ name: "register_session",
197
+ arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId },
198
+ });
199
+ // A refusal (public-entry gate, requires.role, …) is an isError tool RESULT,
200
+ // not a thrown error — surface it, or the session is an eternal silent
201
+ // "Connecting…" (the server logs the same refusal as REFUSED:).
202
+ if ((bind as { isError?: boolean }).isError) {
203
+ const c = (bind as { content?: Array<{ type?: string; text?: string }> }).content;
204
+ console.error("[unoverse] session refused:", c?.[0]?.text ?? "unauthorized");
205
+ }
206
+ } catch (e) {
207
+ if (!cancelled) console.error("[unoverse] stream connect failed", e);
208
+ }
209
+ })();
210
+
211
+ return () => {
212
+ cancelled = true;
213
+ setIsConnected(false);
214
+ setIsReady(false);
215
+ void clientRef.current?.close().catch(() => {});
216
+ clientRef.current = null;
217
+ };
218
+ }, [enabled, streamUrl, workflowId, targetTriggerNode, userId, conversationId, chatId, getAccessToken, store]);
219
+
220
+ // ---- turn identity ----
221
+ // `conversationId` names the CONVERSATION (session addressing, agent memory,
222
+ // elicitation); `chatId` names ONE TURN (the store's response identity — every
223
+ // component of a turn keys `chatId:nodeId`). The channel MINTS a fresh turn id per
224
+ // outbound send; the `<conversationId>:t…` prefix keeps conversation-scoped waiter
225
+ // resolution (elicitation accept) matching turn-scoped node waiters.
226
+ const mintTurn = useCallback(
227
+ () => `${chatId}:t${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`,
228
+ [chatId],
229
+ );
230
+
231
+ // ---- outbound: fire the bound workflow over REST (§5b /execute) ----
232
+ // No user turn — this is the raw trigger. `sendMessage` layers a user turn on top.
233
+ const trigger = useCallback(
234
+ (input?: Record<string, unknown>, turnId?: string) => {
235
+ const turn = turnId ?? mintTurn();
236
+ (async () => {
237
+ // NATIVE MCP: fire the run via a `tools/call` on the live /stream session — NOT
238
+ // the retired REST `/api/workflows/:id/execute` (UNOVERSE_MCP_TEMPLATE_PROTOCOL
239
+ // §0.3). Fire-and-forget: the run's UI streams back over this same session; the
240
+ // caller's token rides the connection (server reads it from extra.authInfo).
241
+ const client = clientRef.current;
242
+ if (!client) {
243
+ console.error("[unoverse] trigger dropped — MCP stream not connected yet");
244
+ return;
245
+ }
246
+ // Same `input` envelope the REST route received (server unwraps it 1:1).
247
+ const inputEnvelope = { chatId: turn, conversationId, userId, providerId: "gravity-ds", metadata: { targetTriggerNode }, ...input };
248
+ try {
249
+ await client.callTool({ name: "trigger", arguments: { workflowId, conversationId, input: inputEnvelope } });
250
+ } catch (e) {
251
+ console.error("[unoverse] trigger failed", e);
252
+ }
253
+ })();
254
+ },
255
+ [workflowId, targetTriggerNode, userId, conversationId, mintTurn],
256
+ );
257
+
258
+ // ---- outbound: send a message (optimistic user turn + trigger) ----
259
+ const sendMessage = useCallback(
260
+ (text: string) => {
261
+ const message = text.trim();
262
+ if (!message) return;
263
+ const turn = mintTurn(); // ONE turn id shared by the user message, the assistant response, and the run
264
+ store.addUserMessage(turn, message); // optimistic user turn
265
+ // Optimistically open the assistant turn too, so the avatar + thinking dots
266
+ // appear IMMEDIATELY on send instead of waiting for the backend's
267
+ // WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
268
+ // (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
269
+ store.startResponse(turn);
270
+ trigger({ message }, turn);
271
+ },
272
+ [mintTurn, store, trigger],
273
+ );
274
+
275
+ // ---- outbound: generic component action → MCP `user_action` tool ----
276
+ const sendAction = useCallback(
277
+ (action: string, data: Record<string, unknown>) => {
278
+ void clientRef.current
279
+ ?.callTool({ name: "user_action", arguments: { action, data, userId, conversationId, chatId } })
280
+ .catch((e) => console.error("[unoverse] user_action failed", e));
281
+ },
282
+ [userId, conversationId, chatId],
283
+ );
284
+
285
+ // The workflow-selected template, reactively (a primitive snapshot so the store
286
+ // subscription doesn't loop). The channel renders it via resources/read.
287
+ // Stable subscribe fn — an inline arrow re-subscribes every render.
288
+ const activeTemplate = useSyncExternalStore(
289
+ useCallback((cb) => store.subscribe(cb), [store]),
290
+ useCallback(() => store.getActiveTemplate().name, [store]),
291
+ );
292
+
293
+ // Answer a pending elicitation with the submitted component state (native path).
294
+ const resolveElicitation = useCallback((state: Record<string, unknown>): boolean => {
295
+ const pending = pendingElicitRef.current;
296
+ if (!pending) return false;
297
+ pending(state);
298
+ return true;
299
+ }, []);
300
+
301
+ return { isConnected, isReady, sendMessage, trigger, sendAction, resolveElicitation, activeTemplate };
302
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The action interpreter (UNOVERSE_SPEC.md §2e-3) — pure state logic, no React,
3
+ * no DOM. Turns a node's `action` (a bare string or a `setValue` envelope) into
4
+ * a store write and/or a server route. This is the ported `updateData`: a LOCAL
5
+ * write into a component's own `chatId:nodeId` slice via the store's existing
6
+ * merge — the generalisation of the composer's `setDraft`.
7
+ *
8
+ * Shape taken from Stac (set_value + Multi Action): `values:[{key,value}]` with
9
+ * `{{binding}}` resolution, an optional chained `then`, and a hard split between
10
+ * LOCAL `setValue` and a server route for anything computed/transactional.
11
+ */
12
+ import type { ComponentStore } from "./store";
13
+ import type { ActionSpec } from "./types";
14
+
15
+ export interface ActionContext {
16
+ store: ComponentStore;
17
+ chatId: string;
18
+ nodeId: string;
19
+ /** Route a non-`setValue` action to the workflow (channel-provided, e.g. USER_ACTION). */
20
+ sendToServer?: (type: string, data: Record<string, unknown>) => void;
21
+ }
22
+
23
+ /** Resolve a value: a `"{{path}}"` template reads from `scope` (dot-path); anything
24
+ * else is a literal. Mirrors Stac's `{{response}}` / `order.status` references. */
25
+ export function resolveValue(value: unknown, scope: Record<string, unknown>): unknown {
26
+ if (typeof value !== "string") return value;
27
+ const m = value.match(/^\{\{\s*([\w.]+)\s*\}\}$/);
28
+ if (!m) return value;
29
+ return m[1]
30
+ .split(".")
31
+ .reduce<unknown>((acc, k) => (acc == null ? undefined : (acc as Record<string, unknown>)[k]), scope);
32
+ }
33
+
34
+ /**
35
+ * Interpret an action (bare string or envelope) against the data scope at the
36
+ * interaction site. `setValue` → local store merge into this `chatId:nodeId`;
37
+ * any other type → the workflow (narrowed by `send`). `then` chains.
38
+ */
39
+ export function dispatchAction(
40
+ action: string | ActionSpec | undefined,
41
+ scope: Record<string, unknown>,
42
+ ctx: ActionContext,
43
+ ): void {
44
+ if (action == null) return;
45
+ const spec: ActionSpec = typeof action === "string" ? { type: action } : action;
46
+
47
+ if (spec.type === "setValue") {
48
+ // Write the dev's chosen keys into THIS COMPONENT's OWN slice (chatId:nodeId) — its own
49
+ // view state: tab / edit / wizard-step / displayState / anything. Read back via
50
+ // `visibleWhen`. Same merge inbound COMPONENT_DATA uses; no server round-trip.
51
+ const patch: Record<string, unknown> = {};
52
+ for (const { key, value } of spec.values ?? []) patch[key] = resolveValue(value, scope);
53
+ // Structure-bumping write (not the deferred COMPONENT_DATA path): templates re-resolve
54
+ // their `select.where` slots the moment a component changes its own state (§5b).
55
+ ctx.store.mergeComponentState(ctx.chatId, ctx.nodeId, patch);
56
+ } else if (spec.type === "setTemplateValue") {
57
+ // Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
58
+ // No component scope. The dev builds disclosure / focus / etc. from a key THEY name
59
+ // (openPanel, focusMode, …) and reads it via `visibleWhen`. The SDK hardcodes NO UI
60
+ // concept — only "write a key to component state" and "write a key to template state".
61
+ const patch: Record<string, unknown> = {};
62
+ for (const { key, value } of spec.values ?? []) patch[key] = resolveValue(value, scope);
63
+ ctx.store.mergeTemplateState(patch);
64
+ } else if (spec.type === "input") {
65
+ // A controlled Input changed — write its bound field back into the state bag that
66
+ // OWNS this tree (local two-way binding; every keystroke is local, never a server
67
+ // round-trip). `scope.field` names the bound field, `scope.text` the new value
68
+ // (InputView). Component scope (real nodeId) → its own slice; template chrome
69
+ // (empty ids) → template state — the composer's `draft` lives there, and writing
70
+ // the slice for an empty id swallows the keystroke (the type-nothing-appears bug).
71
+ // This is the only special-cased event name.
72
+ const field = typeof scope.field === "string" ? scope.field : undefined;
73
+ if (field) {
74
+ if (ctx.nodeId) ctx.store.apply({ type: "COMPONENT_DATA", chatId: ctx.chatId, nodeId: ctx.nodeId, data: { [field]: scope.text } });
75
+ else ctx.store.mergeTemplateState({ [field]: scope.text });
76
+ }
77
+ } else {
78
+ // SERVER — narrow the payload by `send` if given, else forward the whole scope.
79
+ const payload = spec.send ? Object.fromEntries(spec.send.map((k) => [k, scope[k]])) : scope;
80
+ ctx.sendToServer?.(spec.type, payload);
81
+ }
82
+
83
+ if (spec.then) dispatchAction(spec.then, scope, ctx);
84
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @unoverse/core — the MCP client.
3
+ *
4
+ * Reads neutral definitions + the resolved theme from the Unoverse MCP server
5
+ * (resources/read), with a per-request bearer for secured servers. Caches both.
6
+ * No UI framework here.
7
+ */
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
10
+ // CSP-safe (no `new Function`) validator — the default Ajv provider's eval is blocked by
11
+ // ChatGPT's widget CSP. See connection.tsx for the full rationale.
12
+ import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
13
+ import type { UnoverseDefinition, ResolvedTheme } from "./types";
14
+
15
+ export interface UnoverseClientOptions {
16
+ /**
17
+ * Returns a fresh bearer token per request (e.g. an OIDC access_token). The token
18
+ * rides the `Authorization` header on every MCP call so a secured server (default-deny)
19
+ * accepts it. Omit for anonymous/dev (server with AUTH_ENABLED=false). Called per
20
+ * request, so it can return a refreshed token transparently.
21
+ */
22
+ getAccessToken?: () => string | null | undefined | Promise<string | null | undefined>;
23
+ }
24
+
25
+ export class UnoverseClient {
26
+ private client: Client;
27
+ private connected = false;
28
+ private connecting: Promise<void> | null = null;
29
+ private cache = new Map<string, UnoverseDefinition>();
30
+ private themeCache = new Map<string, ResolvedTheme>();
31
+
32
+ constructor(
33
+ private readonly url: string,
34
+ private readonly options: UnoverseClientOptions = {},
35
+ ) {
36
+ this.client = new Client(
37
+ { name: "unoverse-core", version: "0.0.1" },
38
+ { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
39
+ );
40
+ }
41
+
42
+ /** The server ORIGIN this client talks to — `this.url` without the trailing `/mcp`.
43
+ * The one true source for any other lane on the same server (e.g. the audio
44
+ * `/ws/gravity` WS): the SDK derives it HERE so no host has to pass a URL that can be
45
+ * wrong (a sandboxed srcdoc iframe's `location.origin` is the string "null"). */
46
+ get serverOrigin(): string {
47
+ return this.url.replace(/\/mcp\/?$/, "");
48
+ }
49
+
50
+ async connect(): Promise<void> {
51
+ if (this.connected) return;
52
+ // Single shared connect — concurrent readDefinition() calls must not each
53
+ // open a transport ("Already connected to a transport").
54
+ if (!this.connecting) {
55
+ this.connecting = (async () => {
56
+ const getToken = this.options.getAccessToken;
57
+ // Inject the bearer on every underlying request (fresh per call → refresh-safe).
58
+ const authFetch = getToken
59
+ ? async (url: string | URL, init?: RequestInit): Promise<Response> => {
60
+ const token = await getToken();
61
+ const headers = new Headers(init?.headers);
62
+ if (token) headers.set("Authorization", `Bearer ${token}`);
63
+ return fetch(url, { ...init, headers });
64
+ }
65
+ : undefined;
66
+ const transport = new StreamableHTTPClientTransport(
67
+ new URL(this.url),
68
+ authFetch ? { fetch: authFetch } : undefined,
69
+ );
70
+ await this.client.connect(transport);
71
+ this.connected = true;
72
+ })().catch((err) => {
73
+ // Don't cache a FAILED connect (e.g. a 401 before sign-in) — clearing
74
+ // `connecting` lets a later call retry fresh once a token is available.
75
+ this.connecting = null;
76
+ throw err;
77
+ });
78
+ }
79
+ return this.connecting;
80
+ }
81
+
82
+ /** Generic tools/call passthrough (e.g. `user_action` component submissions). */
83
+ async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
84
+ await this.connect();
85
+ return this.client.callTool({ name, arguments: args });
86
+ }
87
+
88
+ /** resources/read a definition by URI (e.g. unoverse://components/Card). Cached. */
89
+ async readDefinition(uri: string): Promise<UnoverseDefinition> {
90
+ const cached = this.cache.get(uri);
91
+ if (cached) return cached;
92
+ await this.connect();
93
+ const res = await this.client.readResource({ uri });
94
+ const first = res.contents[0] as { text?: string };
95
+ if (!first?.text) throw new Error(`No definition content for ${uri}`);
96
+ const def = JSON.parse(first.text) as UnoverseDefinition;
97
+ this.cache.set(uri, def);
98
+ return def;
99
+ }
100
+
101
+ /**
102
+ * resources/read the RESOLVED theme by name (e.g. "light"). The server owns the
103
+ * values (rx/styles); the SDK only fetches — it bundles no tokens. Cached.
104
+ */
105
+ async readTheme(name = "light"): Promise<ResolvedTheme> {
106
+ const cached = this.themeCache.get(name);
107
+ if (cached) return cached;
108
+ await this.connect();
109
+ const res = await this.client.readResource({ uri: `unoverse://theme/${name}` });
110
+ const first = res.contents[0] as { text?: string };
111
+ if (!first?.text) throw new Error(`No theme content for ${name}`);
112
+ const theme = JSON.parse(first.text) as ResolvedTheme;
113
+ this.themeCache.set(name, theme);
114
+ return theme;
115
+ }
116
+
117
+ /** List available theme names (e.g. ["light", "dark"]) from the server. */
118
+ async listThemes(): Promise<string[]> {
119
+ await this.connect();
120
+ const res = await this.client.listResources();
121
+ return res.resources
122
+ .filter((r) => r.uri.startsWith("unoverse://theme/"))
123
+ .map((r) => r.uri.replace("unoverse://theme/", ""));
124
+ }
125
+
126
+ /** List available component definitions (resources/templates + list). */
127
+ async listComponents(): Promise<{ uri: string; name?: string }[]> {
128
+ await this.connect();
129
+ const res = await this.client.listResources();
130
+ return res.resources
131
+ .filter((r) => r.uri.startsWith("unoverse://components/"))
132
+ .map((r) => ({ uri: r.uri, name: r.name }));
133
+ }
134
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The ONE predicate behind every "react to state" mechanism — `visibleWhen`, the
3
+ * `Switch` node, and `style.when`. Keeping it here (pure, framework-agnostic) means the
4
+ * three share identical semantics and there is a single place to test them.
5
+ *
6
+ * This is a SMALL declarative layer, NOT a programming language: a `Condition` picks one
7
+ * of `eq` / `ne` / `in`, or (with none) a truthy test. There is deliberately no
8
+ * `and`/`or`/arithmetic — anything richer is DERIVED in the producing node and sent as a
9
+ * plain field (see UNOVERSE_AUTHORING.md / FRAMEWORK.md).
10
+ */
11
+ import type { Condition, VisibleWhen, UnoverseNode } from "./types";
12
+
13
+ /** Does a `Condition` hold against the data scope? `eq`/`ne`/`in`, else a truthy test. */
14
+ export function matches(cond: Condition, data: Record<string, unknown>): boolean {
15
+ const v = data[cond.field];
16
+ if ("eq" in cond) return v === cond.eq;
17
+ if ("ne" in cond) return v !== cond.ne;
18
+ if ("in" in cond) return Array.isArray(cond.in) && cond.in.includes(v);
19
+ return v != null && v !== "" && v !== false;
20
+ }
21
+
22
+ /** `visibleWhen` semantics: a bare field name is a "has data" test (an empty array counts
23
+ * as empty, so an `Each`-driven section disappears when its list is `[]`); an object is a
24
+ * `Condition`. Returns whether the node should render. */
25
+ export function visible(vw: VisibleWhen, data: Record<string, unknown>): boolean {
26
+ if (typeof vw === "string") {
27
+ const v = data[vw];
28
+ return !(v == null || v === "" || v === false || (Array.isArray(v) && v.length === 0));
29
+ }
30
+ return matches(vw, data);
31
+ }
32
+
33
+ /** `Switch` resolution: the branch keyed by `data[on]` (stringified), else `cases.default`,
34
+ * else `undefined` (render nothing). Pure so it is unit-testable without React. */
35
+ export function selectCase(
36
+ node: { on?: string; cases?: Record<string, UnoverseNode> },
37
+ data: Record<string, unknown>,
38
+ ): UnoverseNode | undefined {
39
+ const cases = node.cases;
40
+ if (!cases) return undefined;
41
+ const key = node.on != null ? String(data[node.on] ?? "") : "";
42
+ return cases[key] ?? cases.default;
43
+ }