@aprovan/mcp-app-server 0.1.0-dev.4d82df8
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/.turbo/turbo-build.log +23 -0
- package/E2E_TESTING.md +224 -0
- package/LICENSE +373 -0
- package/README.md +164 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +990 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/runtime/assets/index-tRNuU9ap.js +1059 -0
- package/dist/runtime/index.html +38 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +1511 -0
- package/dist/server.js.map +1 -0
- package/dist/shell/shell.js +67 -0
- package/docs/widget-preview.png +0 -0
- package/e2e/__snapshots__/.gitkeep +0 -0
- package/e2e/global-setup.ts +114 -0
- package/e2e/global-teardown.ts +15 -0
- package/e2e/visual-regression.test.ts +86 -0
- package/e2e/widget-smoke.test.ts +109 -0
- package/index.html +32 -0
- package/package.json +51 -0
- package/playwright.config.ts +43 -0
- package/src/__tests__/live-update.test.ts +158 -0
- package/src/__tests__/local-backend.test.ts +100 -0
- package/src/__tests__/memory-backend.ts +144 -0
- package/src/__tests__/registry-backend.test.ts +256 -0
- package/src/__tests__/services.test.ts +153 -0
- package/src/__tests__/shim.test.ts +60 -0
- package/src/__tests__/widget-store.test.ts +188 -0
- package/src/e2e-visual.ts +148 -0
- package/src/index.ts +608 -0
- package/src/live-update.ts +150 -0
- package/src/logger.ts +44 -0
- package/src/reference-widgets/live-dashboard.ts +162 -0
- package/src/registry-backend.ts +306 -0
- package/src/runtime/index.html +38 -0
- package/src/runtime/main.ts +164 -0
- package/src/scripts/export-artifacts.ts +51 -0
- package/src/server.ts +398 -0
- package/src/services.ts +307 -0
- package/src/shell/main.ts +172 -0
- package/src/shim.ts +106 -0
- package/src/tunnel.ts +123 -0
- package/src/widget-store/index.ts +10 -0
- package/src/widget-store/local-backend.ts +77 -0
- package/src/widget-store/store.ts +242 -0
- package/src/widget-store/types.ts +53 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
- package/vite.runtime.config.ts +28 -0
- package/vite.shell.config.ts +30 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patchwork widget runtime host.
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the nested, CSP-free iframe embedded by the MCP App shell. It
|
|
5
|
+
* fetches a saved widget's raw source and compiles + mounts it in the browser
|
|
6
|
+
* using the shared `@aprovan/patchwork-compiler` runtime (esbuild-wasm) — the
|
|
7
|
+
* same path the chat app uses.
|
|
8
|
+
*
|
|
9
|
+
* The widget is selected via query params written by the shell:
|
|
10
|
+
* /runtime/?widget=<name>/<hash>&inputs=<urlencoded-json>
|
|
11
|
+
*
|
|
12
|
+
* Service / live-update / context calls made by the widget go through the
|
|
13
|
+
* bridge shim (window.patchwork.* and namespace.*), which forwards them to the
|
|
14
|
+
* parent shell over postMessage; the shell relays to the MCP host.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
createCompiler,
|
|
18
|
+
createProjectFromFiles,
|
|
19
|
+
type Compiler,
|
|
20
|
+
type Manifest,
|
|
21
|
+
type VirtualFile,
|
|
22
|
+
} from "@aprovan/patchwork-compiler";
|
|
23
|
+
import { generateBridgeShim } from "../shim.js";
|
|
24
|
+
|
|
25
|
+
const CDN_BASE = "https://esm.sh";
|
|
26
|
+
|
|
27
|
+
interface WidgetRef {
|
|
28
|
+
name: string;
|
|
29
|
+
hash: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface WidgetFilesResponse {
|
|
33
|
+
files: VirtualFile[];
|
|
34
|
+
entry: string;
|
|
35
|
+
manifest: Manifest;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const statusEl = document.getElementById("pw-status");
|
|
39
|
+
|
|
40
|
+
function setStatus(message: string, isError = false): void {
|
|
41
|
+
if (!statusEl) return;
|
|
42
|
+
statusEl.textContent = message;
|
|
43
|
+
if (isError) statusEl.setAttribute("data-error", "true");
|
|
44
|
+
else statusEl.removeAttribute("data-error");
|
|
45
|
+
statusEl.style.display = message ? "block" : "none";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Execute the bridge shim in this window so `window.patchwork` and the service
|
|
50
|
+
* namespace proxies exist before the widget mounts. Imported as a blob module so
|
|
51
|
+
* execution completes before we continue.
|
|
52
|
+
*/
|
|
53
|
+
async function runShim(code: string): Promise<void> {
|
|
54
|
+
if (!code.trim()) return;
|
|
55
|
+
const blob = new Blob([code], { type: "application/javascript" });
|
|
56
|
+
const url = URL.createObjectURL(blob);
|
|
57
|
+
try {
|
|
58
|
+
await import(/* @vite-ignore */ url);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.warn("[patchwork-runtime] bridge shim failed to load:", err);
|
|
61
|
+
} finally {
|
|
62
|
+
URL.revokeObjectURL(url);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Report content height to the parent shell so it can size the iframe. */
|
|
67
|
+
function reportSize(): void {
|
|
68
|
+
const height = Math.ceil(document.documentElement.getBoundingClientRect().height);
|
|
69
|
+
window.parent.postMessage({ source: "patchwork", kind: "size", height }, "*");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let started = false;
|
|
73
|
+
const compilerCache = new Map<string, Promise<Compiler>>();
|
|
74
|
+
|
|
75
|
+
function getCompiler(image: string): Promise<Compiler> {
|
|
76
|
+
let cached = compilerCache.get(image);
|
|
77
|
+
if (!cached) {
|
|
78
|
+
cached = createCompiler({
|
|
79
|
+
image,
|
|
80
|
+
// Services are bridged to the MCP host via the shim, not an HTTP proxy.
|
|
81
|
+
proxyUrl: "",
|
|
82
|
+
cdnBaseUrl: CDN_BASE,
|
|
83
|
+
widgetCdnBaseUrl: CDN_BASE,
|
|
84
|
+
});
|
|
85
|
+
compilerCache.set(image, cached);
|
|
86
|
+
}
|
|
87
|
+
return cached;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function render(widget: WidgetRef, inputs: Record<string, unknown>): Promise<void> {
|
|
91
|
+
if (started) return;
|
|
92
|
+
started = true;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
setStatus("Loading widget source…");
|
|
96
|
+
const res = await fetch(
|
|
97
|
+
`/widget/${encodeURIComponent(widget.name)}/${encodeURIComponent(widget.hash)}/files`,
|
|
98
|
+
);
|
|
99
|
+
if (!res.ok) throw new Error(`Failed to load widget files (${res.status})`);
|
|
100
|
+
const { files, entry, manifest } = (await res.json()) as WidgetFilesResponse;
|
|
101
|
+
|
|
102
|
+
const services = manifest.services ?? [];
|
|
103
|
+
|
|
104
|
+
// Wire window.patchwork + service namespaces (forwarded to the host shell)
|
|
105
|
+
// before mounting so the widget's calls resolve.
|
|
106
|
+
await runShim(generateBridgeShim({ namespaces: services }));
|
|
107
|
+
|
|
108
|
+
setStatus("Compiling widget…");
|
|
109
|
+
const compiler = await getCompiler(manifest.image);
|
|
110
|
+
|
|
111
|
+
const project = createProjectFromFiles(files, widget.name);
|
|
112
|
+
if (entry) project.entry = entry;
|
|
113
|
+
|
|
114
|
+
// Strip services from the manifest passed to the compiler so its built-in
|
|
115
|
+
// HTTP-proxy namespace globals don't clobber the bridge shim's proxies.
|
|
116
|
+
const compileManifest: Manifest = { ...manifest, services: undefined };
|
|
117
|
+
const compiled = await compiler.compile(project, compileManifest);
|
|
118
|
+
|
|
119
|
+
const root = document.getElementById("root");
|
|
120
|
+
if (!root) throw new Error("Missing #root element");
|
|
121
|
+
|
|
122
|
+
setStatus("");
|
|
123
|
+
await compiler.mount(compiled, { target: root, mode: "embedded", inputs });
|
|
124
|
+
|
|
125
|
+
// Report size now and on any subsequent layout change.
|
|
126
|
+
reportSize();
|
|
127
|
+
const ro = new ResizeObserver(() => reportSize());
|
|
128
|
+
ro.observe(document.body);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
started = false;
|
|
131
|
+
console.error("[patchwork-runtime] render failed", err);
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
setStatus(`Failed to render widget:\n${message}`, true);
|
|
134
|
+
window.parent.postMessage({ source: "patchwork", kind: "error", error: message }, "*");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Render from query params: /runtime/?widget=<name>/<hash>&inputs=<json>
|
|
139
|
+
function renderFromQuery(): void {
|
|
140
|
+
const params = new URLSearchParams(window.location.search);
|
|
141
|
+
const widgetParam = params.get("widget");
|
|
142
|
+
if (!widgetParam) {
|
|
143
|
+
setStatus("No widget specified.", true);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const [name, hash] = widgetParam.split("/");
|
|
147
|
+
if (!name || !hash) {
|
|
148
|
+
setStatus("Invalid widget reference.", true);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let inputs: Record<string, unknown> = {};
|
|
153
|
+
const inputsParam = params.get("inputs");
|
|
154
|
+
if (inputsParam) {
|
|
155
|
+
try {
|
|
156
|
+
inputs = JSON.parse(inputsParam) as Record<string, unknown>;
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore malformed inputs */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
void render({ name, hash }, inputs);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
renderFromQuery();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { Manifest, VirtualFile } from "@aprovan/patchwork-compiler";
|
|
5
|
+
import {
|
|
6
|
+
REFERENCE_WIDGET_FILES,
|
|
7
|
+
REFERENCE_WIDGET_MANIFEST,
|
|
8
|
+
} from "../reference-widgets/live-dashboard.js";
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const ARTIFACTS_DIR = resolve(__dirname, "../../../../.artifacts/widgets");
|
|
12
|
+
|
|
13
|
+
const WIDGETS: Array<{ manifest: Manifest; files: VirtualFile[] }> = [
|
|
14
|
+
{ manifest: REFERENCE_WIDGET_MANIFEST, files: REFERENCE_WIDGET_FILES },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Export the raw source of each reference widget to .artifacts/widgets/<name>/.
|
|
19
|
+
*
|
|
20
|
+
* Widgets are no longer compiled on the server — they are saved as raw source
|
|
21
|
+
* and compiled in the browser by the shared runtime. This script just dumps the
|
|
22
|
+
* raw files (plus manifest.json) for inspection.
|
|
23
|
+
*/
|
|
24
|
+
async function main(): Promise<void> {
|
|
25
|
+
console.log("Exporting raw widget source artifacts...\n");
|
|
26
|
+
|
|
27
|
+
for (const { manifest, files } of WIDGETS) {
|
|
28
|
+
const widgetDir = join(ARTIFACTS_DIR, manifest.name);
|
|
29
|
+
await mkdir(widgetDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const outPath = join(widgetDir, file.path);
|
|
33
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
34
|
+
await writeFile(outPath, file.content, "utf-8");
|
|
35
|
+
}
|
|
36
|
+
await writeFile(
|
|
37
|
+
join(widgetDir, "manifest.json"),
|
|
38
|
+
JSON.stringify(manifest, null, 2),
|
|
39
|
+
"utf-8",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
console.log(` ${manifest.name} → ${widgetDir} (${files.length} files)`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log("\nDone. Render widgets via the runtime host to see them compiled.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
main().catch((err) => {
|
|
49
|
+
console.error("Export failed:", err);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import express from "express";
|
|
7
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
8
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import cors from "cors";
|
|
11
|
+
import { registerSession, unregisterSession } from "./live-update.js";
|
|
12
|
+
import { log, error } from "./logger.js";
|
|
13
|
+
import { createRegistryBackend, type RegistryBackend } from "./registry-backend.js";
|
|
14
|
+
import { createMcpAppServer, type McpAppServerOptions } from "./index.js";
|
|
15
|
+
import { getWidgetStore } from "./widget-store/index.js";
|
|
16
|
+
import { startTunnel, stopTunnel } from "./tunnel.js";
|
|
17
|
+
import type { Request, Response } from "express";
|
|
18
|
+
|
|
19
|
+
const PORT = Number(process.env["PORT"] ?? 3000);
|
|
20
|
+
const WIDGET_PORT = Number(process.env["WIDGET_PORT"] ?? 3002);
|
|
21
|
+
const HOST = process.env["HOST"] ?? "0.0.0.0";
|
|
22
|
+
const TRANSPORT = process.env["TRANSPORT"] ?? (process.argv.includes("--stdio") ? "stdio" : "http");
|
|
23
|
+
const WIDGET_TUNNEL = process.env["WIDGET_TUNNEL"] === "true" || process.argv.includes("--tunnel");
|
|
24
|
+
|
|
25
|
+
type SessionEntry = {
|
|
26
|
+
server: ReturnType<typeof createMcpAppServer>;
|
|
27
|
+
transport: StreamableHTTPServerTransport;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cross-process coordination for the shared widget host.
|
|
32
|
+
*
|
|
33
|
+
* Every stdio spawn of this server (Claude Desktop respawns it freely) would
|
|
34
|
+
* otherwise start its own widget server and its own cloudflared quick tunnel.
|
|
35
|
+
* Only one can bind {@link WIDGET_PORT}; the rest orphan extra tunnels and, worse,
|
|
36
|
+
* each render returns a *different* hostname — and any instance whose ephemeral
|
|
37
|
+
* tunnel has dropped serves a dead URL (Cloudflare 1033) into the widget HTML.
|
|
38
|
+
*
|
|
39
|
+
* We elect a single owner via the port bind: whoever binds the port establishes
|
|
40
|
+
* (and verifies) the tunnel and publishes its base URL to a temp file keyed by
|
|
41
|
+
* port; every other instance reuses that URL instead of starting a tunnel.
|
|
42
|
+
*/
|
|
43
|
+
interface WidgetHostState {
|
|
44
|
+
baseUrl: string;
|
|
45
|
+
pid: number;
|
|
46
|
+
updatedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const WIDGET_STATE_FILE = join(tmpdir(), `patchwork-widget-${WIDGET_PORT}.json`);
|
|
50
|
+
|
|
51
|
+
function readWidgetHostState(): WidgetHostState | null {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(WIDGET_STATE_FILE, "utf8")) as WidgetHostState;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function publishWidgetHostState(baseUrl: string): void {
|
|
60
|
+
try {
|
|
61
|
+
const state: WidgetHostState = { baseUrl, pid: process.pid, updatedAt: Date.now() };
|
|
62
|
+
writeFileSync(WIDGET_STATE_FILE, JSON.stringify(state));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
error("mcp-app-server", "Failed to publish widget host state:", err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isProcessAlive(pid: number): boolean {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, 0);
|
|
71
|
+
return true;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// ESRCH = no such process; EPERM = exists but not ours (still alive).
|
|
74
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wait for the owning instance to publish its (verified) base URL. Only adopt
|
|
80
|
+
* state whose owning pid is still alive, so a non-owner never reuses a dead
|
|
81
|
+
* previous owner's (now-1033) hostname.
|
|
82
|
+
*/
|
|
83
|
+
async function awaitPublishedWidgetBaseUrl(timeoutMs = 35000): Promise<string | null> {
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
const state = readWidgetHostState();
|
|
87
|
+
if (state?.baseUrl && isProcessAlive(state.pid)) return state.baseUrl;
|
|
88
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Locate a built browser bundle dir (dist/<name>) from either dist or src. */
|
|
94
|
+
function resolveDistDir(name: string): string {
|
|
95
|
+
const candidates = [
|
|
96
|
+
fileURLToPath(new URL(`./${name}`, import.meta.url)), // built: dist/<name>
|
|
97
|
+
fileURLToPath(new URL(`../dist/${name}`, import.meta.url)), // dev via tsx: src → dist
|
|
98
|
+
];
|
|
99
|
+
return candidates.find((dir) => existsSync(dir)) ?? candidates[0]!;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Locate the built runtime bundle (dist/runtime). */
|
|
103
|
+
export function resolveRuntimeDir(): string {
|
|
104
|
+
return resolveDistDir("runtime");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Locate the built MCP App shell bundle (dist/shell). */
|
|
108
|
+
export function resolveShellDir(): string {
|
|
109
|
+
return resolveDistDir("shell");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function setupRegistryBackend(): Promise<{
|
|
113
|
+
registryBackend: RegistryBackend | null;
|
|
114
|
+
serverOptions: McpAppServerOptions;
|
|
115
|
+
}> {
|
|
116
|
+
const REGISTRY_PROVIDERS = process.env["REGISTRY_PROVIDERS"];
|
|
117
|
+
|
|
118
|
+
if (!REGISTRY_PROVIDERS) {
|
|
119
|
+
return { registryBackend: null, serverOptions: {} };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const command = process.env["REGISTRY_COMMAND"] ?? "npx";
|
|
123
|
+
const extraArgs = process.env["REGISTRY_ARGS"]?.split(" ").filter(Boolean) ?? [];
|
|
124
|
+
const args = ["@utdk/mcp", ...extraArgs];
|
|
125
|
+
|
|
126
|
+
log("mcp-app-server", `Connecting to Registry MCP server (providers: ${REGISTRY_PROVIDERS})...`);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const registryBackend = await createRegistryBackend({
|
|
130
|
+
command,
|
|
131
|
+
args,
|
|
132
|
+
providers: REGISTRY_PROVIDERS,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const toolInfos = registryBackend.getToolInfos();
|
|
136
|
+
const namespaces = [...new Set(toolInfos.map((t) => t.namespace))];
|
|
137
|
+
log(
|
|
138
|
+
"mcp-app-server",
|
|
139
|
+
`Registry ready: ${toolInfos.length} tools across namespaces: ${namespaces.join(", ")}`
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
registryBackend,
|
|
144
|
+
serverOptions: {
|
|
145
|
+
services: {
|
|
146
|
+
backend: registryBackend,
|
|
147
|
+
tools: toolInfos,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
} catch (err) {
|
|
152
|
+
error("mcp-app-server", "Failed to connect to Registry MCP server:", err);
|
|
153
|
+
error("mcp-app-server", "Starting without Registry service backend.");
|
|
154
|
+
return { registryBackend: null, serverOptions: {} };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function handleExistingSession(existing: SessionEntry, req: Request, res: Response): void {
|
|
159
|
+
try {
|
|
160
|
+
existing.transport.handleRequest(req, res, req.body);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
error("mcp", "session request error", err);
|
|
163
|
+
if (!res.headersSent) {
|
|
164
|
+
res.status(500).json({ error: "Internal server error" });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function createNewSession(
|
|
170
|
+
serverOptions: McpAppServerOptions,
|
|
171
|
+
sessionStore: Map<string, SessionEntry>,
|
|
172
|
+
req: Request,
|
|
173
|
+
res: Response
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const mcpServer = createMcpAppServer(serverOptions);
|
|
176
|
+
const transport = new StreamableHTTPServerTransport({
|
|
177
|
+
sessionIdGenerator: () => randomUUID(),
|
|
178
|
+
onsessioninitialized: (id) => {
|
|
179
|
+
sessionStore.set(id, { server: mcpServer, transport });
|
|
180
|
+
registerSession(id, mcpServer);
|
|
181
|
+
},
|
|
182
|
+
onsessionclosed: (id) => {
|
|
183
|
+
sessionStore.delete(id);
|
|
184
|
+
unregisterSession(id);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
res.on("close", () => {
|
|
189
|
+
const id = transport.sessionId;
|
|
190
|
+
if (id && !sessionStore.has(id)) {
|
|
191
|
+
void mcpServer.close();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await mcpServer.connect(transport);
|
|
197
|
+
await transport.handleRequest(req, res, req.body);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
error("mcp", "new session error", err);
|
|
200
|
+
if (!res.headersSent) {
|
|
201
|
+
res.status(500).json({ error: "Internal server error" });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function registerMcpEndpoint(
|
|
207
|
+
app: ReturnType<typeof createMcpExpressApp>,
|
|
208
|
+
serverOptions: McpAppServerOptions
|
|
209
|
+
): Map<string, SessionEntry> {
|
|
210
|
+
const sessionStore = new Map<string, SessionEntry>();
|
|
211
|
+
|
|
212
|
+
app.all("/mcp", async (req, res) => {
|
|
213
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
214
|
+
|
|
215
|
+
if (sessionId) {
|
|
216
|
+
const existing = sessionStore.get(sessionId);
|
|
217
|
+
if (existing) {
|
|
218
|
+
handleExistingSession(existing, req, res);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await createNewSession(serverOptions, sessionStore, req, res);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return sessionStore;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function registerShutdownHandlers(registryBackend: RegistryBackend | null): void {
|
|
230
|
+
const shutdown = () => {
|
|
231
|
+
if (registryBackend) {
|
|
232
|
+
registryBackend.close().catch(() => {
|
|
233
|
+
/* ignore close errors on exit */
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
process.exit(0);
|
|
237
|
+
};
|
|
238
|
+
process.on("SIGTERM", shutdown);
|
|
239
|
+
process.on("SIGINT", shutdown);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function startServer(): Promise<void> {
|
|
243
|
+
const { registryBackend, serverOptions } = await setupRegistryBackend();
|
|
244
|
+
|
|
245
|
+
const app = createMcpExpressApp({ host: HOST });
|
|
246
|
+
app.use(cors());
|
|
247
|
+
|
|
248
|
+
registerMcpEndpoint(app, serverOptions);
|
|
249
|
+
|
|
250
|
+
app.get("/health", (_req, res) => {
|
|
251
|
+
res.json({ status: "ok", service: "patchwork-mcp-app-server" });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
app.listen(PORT, HOST, () => {
|
|
255
|
+
log("mcp-app-server", `MCP App Server listening on http://${HOST}:${PORT}`);
|
|
256
|
+
log("mcp-app-server", ` POST /mcp — MCP Streamable HTTP endpoint (stateful sessions)`);
|
|
257
|
+
log("mcp-app-server", ` GET /health — health check`);
|
|
258
|
+
log("mcp-app-server", "");
|
|
259
|
+
log("mcp-app-server", "To expose locally via cloudflared:");
|
|
260
|
+
log("mcp-app-server", ` cloudflared tunnel --url http://localhost:${PORT}`);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
registerShutdownHandlers(registryBackend);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function startWidgetServer(): Promise<string> {
|
|
267
|
+
const widgetApp = express();
|
|
268
|
+
widgetApp.use(cors());
|
|
269
|
+
|
|
270
|
+
const store = getWidgetStore();
|
|
271
|
+
|
|
272
|
+
// Serve the MCP App shell (resource document's external script) and the shared
|
|
273
|
+
// browser runtime bundle (compiles widgets in-browser). Both resolve to dist
|
|
274
|
+
// whether running built (dist/server.js) or via tsx (src).
|
|
275
|
+
widgetApp.use("/shell", express.static(resolveShellDir()));
|
|
276
|
+
widgetApp.use("/runtime", express.static(resolveRuntimeDir()));
|
|
277
|
+
|
|
278
|
+
// Raw widget source files — fetched by the runtime and compiled in-browser.
|
|
279
|
+
widgetApp.get("/widget/:name/:hash/files", async (req, res) => {
|
|
280
|
+
const { name, hash } = req.params;
|
|
281
|
+
try {
|
|
282
|
+
const widget = await store.getWidget(name, hash);
|
|
283
|
+
if (!widget) {
|
|
284
|
+
res.status(404).json({ error: "Widget not found" });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
res.json({ files: widget.files, entry: widget.entry, manifest: widget.manifest });
|
|
288
|
+
} catch (err) {
|
|
289
|
+
error("widget-server", "Error serving widget files:", err);
|
|
290
|
+
res.status(500).json({ error: "Internal server error" });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Convenience redirect: /widget/:name/:hash → runtime host with the widget preselected.
|
|
295
|
+
widgetApp.get("/widget/:name/:hash", (req, res) => {
|
|
296
|
+
const { name, hash } = req.params;
|
|
297
|
+
res.redirect(
|
|
298
|
+
302,
|
|
299
|
+
`/runtime/?widget=${encodeURIComponent(name)}/${encodeURIComponent(hash)}`,
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
widgetApp.get("/health", (_req, res) => {
|
|
304
|
+
res.json({ status: "ok", service: "patchwork-widget-server" });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Elect the widget-host owner via the port bind. A failed bind (EADDRINUSE)
|
|
308
|
+
// means another instance already owns the widget server + tunnel.
|
|
309
|
+
const isOwner = await new Promise<boolean>((resolve) => {
|
|
310
|
+
const httpServer = widgetApp.listen(WIDGET_PORT, HOST, () => {
|
|
311
|
+
log("mcp-app-server", `Widget server listening on http://${HOST}:${WIDGET_PORT}`);
|
|
312
|
+
resolve(true);
|
|
313
|
+
});
|
|
314
|
+
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
|
315
|
+
if (err.code === "EADDRINUSE") {
|
|
316
|
+
log(
|
|
317
|
+
"mcp-app-server",
|
|
318
|
+
`Widget port ${WIDGET_PORT} already owned by another instance; reusing its widget host.`,
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
error("mcp-app-server", "Widget server failed to bind:", err);
|
|
322
|
+
}
|
|
323
|
+
resolve(false);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
if (!isOwner) {
|
|
328
|
+
// Reuse the owner's verified, published base URL so every instance hands out
|
|
329
|
+
// the same live hostname instead of orphaning another (possibly dead) tunnel.
|
|
330
|
+
const shared = await awaitPublishedWidgetBaseUrl();
|
|
331
|
+
if (shared) {
|
|
332
|
+
log("mcp-app-server", `Reusing shared widget host: ${shared}`);
|
|
333
|
+
return shared;
|
|
334
|
+
}
|
|
335
|
+
error(
|
|
336
|
+
"mcp-app-server",
|
|
337
|
+
"Owner instance never published a base URL; falling back to localhost (widgets may not load in remote hosts).",
|
|
338
|
+
);
|
|
339
|
+
return `http://localhost:${WIDGET_PORT}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// We own the widget server. Establish (and verify) the tunnel, then publish
|
|
343
|
+
// the base URL for sibling instances.
|
|
344
|
+
let widgetBaseUrl = `http://localhost:${WIDGET_PORT}`;
|
|
345
|
+
|
|
346
|
+
if (WIDGET_TUNNEL) {
|
|
347
|
+
try {
|
|
348
|
+
widgetBaseUrl = await startTunnel(WIDGET_PORT);
|
|
349
|
+
log("mcp-app-server", `Widgets accessible at: ${widgetBaseUrl}`);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
error("mcp-app-server", "Failed to start tunnel, using localhost:", err);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
publishWidgetHostState(widgetBaseUrl);
|
|
356
|
+
return widgetBaseUrl;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function startStdioServer(): Promise<void> {
|
|
360
|
+
const { registryBackend, serverOptions } = await setupRegistryBackend();
|
|
361
|
+
|
|
362
|
+
log("mcp-app-server", "Starting MCP App Server in stdio mode...");
|
|
363
|
+
|
|
364
|
+
// Start widget server and optionally tunnel
|
|
365
|
+
const widgetBaseUrl = await startWidgetServer();
|
|
366
|
+
|
|
367
|
+
// Create MCP server with widget base URL
|
|
368
|
+
const mcpServer = createMcpAppServer({
|
|
369
|
+
...serverOptions,
|
|
370
|
+
widgetBaseUrl,
|
|
371
|
+
});
|
|
372
|
+
const transport = new StdioServerTransport();
|
|
373
|
+
|
|
374
|
+
await mcpServer.connect(transport);
|
|
375
|
+
|
|
376
|
+
const shutdown = () => {
|
|
377
|
+
stopTunnel();
|
|
378
|
+
if (registryBackend) {
|
|
379
|
+
registryBackend.close().catch(() => {});
|
|
380
|
+
}
|
|
381
|
+
process.exit(0);
|
|
382
|
+
};
|
|
383
|
+
process.on("SIGTERM", shutdown);
|
|
384
|
+
process.on("SIGINT", shutdown);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function main(): Promise<void> {
|
|
388
|
+
if (TRANSPORT === "stdio") {
|
|
389
|
+
await startStdioServer();
|
|
390
|
+
} else {
|
|
391
|
+
await startServer();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
main().catch((err: unknown) => {
|
|
396
|
+
error("mcp-app-server", "Fatal startup error:", err);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
});
|