From cd39ace7cf0cc687536cf1e929cc1d35465c3798 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Sat, 24 Jan 2026 23:24:54 +0200 Subject: [PATCH 01/16] examples: add MCP Apps arcade server --- examples/arcade-server/.gitignore | 2 + examples/arcade-server/README.md | 79 +++++++++ examples/arcade-server/game-processor.ts | 184 +++++++++++++++++++ examples/arcade-server/index.ts | 82 +++++++++ examples/arcade-server/package.json | 46 +++++ examples/arcade-server/search.ts | 120 +++++++++++++ examples/arcade-server/server.ts | 216 +++++++++++++++++++++++ examples/arcade-server/tsconfig.json | 17 ++ package-lock.json | 62 ++++--- 9 files changed, 782 insertions(+), 26 deletions(-) create mode 100644 examples/arcade-server/.gitignore create mode 100644 examples/arcade-server/README.md create mode 100644 examples/arcade-server/game-processor.ts create mode 100644 examples/arcade-server/index.ts create mode 100644 examples/arcade-server/package.json create mode 100644 examples/arcade-server/search.ts create mode 100644 examples/arcade-server/server.ts create mode 100644 examples/arcade-server/tsconfig.json diff --git a/examples/arcade-server/.gitignore b/examples/arcade-server/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/examples/arcade-server/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/examples/arcade-server/README.md b/examples/arcade-server/README.md new file mode 100644 index 000000000..fad6eb36b --- /dev/null +++ b/examples/arcade-server/README.md @@ -0,0 +1,79 @@ +# Example: Arcade Server + +An MCP Apps server that lets you browse and play classic arcade games from [archive.org](https://archive.org) directly in an MCP-enabled host. + +## Overview + +This example demonstrates serving **external HTML content** as an MCP App resource. Instead of bundling a custom UI, it fetches game pages from archive.org, processes the HTML server-side to work within an iframe sandbox, and returns it as an inline resource. + +Key techniques: +- Server-side HTML fetching and processing +- `` tag for resolving relative URLs against archive.org +- `baseUriDomains` CSP metadata to allow the base tag +- Rewriting ES module `import()` to classic ` + `, + ); + } + + // Convert inline ES module scripts to classic scripts + html = convertModuleScripts(html); + + // Fetch the emulation script server-side and serve from local endpoint + html = await rewriteEmulationScript(html, serverPort); + + return html; +} + +/** + * Fetches emulation.min.js server-side, rewrites import() → loadScript(), + * caches it, and points the HTML `); + } catch { + // If fetch fails, leave the original script tag + } + + return html; +} + +/** + * Converts ES module scripts to classic scripts and rewrites inline + * import() calls to use window.loadScript(). + */ +function convertModuleScripts(html: string): string { + return html.replace( + /(]*>)([\s\S]*?)(<\/script>)/gi, + (match, openTag: string, content: string, closeTag: string) => { + // Skip our injected scripts + if (content.includes("window.loadScript")) return match; + + // Remove type="module" + const newOpenTag = openTag.replace( + /\s*type\s*=\s*["']module["']/gi, + "", + ); + + // Rewrite dynamic import() to loadScript() + let newContent = content.replace( + /import\s*\(\s*(["'`])([^"'`]+)\1\s*\)/g, + (_m: string, quote: string, path: string) => { + if (path.startsWith("http://") || path.startsWith("https://")) + return _m; + return `window.loadScript(${quote}${path}${quote})`; + }, + ); + + // Convert static import statements + newContent = newContent.replace( + /import\s+(\{[^}]*\}|[^"']+)\s+from\s+(["'])([^"']+)\2/g, + (_m: string, _imports: string, quote: string, path: string) => { + if (path.startsWith("http://") || path.startsWith("https://")) + return _m; + return `window.loadScript(${quote}${path}${quote})`; + }, + ); + + return newOpenTag + newContent + closeTag; + }, + ); +} diff --git a/examples/arcade-server/index.ts b/examples/arcade-server/index.ts new file mode 100644 index 000000000..19509577c --- /dev/null +++ b/examples/arcade-server/index.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +/** + * Arcade MCP Server - Entry Point + * + * Sets up HTTP transport with Express and serves the modified emulation script. + */ + +import cors from "cors"; +import express from "express"; +import type { Request, Response } from "express"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createServer } from "./server.js"; +import { getCachedEmulationScript } from "./game-processor.js"; + +const DEFAULT_PORT = 3002; + +async function main() { + const port = parseInt(process.env.PORT ?? String(DEFAULT_PORT), 10); + const app = express(); + + app.use(cors()); + app.use(express.json()); + + // Serve the modified emulation script (import() rewritten to loadScript()). + // `; return { From 83effb50c70ac9b58d2908dc7415803e2c83fa75 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Sun, 25 Jan 2026 00:35:13 +0200 Subject: [PATCH 06/16] lint --- examples/arcade-server/index.ts | 8 +++++--- examples/arcade-server/server.ts | 4 +--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/arcade-server/index.ts b/examples/arcade-server/index.ts index fa2c5ae55..6a1bb441b 100644 --- a/examples/arcade-server/index.ts +++ b/examples/arcade-server/index.ts @@ -49,9 +49,11 @@ async function main() { res.setHeader("Cache-Control", "no-cache"); res.send(html); } catch (error) { - res.status(500).send( - `Failed to load game: ${error instanceof Error ? error.message : String(error)}`, - ); + res + .status(500) + .send( + `Failed to load game: ${error instanceof Error ? error.message : String(error)}`, + ); } }); diff --git a/examples/arcade-server/server.ts b/examples/arcade-server/server.ts index b9d0b593a..50afaa962 100644 --- a/examples/arcade-server/server.ts +++ b/examples/arcade-server/server.ts @@ -26,9 +26,7 @@ const gameHtmlMap = new Map>(); * Returns the game HTML for the given ID, awaiting processing if in-flight. * Used by the /game-html/:gameId HTTP endpoint. */ -export async function getGameHtmlForId( - gameId: string, -): Promise { +export async function getGameHtmlForId(gameId: string): Promise { const promise = gameHtmlMap.get(gameId); return promise ? promise : null; } From cc331603e0ea042c4ccd63804673efb9ba9cbab8 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Sun, 25 Jan 2026 00:47:03 +0200 Subject: [PATCH 07/16] fix --- examples/arcade-server/search.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/examples/arcade-server/search.ts b/examples/arcade-server/search.ts index e24d91bd5..c3f16389c 100644 --- a/examples/arcade-server/search.ts +++ b/examples/arcade-server/search.ts @@ -92,14 +92,6 @@ async function performSearch( ? `${query} AND (${GAME_COLLECTIONS})` : query; - const params = new URLSearchParams({ - q: fullQuery, - "fl[]": "identifier,title,mediatype,description,year,creator", - output: "json", - rows: String(maxResults), - }); - - // fl[] needs multiple values, URLSearchParams doesn't handle this well const searchUrl = `https://archive.org/advancedsearch.php?q=${encodeURIComponent(fullQuery)}&fl[]=identifier&fl[]=title&fl[]=mediatype&fl[]=description&fl[]=year&fl[]=creator&output=json&rows=${encodeURIComponent(String(maxResults))}`; const response = await fetch(searchUrl, { From e877484b909ee7a463e8c522fbd428b3f81f2947 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Sun, 25 Jan 2026 00:58:20 +0200 Subject: [PATCH 08/16] Update examples/arcade-server/game-processor.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/arcade-server/game-processor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/arcade-server/game-processor.ts b/examples/arcade-server/game-processor.ts index 688b2a12c..afb7812dd 100644 --- a/examples/arcade-server/game-processor.ts +++ b/examples/arcade-server/game-processor.ts @@ -25,7 +25,8 @@ export async function processGameEmbed( gameId: string, serverPort: number, ): Promise { - const embedUrl = `https://archive.org/embed/${gameId}`; + const encodedGameId = encodeURIComponent(gameId); + const embedUrl = `https://archive.org/embed/${encodedGameId}`; const response = await fetch(embedUrl, { headers: { From 6982499392248c55835b813c2ce8336ff5fee7c7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 01:39:55 +0200 Subject: [PATCH 09/16] Add whitespace validation to arcade server game ID validation (#348) * Initial plan * Add whitespace validation to validateGameId function Co-authored-by: idosal <18148989+idosal@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: idosal <18148989+idosal@users.noreply.github.com> --- examples/arcade-server/server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/arcade-server/server.ts b/examples/arcade-server/server.ts index 50afaa962..e42c6c33e 100644 --- a/examples/arcade-server/server.ts +++ b/examples/arcade-server/server.ts @@ -40,7 +40,8 @@ function validateGameId(gameId: string): boolean { !gameId.includes("/") && !gameId.includes("?") && !gameId.includes("#") && - !gameId.includes("..") + !gameId.includes("..") && + !/\s/.test(gameId) ); } From ed8641a9a310b1d44436ee81397cb1dfc98817af Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Sun, 25 Jan 2026 01:01:41 +0200 Subject: [PATCH 10/16] build --- examples/arcade-server/game-processor.ts | 2 +- scripts/setup-bun.mjs | 115 +++++++++++++++++++++-- 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/examples/arcade-server/game-processor.ts b/examples/arcade-server/game-processor.ts index afb7812dd..ef53f26fd 100644 --- a/examples/arcade-server/game-processor.ts +++ b/examples/arcade-server/game-processor.ts @@ -153,7 +153,7 @@ async function rewriteEmulationScript( */ function convertModuleScripts(html: string): string { return html.replace( - /(]*>)([\s\S]*?)(<\/script>)/gi, + /(]*>)([\s\S]*?)(<\/script\s*>)/gi, (match, openTag: string, content: string, closeTag: string) => { // Skip our injected scripts if (content.includes("window.loadScript")) return match; diff --git a/scripts/setup-bun.mjs b/scripts/setup-bun.mjs index 3dc46a0cf..c0b86059c 100644 --- a/scripts/setup-bun.mjs +++ b/scripts/setup-bun.mjs @@ -14,6 +14,7 @@ import { copyFileSync, chmodSync, writeFileSync, + statSync, } from "fs"; import { join, dirname } from "path"; import { spawnSync } from "child_process"; @@ -189,6 +190,26 @@ function extractTar(buffer, destDir) { } } +function copyFileWithRetry(source, dest, maxRetries = 3, delay = 100) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + copyFileSync(source, dest); + return true; + } catch (err) { + if (attempt === maxRetries) { + throw err; + } + // Wait a bit before retrying (exponential backoff) + const waitTime = delay * Math.pow(2, attempt - 1); + const start = Date.now(); + while (Date.now() - start < waitTime) { + // Busy wait (simple delay without external dependencies) + } + } + } + return false; +} + function setupBinLink(bunPath) { if (!existsSync(binDir)) { mkdirSync(binDir, { recursive: true }); @@ -197,17 +218,99 @@ function setupBinLink(bunPath) { const bunLink = join(binDir, bunExe); const bunxLink = join(binDir, isWindows ? "bunx.exe" : "bunx"); - // Remove existing links - for (const link of [bunLink, bunxLink]) { + // Check if files already exist and are valid (same size as source) + // This can help avoid unnecessary copy operations that might fail + let needsCopy = true; + if (existsSync(bunLink) && existsSync(bunxLink)) { try { - unlinkSync(link); - } catch {} + const sourceStat = statSync(bunPath); + const linkStat = statSync(bunLink); + if (sourceStat.size === linkStat.size) { + console.log("Bun binaries already exist and appear valid, skipping copy"); + needsCopy = false; + } + } catch { + // If stat fails, proceed with copy + needsCopy = true; + } + } + + if (needsCopy) { + // Remove existing links + for (const link of [bunLink, bunxLink]) { + try { + unlinkSync(link); + } catch {} + } + } else { + console.log(`Bun linked to: ${bunLink}`); + return; } if (isWindows) { // On Windows, copy the binary (symlinks may not work without admin) - copyFileSync(bunPath, bunLink); - copyFileSync(bunPath, bunxLink); + // Use retry logic to handle file locking issues + try { + copyFileWithRetry(bunPath, bunLink); + copyFileWithRetry(bunPath, bunxLink); + } catch (err) { + // If copy fails, try using Windows copy command as fallback + console.log(`Copy failed, trying Windows copy command: ${err.message}`); + try { + if (isWindows) { + // Use cmd /c copy for Windows with proper path quoting + // Paths with spaces need to be quoted, and we need to handle backslashes + const sourceQuoted = `"${bunPath}"`; + const destQuoted = `"${bunLink}"`; + const destxQuoted = `"${bunxLink}"`; + + // Try copying with a small delay between attempts + const result1 = spawnSync( + "cmd.exe", + ["/c", "copy", "/Y", sourceQuoted, destQuoted], + { shell: false, stdio: "pipe" }, + ); + + // Small delay before second copy + const start = Date.now(); + while (Date.now() - start < 50) {} + + const result2 = spawnSync( + "cmd.exe", + ["/c", "copy", "/Y", sourceQuoted, destxQuoted], + { shell: false, stdio: "pipe" }, + ); + + if (result1.status !== 0) { + const errorMsg = result1.stderr?.toString() || result1.stdout?.toString() || "Unknown error"; + throw new Error(`Windows copy command failed for bun.exe: ${errorMsg}`); + } + if (result2.status !== 0) { + const errorMsg = result2.stderr?.toString() || result2.stdout?.toString() || "Unknown error"; + throw new Error(`Windows copy command failed for bunx.exe: ${errorMsg}`); + } + + // Verify files were created + if (!existsSync(bunLink) || !existsSync(bunxLink)) { + throw new Error("Files were not created after copy command"); + } + } else { + // Fallback to original copyFileSync if not Windows + copyFileSync(bunPath, bunLink); + copyFileSync(bunPath, bunxLink); + } + } catch (fallbackErr) { + // If all copy methods fail, log the error but don't throw + // The script will exit gracefully and bun can be installed manually + console.error( + `All copy methods failed. Bun setup incomplete: ${fallbackErr.message}`, + ); + console.error( + "You may need to install bun manually or run this script with appropriate permissions.", + ); + throw fallbackErr; + } + } } else { // On Unix, use symlinks symlinkSync(bunPath, bunLink); From 1736b201f07d8e2220c6a9a4cabca92690f29a88 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Sun, 25 Jan 2026 01:02:51 +0200 Subject: [PATCH 11/16] bun --- scripts/setup-bun.mjs | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/scripts/setup-bun.mjs b/scripts/setup-bun.mjs index c0b86059c..3314cacca 100644 --- a/scripts/setup-bun.mjs +++ b/scripts/setup-bun.mjs @@ -226,7 +226,9 @@ function setupBinLink(bunPath) { const sourceStat = statSync(bunPath); const linkStat = statSync(bunLink); if (sourceStat.size === linkStat.size) { - console.log("Bun binaries already exist and appear valid, skipping copy"); + console.log( + "Bun binaries already exist and appear valid, skipping copy", + ); needsCopy = false; } } catch { @@ -263,33 +265,43 @@ function setupBinLink(bunPath) { const sourceQuoted = `"${bunPath}"`; const destQuoted = `"${bunLink}"`; const destxQuoted = `"${bunxLink}"`; - + // Try copying with a small delay between attempts const result1 = spawnSync( "cmd.exe", ["/c", "copy", "/Y", sourceQuoted, destQuoted], { shell: false, stdio: "pipe" }, ); - + // Small delay before second copy const start = Date.now(); while (Date.now() - start < 50) {} - + const result2 = spawnSync( "cmd.exe", ["/c", "copy", "/Y", sourceQuoted, destxQuoted], { shell: false, stdio: "pipe" }, ); - + if (result1.status !== 0) { - const errorMsg = result1.stderr?.toString() || result1.stdout?.toString() || "Unknown error"; - throw new Error(`Windows copy command failed for bun.exe: ${errorMsg}`); + const errorMsg = + result1.stderr?.toString() || + result1.stdout?.toString() || + "Unknown error"; + throw new Error( + `Windows copy command failed for bun.exe: ${errorMsg}`, + ); } if (result2.status !== 0) { - const errorMsg = result2.stderr?.toString() || result2.stdout?.toString() || "Unknown error"; - throw new Error(`Windows copy command failed for bunx.exe: ${errorMsg}`); + const errorMsg = + result2.stderr?.toString() || + result2.stdout?.toString() || + "Unknown error"; + throw new Error( + `Windows copy command failed for bunx.exe: ${errorMsg}`, + ); } - + // Verify files were created if (!existsSync(bunLink) || !existsSync(bunxLink)) { throw new Error("Files were not created after copy command"); From 9129a670fde6c84b183d55c741d3e22c3b4bd10f Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Sun, 25 Jan 2026 01:22:25 +0200 Subject: [PATCH 12/16] format --- examples/arcade-server/README.md | 2 +- examples/arcade-server/server.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/arcade-server/README.md b/examples/arcade-server/README.md index 95b3f3a26..2d1ad5067 100644 --- a/examples/arcade-server/README.md +++ b/examples/arcade-server/README.md @@ -80,4 +80,4 @@ Archive.org's game embed pages use ES module `import()` for loading the emulatio - `arcade_20pacgal` - Ms. Pac-Man / Galaga - `arcade_galaga` - Galaga - `arcade_sf2` - Street Fighter II -- `msdos_doom_1993` - DOOM +- `doom-play` - The Ultimate DOOM diff --git a/examples/arcade-server/server.ts b/examples/arcade-server/server.ts index e42c6c33e..50d744bab 100644 --- a/examples/arcade-server/server.ts +++ b/examples/arcade-server/server.ts @@ -147,7 +147,7 @@ export function createServer(port: number): McpServer { gameId: z .string() .describe( - 'The archive.org identifier (e.g., "arcade_20pacgal", "msdos_doom_1993").', + 'The archive.org identifier (e.g., "doom-play", "arcade_20pacgal").', ), }) as any, _meta: { @@ -176,6 +176,7 @@ export function createServer(port: number): McpServer { content: [{ type: "text", text: `Loading arcade game: ${gameId}` }], }; } catch (error) { + gameHtmlMap.delete(gameId); return { content: [ { From b40ef39a28fdcfde54417a97ea3059de9910c0ef Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Sun, 25 Jan 2026 01:50:49 +0200 Subject: [PATCH 13/16] simplify --- examples/arcade-server/README.md | 10 ++++---- examples/arcade-server/index.ts | 20 ++++++++-------- examples/arcade-server/server.ts | 39 ++++---------------------------- 3 files changed, 20 insertions(+), 49 deletions(-) diff --git a/examples/arcade-server/README.md b/examples/arcade-server/README.md index 2d1ad5067..557f368aa 100644 --- a/examples/arcade-server/README.md +++ b/examples/arcade-server/README.md @@ -54,16 +54,16 @@ The server starts on `http://localhost:3002/mcp` by default. Set the `PORT` envi ``` 1. Host calls search_games → Server queries archive.org API → Returns game list -2. Host calls get_game_by_id → Server fetches embed HTML from archive.org -3. Server processes HTML and stores it keyed by game ID: +2. Host calls get_game_by_id → Tool validates gameId and returns success +3. Host reads resource → Gets static loader with MCP Apps protocol handler +4. View performs ui/initialize handshake with host +5. Host sends tool-input with gameId → View fetches /game-html/:gameId +6. Server fetches embed HTML from archive.org and processes it: - Removes archive.org's tag - Injects for URL resolution - Rewrites ES module import() to