@aprovan/patchwork-mcp 0.1.0-dev.030eb3d

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 (50) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +15 -0
  3. package/dist/index.d.ts +128 -0
  4. package/dist/index.js +989 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/dist/runtime/assets/index-CSnUKUMa.js +1058 -0
  8. package/dist/runtime/index.html +38 -0
  9. package/dist/server.d.ts +6 -0
  10. package/dist/server.js +1400 -0
  11. package/dist/server.js.map +1 -0
  12. package/dist/shell/shell.js +67 -0
  13. package/docs/widget-preview.png +0 -0
  14. package/e2e/__snapshots__/.gitkeep +0 -0
  15. package/e2e/global-setup.ts +114 -0
  16. package/e2e/global-teardown.ts +15 -0
  17. package/e2e/visual-regression.test.ts +86 -0
  18. package/e2e/widget-smoke.test.ts +109 -0
  19. package/index.html +32 -0
  20. package/package.json +55 -0
  21. package/playwright.config.ts +43 -0
  22. package/src/__tests__/live-update.test.ts +158 -0
  23. package/src/__tests__/local-backend.test.ts +100 -0
  24. package/src/__tests__/memory-backend.ts +144 -0
  25. package/src/__tests__/services.test.ts +153 -0
  26. package/src/__tests__/shim.test.ts +60 -0
  27. package/src/__tests__/widget-store.test.ts +188 -0
  28. package/src/e2e-visual.ts +148 -0
  29. package/src/index.ts +608 -0
  30. package/src/live-update.ts +150 -0
  31. package/src/logger.ts +44 -0
  32. package/src/reference-widgets/live-dashboard.ts +162 -0
  33. package/src/registry-backend.ts +47 -0
  34. package/src/runtime/index.html +38 -0
  35. package/src/runtime/main.ts +164 -0
  36. package/src/scripts/export-artifacts.ts +51 -0
  37. package/src/server.ts +399 -0
  38. package/src/services.ts +307 -0
  39. package/src/shell/main.ts +172 -0
  40. package/src/shim.ts +106 -0
  41. package/src/tunnel.ts +123 -0
  42. package/src/widget-store/index.ts +10 -0
  43. package/src/widget-store/local-backend.ts +77 -0
  44. package/src/widget-store/store.ts +242 -0
  45. package/src/widget-store/types.ts +53 -0
  46. package/tsconfig.json +10 -0
  47. package/tsup.config.ts +14 -0
  48. package/vite.runtime.config.ts +28 -0
  49. package/vite.shell.config.ts +30 -0
  50. package/vitest.config.ts +7 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Patchwork MCP App "shell".
3
+ *
4
+ * This is the document Claude loads as the MCP App resource. Per the MCP Apps
5
+ * protocol the resource document itself must be the app that connects to the
6
+ * host (`App.connect()` → `window.parent`); it runs under a strict CSP with no
7
+ * `unsafe-eval`, so it cannot run the esbuild-wasm compiler.
8
+ *
9
+ * So the shell stays light: it runs the ext-apps {@link App} (handshake + sizing
10
+ * + host bridge) and embeds the Patchwork runtime in a nested, CSP-free iframe
11
+ * served from the widget host. The runtime compiles the widget in the browser
12
+ * and mounts it; the widget's `window.patchwork.*` / service-namespace calls are
13
+ * forwarded up here over postMessage and relayed to the host:
14
+ *
15
+ * widget (runtime iframe) ──postMessage──▶ shell (App) ──ext-apps──▶ host
16
+ */
17
+ import { App } from "@modelcontextprotocol/ext-apps";
18
+
19
+ interface ShellConfig {
20
+ /** Base URL of the runtime host page, e.g. https://host/runtime/ */
21
+ runtime: string;
22
+ /** "<name>/<hash>" of the saved widget to render. */
23
+ widget: string;
24
+ /** Startup props passed to the widget. */
25
+ inputs: Record<string, unknown>;
26
+ }
27
+
28
+ // Minimal MCP tool-result shape (avoids depending on SDK types in the bundle).
29
+ interface ToolResult {
30
+ isError?: boolean;
31
+ content?: Array<{ type: string; text?: string }>;
32
+ }
33
+
34
+ function readConfig(): ShellConfig {
35
+ const el = document.currentScript as HTMLScriptElement | null;
36
+ const raw = el?.dataset["config"] ?? "";
37
+ try {
38
+ const json = decodeURIComponent(escape(atob(raw)));
39
+ return JSON.parse(json) as ShellConfig;
40
+ } catch {
41
+ return { runtime: "", widget: "", inputs: {} };
42
+ }
43
+ }
44
+
45
+ function parseResult(result: ToolResult): unknown {
46
+ const textBlock = result?.content?.find((c) => c.type === "text");
47
+ if (textBlock?.text !== undefined) {
48
+ try {
49
+ return JSON.parse(textBlock.text);
50
+ } catch {
51
+ return textBlock.text;
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ function boot(): void {
58
+ const config = readConfig();
59
+
60
+ const app = new App({ name: "patchwork-widget", version: "0.1.0" });
61
+
62
+ // Nested, CSP-free runtime iframe (compiles + mounts the widget in-browser).
63
+ const iframe = document.createElement("iframe");
64
+ iframe.style.cssText = "width:100%;border:none;display:block;";
65
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
66
+ const query = new URLSearchParams({
67
+ widget: config.widget,
68
+ inputs: JSON.stringify(config.inputs ?? {}),
69
+ });
70
+ iframe.src = `${config.runtime}?${query.toString()}`;
71
+ (document.getElementById("pw-root") ?? document.body).appendChild(iframe);
72
+
73
+ const post = (msg: Record<string, unknown>): void => {
74
+ iframe.contentWindow?.postMessage({ source: "patchwork-host", ...msg }, "*");
75
+ };
76
+
77
+ // Per-stream cursor for live updates.
78
+ const streams = new Map<string, number>();
79
+
80
+ const pollStream = async (stream: string): Promise<void> => {
81
+ const afterSeq = streams.get(stream) ?? 0;
82
+ try {
83
+ const res = (await app.callServerTool({
84
+ name: "poll_updates",
85
+ arguments: { stream, after_seq: afterSeq },
86
+ })) as ToolResult;
87
+ const parsed = parseResult(res) as { events?: Array<{ seq: number; data: unknown }> };
88
+ if (!parsed?.events?.length) return;
89
+ for (const ev of parsed.events) {
90
+ if (ev.seq > (streams.get(stream) ?? 0)) streams.set(stream, ev.seq);
91
+ post({ kind: "stream-event", stream, data: ev.data, seq: ev.seq });
92
+ }
93
+ } catch (err) {
94
+ console.warn("[patchwork-shell] poll_updates failed:", err);
95
+ }
96
+ };
97
+
98
+ const callTool = async (id: string, name: string, args: unknown): Promise<void> => {
99
+ try {
100
+ const res = (await app.callServerTool({
101
+ name,
102
+ arguments: (args as Record<string, unknown>) ?? {},
103
+ })) as ToolResult;
104
+ if (res.isError) {
105
+ const text = res.content?.find((c) => c.type === "text")?.text;
106
+ post({ kind: "result", id, ok: false, error: text ?? `Tool call failed: ${name}` });
107
+ } else {
108
+ post({ kind: "result", id, ok: true, value: parseResult(res) });
109
+ }
110
+ } catch (err) {
111
+ post({ kind: "result", id, ok: false, error: err instanceof Error ? err.message : String(err) });
112
+ }
113
+ };
114
+
115
+ window.addEventListener("message", (event: MessageEvent) => {
116
+ if (event.source !== iframe.contentWindow) return;
117
+ const m = event.data as Record<string, unknown> | undefined;
118
+ if (!m || m["source"] !== "patchwork") return;
119
+
120
+ switch (m["kind"]) {
121
+ case "size": {
122
+ const h = m["height"];
123
+ if (typeof h === "number" && h > 0) iframe.style.height = `${h}px`;
124
+ break;
125
+ }
126
+ case "service":
127
+ void callTool(m["id"] as string, `${m["namespace"]}__${m["procedure"]}`, m["args"]);
128
+ break;
129
+ case "fire":
130
+ void callTool(m["id"] as string, m["toolName"] as string, m["args"]);
131
+ break;
132
+ case "subscribe": {
133
+ const stream = m["stream"] as string;
134
+ if (streams.has(stream)) break;
135
+ streams.set(stream, 0);
136
+ void app
137
+ .callServerTool({ name: "subscribe_stream", arguments: { stream } })
138
+ .then((res) => {
139
+ const parsed = parseResult(res as ToolResult) as { seq?: number };
140
+ if (typeof parsed?.seq === "number") streams.set(stream, parsed.seq);
141
+ })
142
+ .catch((err) => console.warn("[patchwork-shell] subscribe_stream failed:", err));
143
+ break;
144
+ }
145
+ case "context": {
146
+ const content = m["content"];
147
+ const params =
148
+ typeof content === "string"
149
+ ? { content: [{ type: "text", text: content }] }
150
+ : Array.isArray(content)
151
+ ? { content }
152
+ : { structuredContent: content as Record<string, unknown> };
153
+ void app.updateModelContext(params).catch(() => {});
154
+ break;
155
+ }
156
+ }
157
+ });
158
+
159
+ // When the server signals new data (notifications/tools/list_changed), poll
160
+ // every subscribed stream and forward fresh events down to the widget.
161
+ app.fallbackNotificationHandler = async (notification: { method: string }) => {
162
+ if (notification.method === "notifications/tools/list_changed") {
163
+ for (const stream of streams.keys()) await pollStream(stream);
164
+ }
165
+ };
166
+
167
+ app.connect().catch((err) => {
168
+ console.error("[patchwork-shell] failed to connect to host:", err);
169
+ });
170
+ }
171
+
172
+ boot();
package/src/shim.ts ADDED
@@ -0,0 +1,106 @@
1
+ export interface BridgeShimOptions {
2
+ /** Service namespaces the widget calls (e.g. ["weather", "stripe"]). */
3
+ namespaces: string[];
4
+ }
5
+
6
+ /**
7
+ * Generate the runtime-side bridge shim.
8
+ *
9
+ * This runs inside the CSP-free runtime iframe (the nested frame that compiles
10
+ * and mounts the widget). It exposes `window.patchwork` and a proxy per service
11
+ * namespace, but instead of talking to the MCP host directly — the runtime
12
+ * iframe is one level removed from the host and can't — it forwards every call
13
+ * to the parent shell over `postMessage`. The shell holds the ext-apps `App`
14
+ * and relays to the host:
15
+ *
16
+ * widget → window.patchwork.* / namespace.* → postMessage(parent) → shell → host
17
+ *
18
+ * Messages sent to the parent: `{ source: 'patchwork', kind, ... }`.
19
+ * Messages received from the parent: `{ source: 'patchwork-host', kind, ... }`.
20
+ */
21
+ export function generateBridgeShim(options: BridgeShimOptions): string {
22
+ const namespacesJson = JSON.stringify(options.namespaces ?? []);
23
+
24
+ return `
25
+ (function () {
26
+ if (window.patchwork) return; // guard against double-injection
27
+
28
+ var __pending = {};
29
+ var __seq = 0;
30
+ var __streams = {};
31
+
32
+ function __post(msg) {
33
+ msg.source = 'patchwork';
34
+ window.parent.postMessage(msg, '*');
35
+ }
36
+
37
+ function __request(payload) {
38
+ return new Promise(function (resolve, reject) {
39
+ var id = 'r' + (++__seq);
40
+ __pending[id] = { resolve: resolve, reject: reject };
41
+ payload.id = id;
42
+ __post(payload);
43
+ });
44
+ }
45
+
46
+ window.addEventListener('message', function (e) {
47
+ var m = e.data;
48
+ if (!m || m.source !== 'patchwork-host') return;
49
+ if (m.kind === 'result' && m.id && __pending[m.id]) {
50
+ var p = __pending[m.id];
51
+ delete __pending[m.id];
52
+ if (m.ok) p.resolve(m.value);
53
+ else p.reject(new Error(m.error || 'Service call failed'));
54
+ } else if (m.kind === 'stream-event') {
55
+ var cbs = __streams[m.stream];
56
+ if (cbs) {
57
+ for (var i = 0; i < cbs.length; i++) {
58
+ try { cbs[i](m.data, m.seq, m.stream); }
59
+ catch (err) { console.error('[patchwork] subscribe callback error:', err); }
60
+ }
61
+ }
62
+ }
63
+ });
64
+
65
+ window.patchwork = {
66
+ /** Subscribe to a named server data stream. Returns an unsubscribe fn. */
67
+ subscribe: function (stream, cb) {
68
+ if (!__streams[stream]) {
69
+ __streams[stream] = [];
70
+ __post({ kind: 'subscribe', stream: stream });
71
+ }
72
+ __streams[stream].push(cb);
73
+ return function () {
74
+ var a = __streams[stream];
75
+ if (!a) return;
76
+ var i = a.indexOf(cb);
77
+ if (i !== -1) a.splice(i, 1);
78
+ };
79
+ },
80
+ /** Push widget state into the conversation context. */
81
+ updateContext: function (content) {
82
+ __post({ kind: 'context', content: content });
83
+ return Promise.resolve();
84
+ },
85
+ /** Fire a client event as an MCP tool call and await its result. */
86
+ fireEvent: function (toolName, args) {
87
+ return __request({ kind: 'fire', toolName: toolName, args: args || {} });
88
+ }
89
+ };
90
+
91
+ var __namespaces = ${namespacesJson};
92
+ for (var __i = 0; __i < __namespaces.length; __i++) {
93
+ (function (ns) {
94
+ window[ns] = new Proxy({}, {
95
+ get: function (target, prop) {
96
+ if (typeof prop !== 'string') return undefined;
97
+ return function (args) {
98
+ return __request({ kind: 'service', namespace: ns, procedure: prop, args: args || {} });
99
+ };
100
+ }
101
+ });
102
+ })(__namespaces[__i]);
103
+ }
104
+ })();
105
+ `;
106
+ }
package/src/tunnel.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { log, error } from "./logger.js";
3
+
4
+ let tunnelProcess: ChildProcess | null = null;
5
+ let tunnelUrl: string | null = null;
6
+
7
+ /**
8
+ * Poll the tunnel's public `/health` endpoint until it returns 200.
9
+ *
10
+ * cloudflared prints the `*.trycloudflare.com` URL as soon as the process
11
+ * registers, but the edge route is frequently not live yet — requests in that
12
+ * window return Cloudflare error 1033 ("unable to resolve"). Publishing the URL
13
+ * before it's actually reachable bakes a dead host into rendered widgets, so we
14
+ * verify reachability before adopting it.
15
+ */
16
+ async function waitForTunnelLive(url: string, timeoutMs = 30000): Promise<boolean> {
17
+ const deadline = Date.now() + timeoutMs;
18
+ while (Date.now() < deadline) {
19
+ try {
20
+ const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
21
+ if (res.ok) return true;
22
+ } catch {
23
+ // not reachable yet — keep polling
24
+ }
25
+ await new Promise((r) => setTimeout(r, 1000));
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * Start a cloudflare tunnel to expose a local port publicly.
32
+ * Returns the public URL once the tunnel is established AND verified reachable.
33
+ */
34
+ export async function startTunnel(port: number): Promise<string> {
35
+ if (tunnelUrl) return tunnelUrl;
36
+
37
+ const detectedUrl = await new Promise<string>((resolve, reject) => {
38
+ const args = ["tunnel", "--url", `http://localhost:${port}`];
39
+
40
+ log("tunnel", `Starting cloudflared tunnel for port ${port}...`);
41
+
42
+ tunnelProcess = spawn("cloudflared", args, {
43
+ stdio: ["ignore", "pipe", "pipe"],
44
+ });
45
+
46
+ let resolved = false;
47
+ const timeout = setTimeout(() => {
48
+ if (!resolved) {
49
+ resolved = true;
50
+ reject(new Error("Tunnel startup timed out after 30s"));
51
+ }
52
+ }, 30000);
53
+
54
+ const handleOutput = (data: Buffer) => {
55
+ const text = data.toString();
56
+
57
+ // Look for the tunnel URL in the output
58
+ // cloudflared outputs: "https://xxx.trycloudflare.com"
59
+ const urlMatch = text.match(/https:\/\/[^\s]+\.trycloudflare\.com\/?/);
60
+ if (urlMatch && !resolved) {
61
+ resolved = true;
62
+ clearTimeout(timeout);
63
+ resolve(urlMatch[0].replace(/\/$/, ""));
64
+ }
65
+ };
66
+
67
+ tunnelProcess.stdout?.on("data", handleOutput);
68
+ tunnelProcess.stderr?.on("data", handleOutput);
69
+
70
+ tunnelProcess.on("error", (err) => {
71
+ if (!resolved) {
72
+ resolved = true;
73
+ clearTimeout(timeout);
74
+ error("tunnel", "Failed to start cloudflared:", err);
75
+ reject(err);
76
+ }
77
+ });
78
+
79
+ tunnelProcess.on("exit", (code) => {
80
+ if (!resolved) {
81
+ resolved = true;
82
+ clearTimeout(timeout);
83
+ reject(new Error(`cloudflared exited with code ${code}`));
84
+ }
85
+ tunnelProcess = null;
86
+ tunnelUrl = null;
87
+ });
88
+ });
89
+
90
+ // Verify-then-publish: don't adopt the hostname until the edge route is live,
91
+ // otherwise rendered widgets reference a host that returns Cloudflare 1033.
92
+ log("tunnel", `Tunnel hostname detected (${detectedUrl}); verifying reachability...`);
93
+ const live = await waitForTunnelLive(detectedUrl);
94
+ if (!live) {
95
+ error(
96
+ "tunnel",
97
+ `Tunnel ${detectedUrl} did not become reachable within 30s — publishing anyway, but widgets may fail to load until the edge route propagates.`,
98
+ );
99
+ } else {
100
+ log("tunnel", `Tunnel verified reachable: ${detectedUrl}`);
101
+ }
102
+
103
+ tunnelUrl = detectedUrl;
104
+ return tunnelUrl;
105
+ }
106
+
107
+ /**
108
+ * Stop the cloudflare tunnel if running.
109
+ */
110
+ export function stopTunnel(): void {
111
+ if (tunnelProcess) {
112
+ tunnelProcess.kill();
113
+ tunnelProcess = null;
114
+ tunnelUrl = null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Get the current tunnel URL if available.
120
+ */
121
+ export function getTunnelUrl(): string | null {
122
+ return tunnelUrl;
123
+ }
@@ -0,0 +1,10 @@
1
+ export { WidgetStore, getWidgetStore, resetWidgetStore } from "./store.js";
2
+ export { LocalFileBackend } from "./local-backend.js";
3
+ export type {
4
+ FSProvider,
5
+ FileStats,
6
+ DirEntry,
7
+ StoredWidget,
8
+ StoredWidgetInfo,
9
+ WidgetStoreOptions,
10
+ } from "./types.js";
@@ -0,0 +1,77 @@
1
+ import { readFile, writeFile, unlink, stat, mkdir, readdir, rm, access } from "node:fs/promises";
2
+ import { join, dirname, normalize } from "node:path";
3
+ import type { DirEntry, FileStats, FSProvider } from "./types.js";
4
+
5
+ function createDirEntry(name: string, isDir: boolean): DirEntry {
6
+ return {
7
+ name,
8
+ isFile: () => !isDir,
9
+ isDirectory: () => isDir,
10
+ };
11
+ }
12
+
13
+ function createFileStats(size: number, mtime: Date, isDir: boolean): FileStats {
14
+ return {
15
+ size,
16
+ mtime,
17
+ isFile: () => !isDir,
18
+ isDirectory: () => isDir,
19
+ };
20
+ }
21
+
22
+ export class LocalFileBackend implements FSProvider {
23
+ constructor(private basePath: string) {}
24
+
25
+ private resolve(path: string): string {
26
+ return join(this.basePath, normalize(path));
27
+ }
28
+
29
+ async readFile(path: string): Promise<string> {
30
+ return readFile(this.resolve(path), "utf-8");
31
+ }
32
+
33
+ async writeFile(path: string, content: string): Promise<void> {
34
+ const fullPath = this.resolve(path);
35
+ await mkdir(dirname(fullPath), { recursive: true });
36
+ await writeFile(fullPath, content, "utf-8");
37
+ }
38
+
39
+ async unlink(path: string): Promise<void> {
40
+ await unlink(this.resolve(path));
41
+ }
42
+
43
+ async stat(path: string): Promise<FileStats> {
44
+ const fullPath = this.resolve(path);
45
+ const s = await stat(fullPath);
46
+ return createFileStats(s.size, s.mtime, s.isDirectory());
47
+ }
48
+
49
+ async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
50
+ await mkdir(this.resolve(path), options);
51
+ }
52
+
53
+ async readdir(path: string): Promise<DirEntry[]> {
54
+ const fullPath = this.resolve(path);
55
+ const entries = await readdir(fullPath, { withFileTypes: true });
56
+ return entries.map((e) =>
57
+ createDirEntry(e.name, e.isDirectory()),
58
+ );
59
+ }
60
+
61
+ async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
62
+ if (options?.recursive) {
63
+ await rm(this.resolve(path), { recursive: true, force: true });
64
+ } else {
65
+ await rm(this.resolve(path), { force: true });
66
+ }
67
+ }
68
+
69
+ async exists(path: string): Promise<boolean> {
70
+ try {
71
+ await access(this.resolve(path));
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ }