@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.
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/dist/image/index.html +259 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +26 -0
- package/dist/mesh/index.html +4378 -0
- package/package.json +42 -0
- package/src/image/index.html +185 -0
- package/src/image/main.ts +76 -0
- package/src/mesh/index.html +197 -0
- package/src/mesh/main.ts +186 -0
- package/src/shared/bridge.ts +165 -0
|
@@ -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
|
+
}
|