Alshokairy commited on
Commit
854edba
·
verified ·
1 Parent(s): bc1e378

Deploy HyperFrames MCP Cloud for ChatGPT

Browse files
Files changed (5) hide show
  1. Dockerfile +46 -0
  2. README.md +24 -5
  3. package-lock.json +0 -0
  4. package.json +18 -0
  5. server.mjs +640 -0
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-bookworm
2
+
3
+ ENV NODE_ENV=production \
4
+ PORT=7860 \
5
+ HOST=0.0.0.0 \
6
+ WORKSPACE_ROOT=/tmp/hyperframes-work \
7
+ PRODUCER_FORCE_SCREENSHOT=true \
8
+ PUPPETEER_CACHE_DIR=/app/.cache/puppeteer
9
+
10
+ WORKDIR /app
11
+
12
+ # HyperFrames needs FFmpeg/FFprobe and Chromium runtime libraries.
13
+ RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ ffmpeg \
15
+ ca-certificates \
16
+ fonts-liberation \
17
+ libasound2 \
18
+ libatk-bridge2.0-0 \
19
+ libatk1.0-0 \
20
+ libcups2 \
21
+ libdbus-1-3 \
22
+ libdrm2 \
23
+ libgbm1 \
24
+ libgtk-3-0 \
25
+ libnspr4 \
26
+ libnss3 \
27
+ libx11-xcb1 \
28
+ libxcomposite1 \
29
+ libxdamage1 \
30
+ libxfixes3 \
31
+ libxkbcommon0 \
32
+ libxrandr2 \
33
+ xdg-utils \
34
+ && rm -rf /var/lib/apt/lists/*
35
+
36
+ COPY package.json package-lock.json* ./
37
+ RUN npm install --omit=dev
38
+
39
+ # Pre-cache Puppeteer's chrome-headless-shell where possible.
40
+ RUN npx puppeteer browsers install chrome-headless-shell || true
41
+
42
+ COPY server.mjs ./server.mjs
43
+ RUN mkdir -p /tmp/hyperframes-work /app/.cache/puppeteer && chmod -R 777 /tmp/hyperframes-work /app/.cache
44
+
45
+ EXPOSE 7860
46
+ CMD ["npm", "start"]
README.md CHANGED
@@ -1,10 +1,29 @@
1
  ---
2
- title: Hyperframes Mcp Cloud
3
- emoji: ⚡
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: HyperFrames MCP Cloud
3
+ emoji: 🎬
4
+ colorFrom: yellow
5
+ colorTo: gray
6
  sdk: docker
7
  pinned: false
8
+ app_port: 7860
9
  ---
10
 
11
+ # HyperFrames MCP Cloud
12
+
13
+ Public HTTPS MCP/SSE endpoint for ChatGPT Connectors.
14
+
15
+ ## Endpoints
16
+
17
+ - Health: `/health`
18
+ - MCP SSE: `/sse`
19
+ - MCP messages: `/messages?sessionId=...`
20
+
21
+ ## ChatGPT Connector Input
22
+
23
+ Use:
24
+
25
+ ```text
26
+ https://YOUR-USERNAME-hyperframes-mcp-cloud.hf.space/sse
27
+ ```
28
+
29
+ Authentication: `No Auth` unless you set `AUTH_TOKEN` as a Space secret.
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "hyperframes-mcp-cloud",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "private": true,
6
+ "scripts": {
7
+ "start": "node server.mjs",
8
+ "doctor": "npx hyperframes doctor"
9
+ },
10
+ "dependencies": {
11
+ "@modelcontextprotocol/sdk": "^1.29.0",
12
+ "hyperframes": "^0.6.112",
13
+ "zod": "^4.4.3"
14
+ },
15
+ "engines": {
16
+ "node": ">=22"
17
+ }
18
+ }
server.mjs ADDED
@@ -0,0 +1,640 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * HyperFrames MCP Cloud Server for ChatGPT Connectors / Apps SDK.
4
+ *
5
+ * ChatGPT-ready endpoints:
6
+ * Streamable HTTP MCP: /mcp (recommended by current Apps SDK quickstart)
7
+ * Legacy SSE MCP: /sse + /messages (for connector forms still showing /sse examples)
8
+ * Health: /health
9
+ * Files: /files/<project>/<path>
10
+ */
11
+
12
+ import { createServer } from "node:http";
13
+ import { execFile } from "node:child_process";
14
+ import { randomUUID } from "node:crypto";
15
+ import path from "node:path";
16
+ import fs from "node:fs";
17
+ import { fileURLToPath } from "node:url";
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
20
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
21
+ import { z } from "zod";
22
+
23
+ const APP_ROOT = path.dirname(fileURLToPath(import.meta.url));
24
+ const PORT = parseInt(process.env.PORT || process.env.MCP_PORT || "7860", 10);
25
+ const HOST = process.env.HOST || "0.0.0.0";
26
+ const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT || "/tmp/hyperframes-work";
27
+ const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || "";
28
+ const AUTH_TOKEN = process.env["AUTH" + "_TOKEN"] || "";
29
+ const MCP_PATH = "/mcp";
30
+ const SSE_PATH = "/sse";
31
+ const MESSAGE_PATH = "/messages";
32
+ const MAX_OUTPUT = 60_000;
33
+
34
+ fs.mkdirSync(WORKSPACE_ROOT, { recursive: true });
35
+
36
+ let lastBaseUrl = PUBLIC_BASE_URL;
37
+ const sseTransports = new Map();
38
+
39
+ const EXAMPLES = [
40
+ "blank",
41
+ "warm-grain",
42
+ "play-mode",
43
+ "swiss-grid",
44
+ "vignelli",
45
+ "decision-tree",
46
+ "kinetic-type",
47
+ "product-promo",
48
+ "nyt-graph",
49
+ ];
50
+ const QUALITY = ["draft", "standard", "high"];
51
+ const FORMAT = ["mp4", "webm"];
52
+ const ALLOWED_GENERIC_COMMANDS = [
53
+ "add",
54
+ "benchmark",
55
+ "browser",
56
+ "capture",
57
+ "catalog",
58
+ "compositions",
59
+ "doctor",
60
+ "info",
61
+ "init",
62
+ "inspect",
63
+ "layout",
64
+ "lint",
65
+ "play",
66
+ "publish",
67
+ "remove-background",
68
+ "render",
69
+ "snapshot",
70
+ "transcribe",
71
+ "tts",
72
+ "validate",
73
+ "version",
74
+ ];
75
+
76
+ function clipText(s, n = MAX_OUTPUT) {
77
+ s = String(s || "");
78
+ return s.length > n ? s.slice(0, n) + `\n\n[truncated ${s.length - n} chars]` : s;
79
+ }
80
+
81
+ function safeProjectPath(projectName) {
82
+ if (!projectName || !/^[a-zA-Z0-9._-]+$/.test(projectName)) {
83
+ throw new Error("projectName must contain only letters, numbers, dot, underscore, or hyphen");
84
+ }
85
+ const resolved = path.resolve(WORKSPACE_ROOT, projectName);
86
+ const root = path.resolve(WORKSPACE_ROOT);
87
+ if (!resolved.startsWith(root)) throw new Error("Invalid project path");
88
+ return resolved;
89
+ }
90
+
91
+ function safeFilePath(projectName, filePath) {
92
+ if (!filePath || path.isAbsolute(filePath) || filePath.split(/[\\/]+/).includes("..")) {
93
+ throw new Error("filePath must be a safe relative path");
94
+ }
95
+ const projectDir = safeProjectPath(projectName);
96
+ const resolved = path.resolve(projectDir, filePath);
97
+ if (!resolved.startsWith(projectDir)) throw new Error("Invalid file path");
98
+ return resolved;
99
+ }
100
+
101
+ function safeMaybeAbsoluteProjectFile(projectName, maybePath, fallbackName) {
102
+ if (!maybePath) return safeFilePath(projectName, fallbackName);
103
+ if (path.isAbsolute(maybePath)) {
104
+ const projectDir = safeProjectPath(projectName);
105
+ const resolved = path.resolve(maybePath);
106
+ if (!resolved.startsWith(projectDir)) throw new Error("Absolute output must stay inside the project folder");
107
+ return resolved;
108
+ }
109
+ return safeFilePath(projectName, maybePath);
110
+ }
111
+
112
+ function fileUrl(projectName, filePath) {
113
+ const rel = `/files/${encodeURIComponent(projectName)}/${filePath.split(/[\\/]+/).map(encodeURIComponent).join("/")}`;
114
+ return lastBaseUrl ? `${lastBaseUrl}${rel}` : rel;
115
+ }
116
+
117
+ function projectTree(projectDir, depth = 3, prefix = "") {
118
+ if (!fs.existsSync(projectDir)) return "(missing)";
119
+ const lines = [];
120
+ function walk(dir, level, relPrefix) {
121
+ if (level > depth) return;
122
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true }).slice(0, 80)) {
123
+ if (ent.name === "node_modules" || ent.name === ".git") continue;
124
+ const rel = path.join(relPrefix, ent.name);
125
+ lines.push(rel + (ent.isDirectory() ? "/" : ""));
126
+ if (ent.isDirectory()) walk(path.join(dir, ent.name), level + 1, rel);
127
+ }
128
+ }
129
+ walk(projectDir, 1, prefix);
130
+ return lines.join("\n") || "(empty)";
131
+ }
132
+
133
+ function runCli(args, cwd = WORKSPACE_ROOT, timeoutMs = 300000) {
134
+ return new Promise((resolve) => {
135
+ const env = {
136
+ ...process.env,
137
+ PRODUCER_FORCE_SCREENSHOT: process.env.PRODUCER_FORCE_SCREENSHOT || "true",
138
+ PUPPETEER_CACHE_DIR: process.env.PUPPETEER_CACHE_DIR || "/app/.cache/puppeteer",
139
+ NODE_NO_WARNINGS: "1",
140
+ };
141
+ const localBin = path.join(APP_ROOT, "node_modules", ".bin", process.platform === "win32" ? "hyperframes.cmd" : "hyperframes");
142
+ const hasLocalBin = fs.existsSync(localBin);
143
+ const useShell = process.platform === "win32";
144
+ const cliBin = process.env.HYPERFRAMES_BIN || (hasLocalBin ? localBin : "npx");
145
+ const cliArgs = hasLocalBin || process.env.HYPERFRAMES_BIN ? args.map(String) : ["hyperframes", ...args.map(String)];
146
+ execFile(
147
+ cliBin,
148
+ cliArgs,
149
+ { cwd, env, timeout: timeoutMs, maxBuffer: 1024 * 1024 * 20, shell: useShell },
150
+ (error, stdout, stderr) => {
151
+ resolve({
152
+ stdout: stdout || "",
153
+ stderr: stderr || "",
154
+ exitCode: error ? error.code || 1 : 0,
155
+ error: error ? error.message : "",
156
+ command: `${path.basename(cliBin)} ${cliArgs.join(" ")}`,
157
+ });
158
+ },
159
+ );
160
+ });
161
+ }
162
+
163
+ function resultText(label, result, extra = "") {
164
+ return [
165
+ `${label}`,
166
+ `Exit: ${result.exitCode}`,
167
+ `Command: ${result.command}`,
168
+ extra,
169
+ result.stdout ? `STDOUT:\n${clipText(result.stdout)}` : "",
170
+ result.stderr ? `STDERR:\n${clipText(result.stderr)}` : "",
171
+ result.error && result.exitCode !== 0 ? `ERROR:\n${clipText(result.error)}` : "",
172
+ ]
173
+ .filter(Boolean)
174
+ .join("\n\n");
175
+ }
176
+
177
+ function text(content, structuredContent = {}) {
178
+ return { content: [{ type: "text", text: String(content) }], structuredContent };
179
+ }
180
+
181
+ function meta(name, visibility = ["model", "app"]) {
182
+ return {
183
+ "openai/toolInvocation/invoking": `Running ${name}`.slice(0, 64),
184
+ "openai/toolInvocation/invoked": `${name} complete`.slice(0, 64),
185
+ ui: { visibility },
186
+ };
187
+ }
188
+
189
+ const projectNameSchema = z.string().regex(/^[a-zA-Z0-9._-]+$/).describe("Cloud workspace project name");
190
+ const filePathSchema = z.string().describe("Safe relative path inside the project, e.g. index.html or assets/data.json");
191
+
192
+ function createHyperframesServer() {
193
+ const server = new McpServer({ name: "hyperframes-mcp-cloud", version: "1.1.0" });
194
+
195
+ server.registerTool(
196
+ "hyperframes_doctor",
197
+ {
198
+ title: "HyperFrames doctor",
199
+ description: "Diagnose HyperFrames cloud environment: Node, FFmpeg/FFprobe, Chrome headless, Docker, memory.",
200
+ inputSchema: {},
201
+ _meta: meta("doctor"),
202
+ annotations: { readOnlyHint: true, openWorldHint: false },
203
+ },
204
+ async () => {
205
+ const r = await runCli(["doctor"], WORKSPACE_ROOT, 180000);
206
+ return text(resultText("HyperFrames doctor", r));
207
+ },
208
+ );
209
+
210
+ server.registerTool(
211
+ "hyperframes_info",
212
+ {
213
+ title: "HyperFrames info",
214
+ description: "Show HyperFrames CLI/runtime information.",
215
+ inputSchema: {},
216
+ _meta: meta("info"),
217
+ annotations: { readOnlyHint: true, openWorldHint: false },
218
+ },
219
+ async () => {
220
+ const r = await runCli(["info"], WORKSPACE_ROOT, 120000);
221
+ return text(resultText("HyperFrames info", r));
222
+ },
223
+ );
224
+
225
+ server.registerTool(
226
+ "hyperframes_list_projects",
227
+ {
228
+ title: "List HyperFrames projects",
229
+ description: "List projects and top-level files in the cloud workspace.",
230
+ inputSchema: {},
231
+ _meta: meta("list projects"),
232
+ annotations: { readOnlyHint: true, openWorldHint: false },
233
+ },
234
+ async () => {
235
+ const projects = fs.readdirSync(WORKSPACE_ROOT, { withFileTypes: true }).map((d) => d.name + (d.isDirectory() ? "/" : ""));
236
+ return text(`Workspace: ${WORKSPACE_ROOT}\n\n${projects.join("\n") || "(empty)"}`, { projects });
237
+ },
238
+ );
239
+
240
+ server.registerTool(
241
+ "hyperframes_init",
242
+ {
243
+ title: "Initialize project",
244
+ description: "Create a new HyperFrames project scaffold in the cloud workspace.",
245
+ inputSchema: {
246
+ projectName: projectNameSchema,
247
+ example: z.enum(EXAMPLES).default("blank").describe("Starter template"),
248
+ },
249
+ _meta: meta("init"),
250
+ annotations: { destructiveHint: false, openWorldHint: false },
251
+ },
252
+ async ({ projectName, example = "blank" }) => {
253
+ const projectDir = safeProjectPath(projectName);
254
+ const r = await runCli(["init", projectName, "--example", example, "--non-interactive"], WORKSPACE_ROOT, 240000);
255
+ return text(resultText("Project initialized", r, `Project: ${projectDir}\nTree:\n${projectTree(projectDir)}`), { projectName, projectDir });
256
+ },
257
+ );
258
+
259
+ server.registerTool(
260
+ "hyperframes_write_file",
261
+ {
262
+ title: "Write project file",
263
+ description: "Write/overwrite a file inside a HyperFrames project, e.g. index.html, DESIGN.md, SCRIPT.md, assets/data.json.",
264
+ inputSchema: { projectName: projectNameSchema, filePath: filePathSchema, content: z.string() },
265
+ _meta: meta("write file"),
266
+ annotations: { destructiveHint: true, openWorldHint: false },
267
+ },
268
+ async ({ projectName, filePath, content }) => {
269
+ const file = safeFilePath(projectName, filePath);
270
+ fs.mkdirSync(path.dirname(file), { recursive: true });
271
+ fs.writeFileSync(file, content, "utf8");
272
+ return text(`Wrote ${filePath} (${Buffer.byteLength(content, "utf8")} bytes)`, { projectName, filePath, bytes: Buffer.byteLength(content, "utf8") });
273
+ },
274
+ );
275
+
276
+ server.registerTool(
277
+ "hyperframes_read_file",
278
+ {
279
+ title: "Read project file",
280
+ description: "Read a text file inside a HyperFrames project.",
281
+ inputSchema: { projectName: projectNameSchema, filePath: filePathSchema, maxChars: z.number().int().min(100).max(100000).default(60000) },
282
+ _meta: meta("read file"),
283
+ annotations: { readOnlyHint: true, openWorldHint: false },
284
+ },
285
+ async ({ projectName, filePath, maxChars = 60000 }) => {
286
+ const file = safeFilePath(projectName, filePath);
287
+ const data = fs.readFileSync(file, "utf8");
288
+ return text(clipText(data, maxChars), { projectName, filePath, bytes: Buffer.byteLength(data, "utf8") });
289
+ },
290
+ );
291
+
292
+ server.registerTool(
293
+ "hyperframes_project_tree",
294
+ {
295
+ title: "Project tree",
296
+ description: "Show the file tree for a HyperFrames project.",
297
+ inputSchema: { projectName: projectNameSchema, depth: z.number().int().min(1).max(8).default(4) },
298
+ _meta: meta("project tree"),
299
+ annotations: { readOnlyHint: true, openWorldHint: false },
300
+ },
301
+ async ({ projectName, depth = 4 }) => {
302
+ const projectDir = safeProjectPath(projectName);
303
+ return text(projectTree(projectDir, depth), { projectName });
304
+ },
305
+ );
306
+
307
+ for (const [toolName, command, description, timeout] of [
308
+ ["hyperframes_lint", "lint", "Static structure check for HyperFrames HTML composition files.", 120000],
309
+ ["hyperframes_validate", "validate", "Runtime validation: JS errors, missing assets, contrast audit.", 240000],
310
+ ["hyperframes_inspect", "inspect", "Visual layout audit: overflow, off-frame elements, occluded text.", 240000],
311
+ ]) {
312
+ server.registerTool(
313
+ toolName,
314
+ {
315
+ title: `HyperFrames ${command}`,
316
+ description,
317
+ inputSchema: { projectName: projectNameSchema, strict: z.boolean().default(false) },
318
+ _meta: meta(command),
319
+ annotations: { readOnlyHint: true, openWorldHint: false },
320
+ },
321
+ async ({ projectName, strict = false }) => {
322
+ const projectDir = safeProjectPath(projectName);
323
+ const args = [command];
324
+ if (strict) args.push("--strict");
325
+ const r = await runCli(args, projectDir, timeout);
326
+ return text(resultText(`HyperFrames ${command}`, r), { projectName, exitCode: r.exitCode });
327
+ },
328
+ );
329
+ }
330
+
331
+ server.registerTool(
332
+ "hyperframes_render",
333
+ {
334
+ title: "Render video",
335
+ description: "Render a HyperFrames project to MP4/WebM. Returns a cloud download URL under /files.",
336
+ inputSchema: {
337
+ projectName: projectNameSchema,
338
+ output: z.string().default("output.mp4").describe("Relative output path inside project"),
339
+ quality: z.enum(QUALITY).default("draft"),
340
+ fps: z.union([z.literal(24), z.literal(30), z.literal(60)]).default(24),
341
+ format: z.enum(FORMAT).default("mp4"),
342
+ browserTimeout: z.number().int().min(30).max(300).default(120),
343
+ strict: z.boolean().default(false),
344
+ },
345
+ _meta: meta("render"),
346
+ annotations: { destructiveHint: false, openWorldHint: false },
347
+ },
348
+ async ({ projectName, output = "output.mp4", quality = "draft", fps = 24, format = "mp4", browserTimeout = 120, strict = false }) => {
349
+ const projectDir = safeProjectPath(projectName);
350
+ const outPath = safeMaybeAbsoluteProjectFile(projectName, output, "output.mp4");
351
+ const args = ["render", "--quality", quality, "--fps", fps, "--format", format, "--browser-timeout", browserTimeout, "--output", outPath];
352
+ if (strict) args.push("--strict");
353
+ const r = await runCli(args, projectDir, 900000);
354
+ const relOut = path.relative(projectDir, outPath).replace(/\\/g, "/");
355
+ const url = fs.existsSync(outPath) ? fileUrl(projectName, relOut) : "";
356
+ return text(resultText("HyperFrames render", r, url ? `Download URL: ${url}` : `Output path: ${outPath}`), { projectName, output: relOut, url, exitCode: r.exitCode });
357
+ },
358
+ );
359
+
360
+ server.registerTool(
361
+ "hyperframes_snapshot",
362
+ {
363
+ title: "Snapshot frame",
364
+ description: "Capture a still snapshot/thumbnail from a HyperFrames project when supported by the installed CLI.",
365
+ inputSchema: {
366
+ projectName: projectNameSchema,
367
+ output: z.string().default("snapshot.png"),
368
+ time: z.string().default("1s").describe("Timestamp such as 1s or 00:00:01"),
369
+ },
370
+ _meta: meta("snapshot"),
371
+ annotations: { destructiveHint: false, openWorldHint: false },
372
+ },
373
+ async ({ projectName, output = "snapshot.png", time = "1s" }) => {
374
+ const projectDir = safeProjectPath(projectName);
375
+ const outPath = safeMaybeAbsoluteProjectFile(projectName, output, "snapshot.png");
376
+ const r = await runCli(["snapshot", "--time", time, "--output", outPath], projectDir, 300000);
377
+ const relOut = path.relative(projectDir, outPath).replace(/\\/g, "/");
378
+ const url = fs.existsSync(outPath) ? fileUrl(projectName, relOut) : "";
379
+ return text(resultText("HyperFrames snapshot", r, url ? `Download URL: ${url}` : `Output path: ${outPath}`), { projectName, output: relOut, url, exitCode: r.exitCode });
380
+ },
381
+ );
382
+
383
+ server.registerTool(
384
+ "hyperframes_tts",
385
+ {
386
+ title: "Text to speech",
387
+ description: "Generate narration audio with HyperFrames TTS voices.",
388
+ inputSchema: {
389
+ projectName: projectNameSchema,
390
+ text: z.string().min(1),
391
+ voice: z.string().default("af_nova"),
392
+ output: z.string().default("narration.wav"),
393
+ },
394
+ _meta: meta("tts"),
395
+ annotations: { destructiveHint: false, openWorldHint: false },
396
+ },
397
+ async ({ projectName, text: speechText, voice = "af_nova", output = "narration.wav" }) => {
398
+ const projectDir = safeProjectPath(projectName);
399
+ fs.mkdirSync(projectDir, { recursive: true });
400
+ const outPath = safeMaybeAbsoluteProjectFile(projectName, output, "narration.wav");
401
+ const r = await runCli(["tts", speechText, "--voice", voice, "--output", outPath], projectDir, 300000);
402
+ const relOut = path.relative(projectDir, outPath).replace(/\\/g, "/");
403
+ const url = fs.existsSync(outPath) ? fileUrl(projectName, relOut) : "";
404
+ return text(resultText("HyperFrames TTS", r, url ? `Download URL: ${url}` : `Output path: ${outPath}`), { projectName, output: relOut, url, exitCode: r.exitCode });
405
+ },
406
+ );
407
+
408
+ server.registerTool(
409
+ "hyperframes_transcribe",
410
+ {
411
+ title: "Transcribe audio",
412
+ description: "Transcribe an audio file inside a project using HyperFrames/Whisper transcription.",
413
+ inputSchema: { projectName: projectNameSchema, audioPath: filePathSchema },
414
+ _meta: meta("transcribe"),
415
+ annotations: { readOnlyHint: true, openWorldHint: false },
416
+ },
417
+ async ({ projectName, audioPath }) => {
418
+ const projectDir = safeProjectPath(projectName);
419
+ const file = safeFilePath(projectName, audioPath);
420
+ const r = await runCli(["transcribe", file], projectDir, 600000);
421
+ return text(resultText("HyperFrames transcribe", r), { projectName, exitCode: r.exitCode });
422
+ },
423
+ );
424
+
425
+ server.registerTool(
426
+ "hyperframes_capture_url",
427
+ {
428
+ title: "Capture website",
429
+ description: "Use HyperFrames capture to convert a public URL into a project scaffold when the site permits headless access.",
430
+ inputSchema: { projectName: projectNameSchema, url: z.string().url() },
431
+ _meta: meta("capture"),
432
+ annotations: { openWorldHint: true, destructiveHint: false },
433
+ },
434
+ async ({ projectName, url }) => {
435
+ const projectDir = safeProjectPath(projectName);
436
+ const r = await runCli(["capture", url, "--output", projectDir], WORKSPACE_ROOT, 600000);
437
+ return text(resultText("HyperFrames capture", r, `Project: ${projectDir}\nTree:\n${projectTree(projectDir)}`), { projectName, exitCode: r.exitCode });
438
+ },
439
+ );
440
+
441
+ server.registerTool(
442
+ "hyperframes_add",
443
+ {
444
+ title: "Add HyperFrames block/component",
445
+ description: "Run `hyperframes add` for transitions, blocks, components, or registry assets inside a project.",
446
+ inputSchema: { projectName: projectNameSchema, name: z.string().min(1).describe("Block/component/transition name or --list"), extraArgs: z.array(z.string()).default([]) },
447
+ _meta: meta("add"),
448
+ annotations: { destructiveHint: false, openWorldHint: true },
449
+ },
450
+ async ({ projectName, name, extraArgs = [] }) => {
451
+ const projectDir = safeProjectPath(projectName);
452
+ fs.mkdirSync(projectDir, { recursive: true });
453
+ const r = await runCli(["add", name, ...extraArgs], projectDir, 300000);
454
+ return text(resultText("HyperFrames add", r, `Tree:\n${projectTree(projectDir)}`), { projectName, exitCode: r.exitCode });
455
+ },
456
+ );
457
+
458
+ server.registerTool(
459
+ "hyperframes_remove_background",
460
+ {
461
+ title: "Remove image background",
462
+ description: "Run HyperFrames remove-background on an image file inside a project.",
463
+ inputSchema: { projectName: projectNameSchema, input: filePathSchema, output: z.string().default("background-removed.png") },
464
+ _meta: meta("remove background"),
465
+ annotations: { destructiveHint: false, openWorldHint: false },
466
+ },
467
+ async ({ projectName, input, output = "background-removed.png" }) => {
468
+ const projectDir = safeProjectPath(projectName);
469
+ const inPath = safeFilePath(projectName, input);
470
+ const outPath = safeMaybeAbsoluteProjectFile(projectName, output, "background-removed.png");
471
+ const r = await runCli(["remove-background", inPath, "--output", outPath], projectDir, 600000);
472
+ const relOut = path.relative(projectDir, outPath).replace(/\\/g, "/");
473
+ const url = fs.existsSync(outPath) ? fileUrl(projectName, relOut) : "";
474
+ return text(resultText("HyperFrames remove-background", r, url ? `Download URL: ${url}` : `Output path: ${outPath}`), { projectName, output: relOut, url, exitCode: r.exitCode });
475
+ },
476
+ );
477
+
478
+ server.registerTool(
479
+ "hyperframes_catalog",
480
+ {
481
+ title: "Catalog/list registry",
482
+ description: "List HyperFrames catalog/registry items such as blocks, examples, components, and transitions.",
483
+ inputSchema: { extraArgs: z.array(z.string()).default([]) },
484
+ _meta: meta("catalog"),
485
+ annotations: { readOnlyHint: true, openWorldHint: true },
486
+ },
487
+ async ({ extraArgs = [] }) => {
488
+ const r = await runCli(["catalog", ...extraArgs], WORKSPACE_ROOT, 180000);
489
+ return text(resultText("HyperFrames catalog", r), { exitCode: r.exitCode });
490
+ },
491
+ );
492
+
493
+ server.registerTool(
494
+ "hyperframes_cli",
495
+ {
496
+ title: "Generic HyperFrames CLI",
497
+ description: "Advanced escape hatch for HyperFrames CLI features not covered by specialized tools. Command is allowlisted and args are passed directly.",
498
+ inputSchema: {
499
+ projectName: z.string().regex(/^[a-zA-Z0-9._-]+$/).optional().describe("Optional project cwd"),
500
+ command: z.enum(ALLOWED_GENERIC_COMMANDS),
501
+ args: z.array(z.string()).default([]),
502
+ timeoutSeconds: z.number().int().min(5).max(900).default(300),
503
+ },
504
+ _meta: meta("cli"),
505
+ annotations: { openWorldHint: true, destructiveHint: true },
506
+ },
507
+ async ({ projectName, command, args = [], timeoutSeconds = 300 }) => {
508
+ const cwd = projectName ? safeProjectPath(projectName) : WORKSPACE_ROOT;
509
+ fs.mkdirSync(cwd, { recursive: true });
510
+ const r = await runCli([command, ...args], cwd, timeoutSeconds * 1000);
511
+ return text(resultText(`HyperFrames ${command}`, r), { projectName: projectName || null, command, exitCode: r.exitCode });
512
+ },
513
+ );
514
+
515
+ return server;
516
+ }
517
+
518
+ function authorized(req) {
519
+ if (!AUTH_TOKEN) return true;
520
+ const h = req.headers.authorization || "";
521
+ return h === `Bearer ${AUTH_TOKEN}`;
522
+ }
523
+
524
+ function setCors(req, res) {
525
+ const origin = req.headers.origin || "*";
526
+ res.setHeader("Access-Control-Allow-Origin", origin === "null" ? "*" : origin);
527
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS");
528
+ res.setHeader("Access-Control-Allow-Headers", "content-type, mcp-session-id, authorization");
529
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
530
+ }
531
+
532
+ function updateBaseUrl(req) {
533
+ if (PUBLIC_BASE_URL) {
534
+ lastBaseUrl = PUBLIC_BASE_URL.replace(/\/$/, "");
535
+ return;
536
+ }
537
+ const proto = req.headers["x-forwarded-proto"] || "https";
538
+ const host = req.headers["x-forwarded-host"] || req.headers.host;
539
+ if (host) lastBaseUrl = `${proto}://${host}`.replace(/\/$/, "");
540
+ }
541
+
542
+ function sendJson(res, code, obj) {
543
+ res.writeHead(code, { "content-type": "application/json" });
544
+ res.end(JSON.stringify(obj));
545
+ }
546
+
547
+ function serveFile(req, res, url) {
548
+ const parts = url.pathname.split("/").slice(2).map(decodeURIComponent);
549
+ const projectName = parts.shift();
550
+ const filePath = parts.join("/");
551
+ const file = safeFilePath(projectName, filePath);
552
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
553
+ res.writeHead(404).end("Not found");
554
+ return;
555
+ }
556
+ const ext = path.extname(file).toLowerCase();
557
+ const type = ext === ".mp4" ? "video/mp4" : ext === ".webm" ? "video/webm" : ext === ".png" ? "image/png" : ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : ext === ".wav" ? "audio/wav" : ext === ".mp3" ? "audio/mpeg" : "application/octet-stream";
558
+ res.writeHead(200, { "content-type": type, "cache-control": "public, max-age=3600" });
559
+ fs.createReadStream(file).pipe(res);
560
+ }
561
+
562
+ const httpServer = createServer(async (req, res) => {
563
+ try {
564
+ if (!req.url) return res.writeHead(400).end("Missing URL");
565
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
566
+ updateBaseUrl(req);
567
+ setCors(req, res);
568
+
569
+ if (req.method === "OPTIONS") return res.writeHead(204).end();
570
+ if (!authorized(req)) return res.writeHead(401).end("Unauthorized");
571
+
572
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) {
573
+ return sendJson(res, 200, {
574
+ status: "ok",
575
+ name: "hyperframes-mcp-cloud",
576
+ version: "1.1.0",
577
+ endpoints: { mcp: "/mcp", sse: "/sse", health: "/health", files: "/files/<project>/<path>" },
578
+ workspace: WORKSPACE_ROOT,
579
+ sessions: sseTransports.size,
580
+ uptime: process.uptime(),
581
+ });
582
+ }
583
+
584
+ if (req.method === "GET" && url.pathname.startsWith("/files/")) {
585
+ return serveFile(req, res, url);
586
+ }
587
+
588
+ // ChatGPT Apps SDK recommended Streamable HTTP MCP endpoint.
589
+ const MCP_METHODS = new Set(["POST", "GET", "DELETE"]);
590
+ if (url.pathname === MCP_PATH && req.method && MCP_METHODS.has(req.method)) {
591
+ const server = createHyperframesServer();
592
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
593
+ res.on("close", () => {
594
+ transport.close().catch(() => {});
595
+ server.close().catch(() => {});
596
+ });
597
+ await server.connect(transport);
598
+ await transport.handleRequest(req, res);
599
+ return;
600
+ }
601
+
602
+ // Legacy SSE endpoint for connector UIs that still show https://.../sse examples.
603
+ if (url.pathname === SSE_PATH && req.method === "GET") {
604
+ const transport = new SSEServerTransport(MESSAGE_PATH, res);
605
+ const server = createHyperframesServer();
606
+ sseTransports.set(transport.sessionId, { transport, server });
607
+ res.on("close", () => {
608
+ sseTransports.delete(transport.sessionId);
609
+ transport.close().catch(() => {});
610
+ server.close().catch(() => {});
611
+ });
612
+ await server.connect(transport);
613
+ return;
614
+ }
615
+
616
+ if (url.pathname === MESSAGE_PATH && req.method === "POST") {
617
+ const sessionId = url.searchParams.get("sessionId");
618
+ const entry = sseTransports.get(sessionId);
619
+ if (!entry) return res.writeHead(404).end("Session not found");
620
+ await entry.transport.handlePostMessage(req, res);
621
+ return;
622
+ }
623
+
624
+ // OAuth/discovery paths not in use. Return clean 404 so ChatGPT wizard does not show 502.
625
+ if (url.pathname.includes("oauth") || url.pathname.includes(".well-known")) {
626
+ return res.writeHead(404, { "content-type": "text/plain" }).end("Not configured");
627
+ }
628
+
629
+ return res.writeHead(404).end("Not found");
630
+ } catch (err) {
631
+ console.error("Request error", err);
632
+ if (!res.headersSent) res.writeHead(500).end("Internal server error");
633
+ }
634
+ });
635
+
636
+ httpServer.listen(PORT, HOST, () => {
637
+ console.log(`HyperFrames MCP Cloud listening on http://${HOST}:${PORT}`);
638
+ console.log(`Streamable HTTP: ${MCP_PATH}`);
639
+ console.log(`SSE compatibility: ${SSE_PATH}`);
640
+ });