@writepanda/mcp 1.259.0 → 1.262.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,506 @@
1
+ // Shared by the MCP Apps views (edit preview, clips): the host bridge, theme
2
+ // handling and base styles.
3
+ //
4
+ // The views are plain HTML with inline script, so they run in a host's
5
+ // sandboxed iframe with the default CSP (no network, data: images). The
6
+ // functions below are real code serialized with Function.prototype.toString
7
+ // into the page; they must not reference anything outside their own body.
8
+
9
+ /**
10
+ * The view side of MCP Apps (SEP-1865, protocol 2026-01-26): JSON-RPC 2.0
11
+ * over postMessage to the host. Installs `window.PandaKit`.
12
+ *
13
+ * ChatGPT implements the same protocol; when a host doesn't answer
14
+ * ui/initialize but exposes the older `window.openai` bridge, that is used.
15
+ */
16
+ export function installKit(appVersion) {
17
+ const PROTOCOL = "2026-01-26";
18
+ const pending = new Map();
19
+ const listeners = {};
20
+ let nextId = 1;
21
+ let legacy = null;
22
+ const state = {
23
+ hostContext: {},
24
+ hostCapabilities: {},
25
+ toolInput: null,
26
+ toolResult: null,
27
+ };
28
+
29
+ const post = (msg) => window.parent.postMessage(msg, "*");
30
+ const emit = (name, value) => {
31
+ for (const fn of listeners[name] ?? []) {
32
+ try {
33
+ fn(value);
34
+ } catch (err) {
35
+ console.error(err);
36
+ }
37
+ }
38
+ };
39
+
40
+ function request(method, params, timeoutMs = 600_000) {
41
+ const id = nextId++;
42
+ post({ jsonrpc: "2.0", id, method, params });
43
+ return new Promise((resolve, reject) => {
44
+ const timer = setTimeout(() => {
45
+ if (pending.delete(id)) reject(new Error(`${method} timed out`));
46
+ }, timeoutMs);
47
+ pending.set(id, {
48
+ resolve: (v) => {
49
+ clearTimeout(timer);
50
+ resolve(v);
51
+ },
52
+ reject: (e) => {
53
+ clearTimeout(timer);
54
+ reject(e);
55
+ },
56
+ });
57
+ });
58
+ }
59
+ const notify = (method, params) => post({ jsonrpc: "2.0", method, params });
60
+
61
+ function applyContext(ctx) {
62
+ if (!ctx || typeof ctx !== "object") return;
63
+ state.hostContext = { ...state.hostContext, ...ctx };
64
+ const root = document.documentElement;
65
+ if (ctx.theme === "dark" || ctx.theme === "light") {
66
+ root.dataset.theme = ctx.theme;
67
+ root.style.colorScheme = ctx.theme;
68
+ }
69
+ const vars = ctx.styles?.variables;
70
+ if (vars && typeof vars === "object") {
71
+ for (const [k, v] of Object.entries(vars)) {
72
+ if (k.startsWith("--") && typeof v === "string" && v) root.style.setProperty(k, v);
73
+ }
74
+ }
75
+ const fonts = ctx.styles?.css?.fonts;
76
+ if (typeof fonts === "string" && fonts && !document.getElementById("host-fonts")) {
77
+ const style = document.createElement("style");
78
+ style.id = "host-fonts";
79
+ style.textContent = fonts;
80
+ document.head.appendChild(style);
81
+ }
82
+ emit("context", state.hostContext);
83
+ }
84
+
85
+ window.addEventListener("message", (event) => {
86
+ if (event.source !== window.parent) return;
87
+ const msg = event.data;
88
+ if (!msg || msg.jsonrpc !== "2.0") return;
89
+ if (typeof msg.method === "string") {
90
+ if (msg.id !== undefined && msg.id !== null) {
91
+ // A request from the host.
92
+ if (msg.method === "ui/resource-teardown" || msg.method === "ping") {
93
+ emit("teardown");
94
+ post({ jsonrpc: "2.0", id: msg.id, result: {} });
95
+ } else {
96
+ post({
97
+ jsonrpc: "2.0",
98
+ id: msg.id,
99
+ error: { code: -32601, message: `Method not found: ${msg.method}` },
100
+ });
101
+ }
102
+ return;
103
+ }
104
+ const p = msg.params ?? {};
105
+ if (msg.method === "ui/notifications/tool-input") {
106
+ state.toolInput = p.arguments ?? {};
107
+ emit("input", state.toolInput);
108
+ } else if (msg.method === "ui/notifications/tool-result") {
109
+ state.toolResult = p;
110
+ emit("result", p);
111
+ } else if (msg.method === "ui/notifications/tool-cancelled") {
112
+ emit("cancelled", p.reason);
113
+ } else if (msg.method === "ui/notifications/host-context-changed") {
114
+ applyContext(p);
115
+ }
116
+ return;
117
+ }
118
+ const waiter = pending.get(msg.id);
119
+ if (!waiter) return;
120
+ pending.delete(msg.id);
121
+ if (msg.error) waiter.reject(new Error(msg.error.message ?? "Request failed"));
122
+ else waiter.resolve(msg.result);
123
+ });
124
+
125
+ // Keep the iframe as tall as the content (measured like the reference
126
+ // App SDK: html at max-content, once per frame), from initialization on.
127
+ let lastWidth = 0;
128
+ let lastHeight = 0;
129
+ let sizeQueued = false;
130
+ const reportSize = () => {
131
+ if (sizeQueued || legacy) return;
132
+ sizeQueued = true;
133
+ requestAnimationFrame(() => {
134
+ sizeQueued = false;
135
+ const root = document.documentElement;
136
+ const prev = root.style.height;
137
+ root.style.height = "max-content";
138
+ const height = Math.ceil(root.getBoundingClientRect().height);
139
+ root.style.height = prev;
140
+ const width = Math.ceil(window.innerWidth);
141
+ if (width === lastWidth && height === lastHeight) return;
142
+ lastWidth = width;
143
+ lastHeight = height;
144
+ notify("ui/notifications/size-changed", { width, height });
145
+ });
146
+ };
147
+ const watchSize = () => {
148
+ const ro = new ResizeObserver(reportSize);
149
+ ro.observe(document.documentElement);
150
+ ro.observe(document.body);
151
+ reportSize();
152
+ };
153
+
154
+ /** The data of a PandaStudio tool result: structuredContent, else the JSON
155
+ * text block, unwrapped from the automation server's { ok, data }. */
156
+ function resultData(result) {
157
+ if (!result) return null;
158
+ let body = result.structuredContent;
159
+ if (!body) {
160
+ const text = (result.content ?? []).find((c) => c?.type === "text")?.text;
161
+ if (typeof text === "string") {
162
+ try {
163
+ body = JSON.parse(text);
164
+ } catch {
165
+ body = null;
166
+ }
167
+ }
168
+ }
169
+ if (body && typeof body === "object" && "ok" in body && "data" in body) return body.data;
170
+ return body ?? null;
171
+ }
172
+ function resultError(result) {
173
+ if (!result?.isError) return null;
174
+ const data = resultData(result);
175
+ if (data && typeof data === "object" && typeof data.error === "string") return data.error;
176
+ const text = (result.content ?? []).find((c) => c?.type === "text")?.text;
177
+ if (typeof text === "string") {
178
+ try {
179
+ const body = JSON.parse(text);
180
+ if (typeof body?.error === "string") return body.error;
181
+ } catch {
182
+ /* plain text */
183
+ }
184
+ return text.replace(/^tool error: /, "");
185
+ }
186
+ return "Something went wrong";
187
+ }
188
+
189
+ /** Call one of this server's tools; resolves to its data, throws its error. */
190
+ async function callTool(name, args = {}) {
191
+ let result;
192
+ if (legacy) {
193
+ const out = await legacy.callTool(name, args);
194
+ result = out?.content || out?.structuredContent ? out : { structuredContent: out };
195
+ } else {
196
+ result = await request("tools/call", { name, arguments: args });
197
+ }
198
+ const error = resultError(result);
199
+ if (error) throw new Error(error);
200
+ return { data: resultData(result), result };
201
+ }
202
+
203
+ /** Load images the app reported (frames, clip stills) as data: URLs. Paths
204
+ * the server hasn't seen this session come back as null. */
205
+ async function loadImages(paths) {
206
+ const wanted = [...new Set(paths.filter((p) => typeof p === "string" && p))];
207
+ const out = new Map();
208
+ if (!wanted.length) return out;
209
+ const { result } = await callTool("ui_read_media", { paths: wanted });
210
+ const images = (result.content ?? []).filter((c) => c?.type === "image");
211
+ const items = result.structuredContent?.items ?? [];
212
+ for (const item of items) {
213
+ const img = typeof item.image === "number" ? images[item.image] : null;
214
+ out.set(item.path, img ? `data:${img.mimeType};base64,${img.data}` : null);
215
+ }
216
+ return out;
217
+ }
218
+
219
+ const canMessage = () => !legacy && !!state.hostCapabilities.message;
220
+ async function sendMessage(text) {
221
+ if (legacy?.sendFollowUpMessage) return legacy.sendFollowUpMessage({ prompt: text });
222
+ return request("ui/message", { role: "user", content: [{ type: "text", text }] });
223
+ }
224
+ async function updateModelContext(text) {
225
+ if (legacy || !state.hostCapabilities.updateModelContext) return;
226
+ try {
227
+ await request("ui/update-model-context", { content: [{ type: "text", text }] }, 10_000);
228
+ } catch {
229
+ /* best effort */
230
+ }
231
+ }
232
+ const canFullscreen = () =>
233
+ !legacy && (state.hostContext.availableDisplayModes ?? []).includes("fullscreen");
234
+ async function requestDisplayMode(mode) {
235
+ if (legacy?.requestDisplayMode) return legacy.requestDisplayMode({ mode });
236
+ return request("ui/request-display-mode", { mode }, 10_000);
237
+ }
238
+
239
+ function adoptLegacyBridge(openai) {
240
+ legacy = openai;
241
+ state.hostContext = { theme: openai.theme, displayMode: openai.displayMode };
242
+ applyContext(state.hostContext);
243
+ if (openai.toolInput) {
244
+ state.toolInput = openai.toolInput;
245
+ emit("input", state.toolInput);
246
+ }
247
+ const deliver = () => {
248
+ if (!openai.toolOutput) return false;
249
+ state.toolResult = { structuredContent: openai.toolOutput };
250
+ emit("result", state.toolResult);
251
+ return true;
252
+ };
253
+ if (!deliver()) {
254
+ window.addEventListener("openai:set_globals", deliver, { once: true });
255
+ }
256
+ }
257
+
258
+ /** ui/initialize, sent again while unanswered: a host that starts
259
+ * listening after the page loads would otherwise miss it. */
260
+ function initialize() {
261
+ const params = {
262
+ appInfo: { name: "PandaStudio", version: appVersion },
263
+ appCapabilities: { availableDisplayModes: ["inline", "fullscreen"] },
264
+ protocolVersion: PROTOCOL,
265
+ };
266
+ const deadline = Date.now() + (window.openai ? 1500 : 30_000);
267
+ return new Promise((resolve, reject) => {
268
+ let settled = false;
269
+ let tries = 0;
270
+ const attempt = () => {
271
+ if (settled) return;
272
+ tries += 1;
273
+ request("ui/initialize", params, Math.max(250, deadline - Date.now())).then(
274
+ (r) => {
275
+ if (settled) return;
276
+ settled = true;
277
+ resolve(r);
278
+ },
279
+ (err) => {
280
+ if (settled || Date.now() < deadline) return;
281
+ settled = true;
282
+ reject(err);
283
+ },
284
+ );
285
+ if (Date.now() + 500 * tries < deadline) setTimeout(attempt, 500 * tries);
286
+ };
287
+ attempt();
288
+ });
289
+ }
290
+
291
+ async function connect() {
292
+ try {
293
+ const init = await initialize();
294
+ state.hostCapabilities = init?.hostCapabilities ?? {};
295
+ applyContext(init?.hostContext ?? {});
296
+ notify("ui/notifications/initialized", {});
297
+ watchSize();
298
+ } catch (err) {
299
+ if (window.openai) adoptLegacyBridge(window.openai);
300
+ else throw err;
301
+ }
302
+ }
303
+
304
+ window.PandaKit = {
305
+ state,
306
+ on: (name, fn) => {
307
+ if (!listeners[name]) listeners[name] = [];
308
+ listeners[name].push(fn);
309
+ },
310
+ connect,
311
+ callTool,
312
+ loadImages,
313
+ resultData,
314
+ resultError,
315
+ canMessage,
316
+ sendMessage,
317
+ updateModelContext,
318
+ canFullscreen,
319
+ requestDisplayMode,
320
+ toolName: () => state.hostContext.toolInfo?.tool?.name ?? null,
321
+ };
322
+ }
323
+
324
+ /** Small DOM + formatting helpers every view uses (window.PandaUI). */
325
+ export function installUi() {
326
+ const h = (tag, attrs, ...children) => {
327
+ const el = document.createElement(tag);
328
+ for (const [k, v] of Object.entries(attrs ?? {})) {
329
+ if (v === undefined || v === null || v === false) continue;
330
+ if (k === "class") el.className = v;
331
+ else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
332
+ else if (k === "style" && typeof v === "object") {
333
+ for (const [prop, val] of Object.entries(v)) {
334
+ if (prop.startsWith("--")) el.style.setProperty(prop, val);
335
+ else el.style[prop] = val;
336
+ }
337
+ } else el.setAttribute(k, v === true ? "" : String(v));
338
+ }
339
+ for (const c of children.flat()) {
340
+ if (c === null || c === undefined || c === false) continue;
341
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
342
+ }
343
+ return el;
344
+ };
345
+ const duration = (ms) => {
346
+ const total = Math.max(0, Math.round((ms ?? 0) / 1000));
347
+ const hr = Math.floor(total / 3600);
348
+ const m = Math.floor((total % 3600) / 60);
349
+ const s = String(total % 60).padStart(2, "0");
350
+ return hr > 0 ? `${hr}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
351
+ };
352
+ /** A button that disables itself and shows a busy label while `run` works. */
353
+ const actionButton = (label, run, opts = {}) => {
354
+ const btn = h("button", {
355
+ class: opts.primary ? "btn primary" : "btn",
356
+ type: "button",
357
+ title: opts.title,
358
+ });
359
+ btn.textContent = label;
360
+ btn.addEventListener("click", async (e) => {
361
+ e.stopPropagation();
362
+ if (btn.disabled) return;
363
+ btn.disabled = true;
364
+ const prev = btn.textContent;
365
+ if (opts.busy) btn.textContent = opts.busy;
366
+ try {
367
+ await run(btn);
368
+ } catch (err) {
369
+ window.PandaUI.toast(err?.message ?? String(err), true);
370
+ } finally {
371
+ if (btn.isConnected) {
372
+ btn.disabled = false;
373
+ if (btn.textContent === opts.busy) btn.textContent = prev;
374
+ }
375
+ }
376
+ });
377
+ return btn;
378
+ };
379
+ let toastTimer = 0;
380
+ const toast = (text, isError = false) => {
381
+ let el = document.getElementById("toast");
382
+ if (!el) {
383
+ el = h("div", { id: "toast", role: "status" });
384
+ document.body.append(el);
385
+ }
386
+ el.textContent = text;
387
+ el.className = isError ? "show error" : "show";
388
+ clearTimeout(toastTimer);
389
+ toastTimer = setTimeout(() => {
390
+ el.className = "";
391
+ }, 4000);
392
+ };
393
+ window.PandaUI = { h, duration, actionButton, toast };
394
+ }
395
+
396
+ export const BASE_CSS = `
397
+ :root {
398
+ color-scheme: light dark;
399
+ --bg: var(--color-background-primary, #ffffff);
400
+ --bg2: var(--color-background-secondary, #f4f4f5);
401
+ --bg3: var(--color-background-tertiary, #e9e9ec);
402
+ --fg: var(--color-text-primary, #18181b);
403
+ --fg2: var(--color-text-secondary, #52525b);
404
+ --fg3: var(--color-text-tertiary, #8a8a93);
405
+ --line: var(--color-border-primary, rgba(0, 0, 0, 0.12));
406
+ --line2: var(--color-border-secondary, rgba(0, 0, 0, 0.07));
407
+ --danger: var(--color-text-danger, #c2410c);
408
+ --success: var(--color-text-success, #15803d);
409
+ --accent: #ff5a1f;
410
+ --radius: var(--border-radius-md, 8px);
411
+ --radius-lg: var(--border-radius-lg, 12px);
412
+ --font: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
413
+ }
414
+ @media (prefers-color-scheme: dark) {
415
+ :root:not([data-theme="light"]) {
416
+ --bg: var(--color-background-primary, #1c1c1f);
417
+ --bg2: var(--color-background-secondary, #26262a);
418
+ --bg3: var(--color-background-tertiary, #303035);
419
+ --fg: var(--color-text-primary, #f4f4f5);
420
+ --fg2: var(--color-text-secondary, #b4b4bb);
421
+ --fg3: var(--color-text-tertiary, #85858d);
422
+ --line: var(--color-border-primary, rgba(255, 255, 255, 0.14));
423
+ --line2: var(--color-border-secondary, rgba(255, 255, 255, 0.08));
424
+ --danger: var(--color-text-danger, #fb923c);
425
+ --success: var(--color-text-success, #4ade80);
426
+ }
427
+ }
428
+ :root[data-theme="dark"] {
429
+ --bg: var(--color-background-primary, #1c1c1f);
430
+ --bg2: var(--color-background-secondary, #26262a);
431
+ --bg3: var(--color-background-tertiary, #303035);
432
+ --fg: var(--color-text-primary, #f4f4f5);
433
+ --fg2: var(--color-text-secondary, #b4b4bb);
434
+ --fg3: var(--color-text-tertiary, #85858d);
435
+ --line: var(--color-border-primary, rgba(255, 255, 255, 0.14));
436
+ --line2: var(--color-border-secondary, rgba(255, 255, 255, 0.08));
437
+ --danger: var(--color-text-danger, #fb923c);
438
+ --success: var(--color-text-success, #4ade80);
439
+ }
440
+ * { box-sizing: border-box; }
441
+ html, body { margin: 0; padding: 0; }
442
+ body {
443
+ background: var(--bg);
444
+ color: var(--fg);
445
+ font: 14px/1.45 var(--font);
446
+ -webkit-font-smoothing: antialiased;
447
+ }
448
+ #app { padding: 14px; }
449
+ .row { display: flex; align-items: center; gap: 8px; }
450
+ .spread { justify-content: space-between; }
451
+ .wrap { flex-wrap: wrap; }
452
+ .muted { color: var(--fg2); }
453
+ .faint { color: var(--fg3); }
454
+ .small { font-size: 12px; }
455
+ h1 { font-size: 15px; font-weight: 600; margin: 0; line-height: 1.3; }
456
+ .brand { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600;
457
+ letter-spacing: 0.04em; text-transform: uppercase; color: var(--fg3); }
458
+ .brand i { width: 8px; height: 8px; border-radius: 2px; background: var(--accent); display: inline-block; }
459
+ .btn {
460
+ font: inherit; font-size: 13px; font-weight: 500; color: var(--fg); background: var(--bg2);
461
+ border: 1px solid var(--line); border-radius: var(--radius); padding: 5px 11px; cursor: pointer;
462
+ white-space: nowrap;
463
+ }
464
+ .btn:hover:not(:disabled) { background: var(--bg3); }
465
+ .btn:disabled { opacity: 0.55; cursor: default; }
466
+ .btn.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); }
467
+ .btn.primary:hover:not(:disabled) { opacity: 0.88; background: var(--fg); }
468
+ .btn.danger { color: var(--danger); }
469
+ .btn.ghost { background: transparent; border-color: transparent; padding: 4px 6px; }
470
+ .btn.ghost:hover:not(:disabled) { background: var(--bg2); }
471
+ .chip { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 3px 9px;
472
+ border-radius: 999px; background: var(--bg2); border: 1px solid var(--line2); color: var(--fg2); }
473
+ .chip.strong { color: var(--fg); font-weight: 500; }
474
+ .skeleton { background: linear-gradient(90deg, var(--bg2), var(--bg3), var(--bg2)); background-size: 200% 100%;
475
+ animation: shimmer 1.4s infinite linear; }
476
+ @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
477
+ .bar { height: 4px; border-radius: 2px; background: var(--bg3); overflow: hidden; }
478
+ .bar > div { height: 100%; background: var(--accent); transition: width 0.4s ease; }
479
+ .error { color: var(--danger); }
480
+ #toast { position: fixed; left: 50%; bottom: 12px; transform: translate(-50%, 20px); opacity: 0;
481
+ background: var(--fg); color: var(--bg); padding: 7px 12px; border-radius: var(--radius); font-size: 13px;
482
+ transition: all 0.2s ease; pointer-events: none; max-width: calc(100% - 24px); }
483
+ #toast.show { opacity: 1; transform: translate(-50%, 0); }
484
+ #toast.error { background: var(--danger); color: #fff; }
485
+ `;
486
+
487
+ /** One self-contained view page. `main` runs once the kit is connected. */
488
+ export function viewPage({ title, version, css, main }) {
489
+ return `<!doctype html>
490
+ <html lang="en">
491
+ <head>
492
+ <meta charset="utf-8">
493
+ <meta name="viewport" content="width=device-width, initial-scale=1">
494
+ <title>${title}</title>
495
+ <style>${BASE_CSS}${css}</style>
496
+ </head>
497
+ <body>
498
+ <div id="app"></div>
499
+ <script>
500
+ (${installKit.toString()})(${JSON.stringify(version)});
501
+ (${installUi.toString()})();
502
+ (${main.toString()})(window.PandaKit, window.PandaUI);
503
+ </script>
504
+ </body>
505
+ </html>`;
506
+ }