@womp/mcp-ui 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.
@@ -0,0 +1,165 @@
1
+ import {
2
+ App,
3
+ PostMessageTransport,
4
+ applyDocumentTheme,
5
+ applyHostStyleVariables,
6
+ } from "@modelcontextprotocol/ext-apps";
7
+ import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
8
+
9
+ /**
10
+ * Shared host-bridge plumbing for every Womp card.
11
+ *
12
+ * Lives in one place because the handshake is subtle and duplicating it across
13
+ * cards is how the two would drift. Three facts it encodes, all learned the
14
+ * hard way:
15
+ *
16
+ * 1. The host mounts the iframe HIDDEN (`visibility: hidden; height: 100px`)
17
+ * and reveals it only after `ui/initialize` -> `ui/notifications/initialized`
18
+ * plus a size report. `app.connect()` does all of that including
19
+ * auto-resize. Skip it and the card renders perfectly, invisibly.
20
+ *
21
+ * 2. Handlers must be attached BEFORE `connect()` or they never fire.
22
+ *
23
+ * 3. Host theme values are NOT CSS custom properties on the document — they
24
+ * arrive over the bridge and the app applies them itself. Every `var()` in
25
+ * the cards therefore ships a fallback.
26
+ */
27
+
28
+ /** Shape returned by swan-backend's GENERATION_OUTPUT_SCHEMA (api/mcp.ts). */
29
+ export interface GenerationResult {
30
+ status?: "complete" | "running";
31
+ generationId?: number;
32
+ operationId?: number;
33
+ imageUrl?: string;
34
+ glbUrl?: string;
35
+ previewUrl?: string;
36
+ }
37
+
38
+ export const $ = (id: string) => document.getElementById(id) as HTMLElement;
39
+
40
+ /** Poll cadence: quick at first, then back off. Generations run 20-60s+. */
41
+ const POLL_START_MS = 2_000;
42
+ const POLL_MAX_MS = 10_000;
43
+ /** Give up eventually rather than spin forever in a hidden iframe. */
44
+ const POLL_CEILING_MS = 6 * 60 * 1000;
45
+
46
+ export interface CardHooks {
47
+ /** Called with every result, running or complete. */
48
+ onResult: (data: GenerationResult) => void;
49
+ /** Called when a poll gives up. */
50
+ onTimeout?: () => void;
51
+ }
52
+
53
+ export interface Card {
54
+ app: App;
55
+ /** Latest result rendered — what action buttons operate on. */
56
+ current: () => GenerationResult | undefined;
57
+ }
58
+
59
+ /**
60
+ * Wire a card to the host: connect, route results, and self-update while a
61
+ * generation is still running.
62
+ *
63
+ * The running state polls `get_generation` through the host, which is allowed
64
+ * because `CallToolRequest` is part of `AppRequest`. That is what lets the tool
65
+ * return immediately instead of blocking the conversation, and it is why the
66
+ * tool must declare `visibility: ["model", "app"]` — without "app" the host
67
+ * refuses the call and the card waits forever.
68
+ */
69
+ export function createCard(name: string, hooks: CardHooks): Card {
70
+ let pollTimer: number | undefined;
71
+ let pollStarted = 0;
72
+ let current: GenerationResult | undefined;
73
+
74
+ const stopPolling = () => {
75
+ if (pollTimer !== undefined) {
76
+ clearTimeout(pollTimer);
77
+ pollTimer = undefined;
78
+ }
79
+ };
80
+
81
+ const app = new App({ name, version: "0.0.1" }, {});
82
+
83
+ const fetchGeneration = async (operationId: number) => {
84
+ const res = await app.request(
85
+ { method: "tools/call", params: { name: "get_generation", arguments: { operationId } } },
86
+ CallToolResultSchema,
87
+ );
88
+ if (res.isError) return undefined;
89
+ return res.structuredContent as GenerationResult | undefined;
90
+ };
91
+
92
+ const poll = (operationId: number, delay: number) => {
93
+ pollTimer = window.setTimeout(async () => {
94
+ if (Date.now() - pollStarted > POLL_CEILING_MS) {
95
+ hooks.onTimeout?.();
96
+ return;
97
+ }
98
+ try {
99
+ const data = await fetchGeneration(operationId);
100
+ if (data?.status === "complete") {
101
+ stopPolling();
102
+ current = data;
103
+ hooks.onResult(data);
104
+ return;
105
+ }
106
+ } catch {
107
+ // A failed poll is not a failed generation. The ceiling above is what
108
+ // stops this eventually.
109
+ }
110
+ poll(operationId, Math.min(delay * 1.5, POLL_MAX_MS));
111
+ }, delay);
112
+ };
113
+
114
+ const handle = (data: GenerationResult | undefined) => {
115
+ if (!data) return;
116
+ stopPolling();
117
+ current = data;
118
+ hooks.onResult(data);
119
+ if (data.status === "running" && data.operationId !== undefined) {
120
+ pollStarted = Date.now();
121
+ poll(data.operationId, POLL_START_MS);
122
+ }
123
+ };
124
+
125
+ // Before connect(), always.
126
+ app.ontoolinput = () => hooks.onResult({ status: "running" });
127
+ app.ontoolresult = (params) => handle(params?.structuredContent as GenerationResult);
128
+ app.onhostcontextchanged = (ctx) => {
129
+ if (ctx?.theme) applyDocumentTheme(ctx.theme);
130
+ if (ctx?.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
131
+ // Cards are capped narrow inline so they do not wall off the transcript;
132
+ // fullscreen has to undo that or Expand looks broken.
133
+ if (ctx?.displayMode) document.documentElement.dataset.displayMode = ctx.displayMode;
134
+ if (ctx?.safeAreaInsets) {
135
+ const { top = 0, right = 0, bottom = 0, left = 0 } = ctx.safeAreaInsets;
136
+ document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
137
+ }
138
+ };
139
+ // Otherwise a closed chat leaves timers — and a WebGL context — running in a
140
+ // hidden iframe.
141
+ app.onteardown = async () => {
142
+ stopPolling();
143
+ return {};
144
+ };
145
+
146
+ app.connect(new PostMessageTransport(window.parent, window.parent)).catch(() => {
147
+ // Nothing to show if this fails: the host never reveals an unconnected
148
+ // frame, so the card is invisible regardless.
149
+ });
150
+
151
+ return { app, current: () => current };
152
+ }
153
+
154
+ /** Ask the host to open a URL; anchors cannot navigate out of the sandbox. */
155
+ export function openLink(app: App, url: string) {
156
+ return app.openLink({ url });
157
+ }
158
+
159
+ /** Toggle fullscreen, trusting the host's answer over our request. */
160
+ export function toggleFullscreen(app: App) {
161
+ const isFull = document.documentElement.dataset.displayMode === "fullscreen";
162
+ return app.requestDisplayMode({ mode: isFull ? "inline" : "fullscreen" }).then((res) => {
163
+ if (res?.mode) document.documentElement.dataset.displayMode = res.mode;
164
+ });
165
+ }