-
Notifications
You must be signed in to change notification settings - Fork 67
examples: add MCP Apps arcade server #346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
idosal
wants to merge
16
commits into
main
Choose a base branch
from
exxamples/arcade
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
cd39ace
examples: add MCP Apps arcade server
idosal 0d51367
fixes
idosal 79a634c
Update examples/arcade-server/search.ts
idosal b9eadb0
Update examples/arcade-server/game-processor.ts
idosal 9646ed0
fixes
idosal 83effb5
lint
idosal cc33160
fix
idosal e877484
Update examples/arcade-server/game-processor.ts
idosal 6982499
Add whitespace validation to arcade server game ID validation (#348)
Copilot ed8641a
build
idosal 1736b20
bun
idosal 9129a67
format
idosal b40ef39
simplify
idosal 1419a65
format
idosal fff9e7a
codeql
idosal 887e0d4
fix csp
idosal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| node_modules/ | ||
| dist/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| # 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. The resource is a static loader that uses the MCP Apps protocol to receive tool arguments, then fetches the processed game HTML from a server endpoint. This pattern allows the same resource to display different games based on tool input. | ||
|
|
||
| Key techniques: | ||
|
|
||
| - MCP Apps protocol handshake (`ui/initialize` → `ui/notifications/tool-input`) to receive game ID dynamically | ||
| - Server-side HTML fetching and processing per game ID | ||
| - `<base href>` tag for resolving relative URLs against archive.org | ||
| - `baseUriDomains` CSP metadata to allow the base tag | ||
| - Rewriting ES module `import()` to classic `<script src>` loading (for srcdoc iframe compatibility) | ||
| - Local script endpoint to bypass CORS restrictions in sandboxed iframes | ||
|
|
||
| ## Key Files | ||
|
|
||
| - [`server.ts`](server.ts) - MCP server with tool and resource registration | ||
| - [`index.ts`](index.ts) - HTTP transport and Express setup | ||
| - [`game-processor.ts`](game-processor.ts) - Fetches and processes archive.org HTML | ||
| - [`search.ts`](search.ts) - Archive.org search with smart fallbacks | ||
|
|
||
| ## Getting Started | ||
|
|
||
| ```bash | ||
| npm install | ||
| npm run dev | ||
| ``` | ||
|
|
||
| The server starts on `http://localhost:3002/mcp` by default. Set the `PORT` environment variable to change it. | ||
|
|
||
| ### MCP Client Configuration | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "arcade": { | ||
| "url": "http://localhost:3002/mcp" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Tools | ||
|
|
||
| | Tool | Description | UI | | ||
| | ---------------- | ------------------------------------------- | --- | | ||
| | `search_games` | Search archive.org for arcade games by name | No | | ||
| | `get_game_by_id` | Load and play a specific game | Yes | | ||
|
|
||
| ## How It Works | ||
|
|
||
| ``` | ||
| 1. Host calls search_games → Server queries archive.org API → Returns game list | ||
| 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 <base> tag | ||
| - Injects <base href="https://archive.org/"> for URL resolution | ||
| - Rewrites ES module import() to <script src> loading | ||
| - Fetches emulation.min.js, patches it, serves from local endpoint | ||
| - Injects layout CSS for full-viewport display | ||
| 7. Game runs: emulator loads ROM, initializes MAME, game is playable | ||
| ``` | ||
|
|
||
| ### Why the Processing? | ||
|
|
||
| Archive.org's game embed pages use ES module `import()` for loading the emulation engine. In `srcdoc` iframes (used by MCP hosts), `import()` fails because the iframe has a `null` origin. The server works around this by: | ||
|
|
||
| 1. **Fetching `emulation.min.js` server-side** and replacing `import()` with `window.loadScript()` | ||
| 2. **Serving the patched script** from a local Express endpoint (`/scripts/emulation.js`) | ||
| 3. **Using `<script src>`** which is not subject to CORS restrictions, unlike `fetch()` or `import()` | ||
|
|
||
| ## Example Game IDs | ||
|
|
||
| - `arcade_20pacgal` - Ms. Pac-Man / Galaga | ||
| - `arcade_galaga` - Galaga | ||
| - `arcade_sf2` - Street Fighter II | ||
| - `doom-play` - The Ultimate DOOM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| /** | ||
| * Game Processor | ||
| * | ||
| * Fetches and processes archive.org game HTML for embedding in MCP Apps. | ||
| * Uses <base href="https://archive.org/"> for relative URL resolution, | ||
| * and rewrites ES module import() calls to classic <script src> loading | ||
| * (dynamic import() doesn't work in srcdoc iframes due to null origin). | ||
| */ | ||
|
|
||
| // Cache for the modified emulation script content | ||
| let cachedEmulationScript: string | null = null; | ||
|
|
||
| /** | ||
| * Returns the cached modified emulation script. | ||
| * Called by the server's /scripts/emulation.js endpoint. | ||
| */ | ||
| export function getCachedEmulationScript(): string | null { | ||
| return cachedEmulationScript; | ||
| } | ||
|
|
||
| /** | ||
| * Fetches and processes archive.org game HTML for inline embedding. | ||
| */ | ||
| export async function processGameEmbed( | ||
| gameId: string, | ||
| serverPort: number, | ||
| ): Promise<string> { | ||
| const encodedGameId = encodeURIComponent(gameId); | ||
| const embedUrl = `https://archive.org/embed/${encodedGameId}`; | ||
|
|
||
| const response = await fetch(embedUrl, { | ||
| headers: { | ||
| "User-Agent": "MCP-Arcade-Server/1.0", | ||
| Accept: "text/html", | ||
| }, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error( | ||
| `Failed to fetch game: ${response.status} ${response.statusText}`, | ||
| ); | ||
| } | ||
|
|
||
| let html = await response.text(); | ||
|
|
||
| // Remove archive.org's <base> tag (would violate CSP base-uri) | ||
| html = html.replace(/<base\s+[^>]*>/gi, ""); | ||
|
|
||
| // Inject our <base> tag, hash-link interceptor, script loader, and layout CSS | ||
| const headMatch = html.match(/<head[^>]*>/i); | ||
| if (headMatch) { | ||
| html = html.replace( | ||
| headMatch[0], | ||
| `${headMatch[0]} | ||
| <base href="https://archive.org/"> | ||
| <script> | ||
| // Intercept hash-link clicks that would navigate away due to <base> | ||
| document.addEventListener("click", function(e) { | ||
| var el = e.target; | ||
| while (el && el.tagName !== "A") el = el.parentElement; | ||
| if (el && el.getAttribute("href") && el.getAttribute("href").charAt(0) === "#") { | ||
| e.preventDefault(); | ||
| } | ||
| }, true); | ||
|
|
||
| // Script loader: replaces dynamic import() which fails in srcdoc iframes. | ||
| // Uses <script src> which respects <base> and bypasses CORS. | ||
| if (!window.loadScript) { | ||
| window.loadScript = function(url) { | ||
| return new Promise(function(resolve) { | ||
| var s = document.createElement("script"); | ||
| s.src = url; | ||
| s.onload = function() { | ||
| resolve({ | ||
| default: window.Emulator || window.IALoader || window.Loader || {}, | ||
| __esModule: true | ||
| }); | ||
| }; | ||
| s.onerror = function() { | ||
| console.error("Failed to load script:", url); | ||
| resolve({ default: {}, __esModule: true }); | ||
| }; | ||
| document.head.appendChild(s); | ||
| }); | ||
| }; | ||
| } | ||
| </script> | ||
| <style> | ||
| html, body { width: 100% !important; height: 100% !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; } | ||
| #wrap, #emulate { width: 100% !important; height: 100% !important; margin: 0 !important; padding: 0 !important; max-width: none !important; } | ||
| #canvasholder { width: 100% !important; height: 100% !important; } | ||
| #canvas { width: 100% !important; height: 100% !important; max-width: 100% !important; max-height: 100% !important; object-fit: contain; } | ||
| </style>`, | ||
| ); | ||
| } | ||
|
|
||
| // 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 <script src> to our local endpoint. | ||
| * This avoids: 1) import() failing in srcdoc, 2) CORS blocking fetch from srcdoc. | ||
| */ | ||
| async function rewriteEmulationScript( | ||
| html: string, | ||
| serverPort: number, | ||
| ): Promise<string> { | ||
idosal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // NOTE: We intentionally match only the first <script> tag whose src contains | ||
| // "emulation.min.js". Archive.org embeds are expected to include a single | ||
| // relevant emulation script, so rewriting the first match is sufficient. | ||
| // If Archive.org's HTML structure changes to include multiple such scripts, | ||
| // this logic may need to be revisited. | ||
| const pattern = | ||
| /<script\s+[^>]*src=["']([^"']*emulation\.min\.js[^"']*)["'][^>]*><\/script>/i; | ||
| const match = html.match(pattern); | ||
| if (!match) return html; | ||
|
|
||
| const scriptTag = match[0]; | ||
| let scriptUrl = match[1]; | ||
| if (scriptUrl.startsWith("//")) { | ||
| scriptUrl = "https:" + scriptUrl; | ||
| } | ||
|
|
||
| try { | ||
| const response = await fetch(scriptUrl, { | ||
| headers: { "User-Agent": "MCP-Arcade-Server/1.0" }, | ||
| }); | ||
| if (!response.ok) return html; | ||
|
|
||
| let content = await response.text(); | ||
| content = content.replace(/\bimport\s*\(/g, "window.loadScript("); | ||
| cachedEmulationScript = content; | ||
|
|
||
| const localUrl = `http://localhost:${serverPort}/scripts/emulation.js`; | ||
| html = html.replace(scriptTag, `<script src="${localUrl}"></script>`); | ||
| } 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( | ||
| /(<script[^>]*>)([\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})`; | ||
| }, | ||
idosal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ); | ||
|
|
||
| // 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})`; | ||
| }, | ||
| ); | ||
idosal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return newOpenTag + newContent + closeTag; | ||
| }, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| #!/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, validateGameId } from "./server.js"; | ||
| import { | ||
| getCachedEmulationScript, | ||
| processGameEmbed, | ||
| } from "./game-processor.js"; | ||
|
|
||
| const DEFAULT_PORT = 3001; | ||
|
|
||
| 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()). | ||
| // <script src> is not subject to CORS, so this works from srcdoc iframes. | ||
| app.get("/scripts/emulation.js", (_req: Request, res: Response) => { | ||
| const script = getCachedEmulationScript(); | ||
| if (!script) { | ||
| res.status(404).send("// No script cached. Load a game first."); | ||
| return; | ||
| } | ||
| res.setHeader("Content-Type", "application/javascript"); | ||
| res.setHeader("Cache-Control", "no-cache"); | ||
| res.send(script); | ||
| }); | ||
|
|
||
| // Serve game HTML by ID. Fetches and processes the game from archive.org. | ||
| app.get("/game-html/:gameId", async (req: Request, res: Response) => { | ||
| const gameId = req.params.gameId as string; | ||
| if (!validateGameId(gameId)) { | ||
| res.status(400).send("Invalid game ID."); | ||
| return; | ||
| } | ||
| try { | ||
| const html = await processGameEmbed(gameId, port); | ||
| res.setHeader("Content-Type", "text/html"); | ||
| res.setHeader("Cache-Control", "no-cache"); | ||
| res.send(html); | ||
| } catch (error) { | ||
| console.error("Failed to load game:", gameId, error); | ||
| res.status(500).send("Failed to load game."); | ||
| } | ||
| }); | ||
|
|
||
| // MCP endpoint - stateless transport (new server per request) | ||
| app.all("/mcp", async (req: Request, res: Response) => { | ||
| const server = createServer(port); | ||
| const transport = new StreamableHTTPServerTransport({ | ||
| sessionIdGenerator: undefined, | ||
| }); | ||
|
|
||
| res.on("close", () => { | ||
| transport.close().catch(() => {}); | ||
| server.close().catch(() => {}); | ||
| }); | ||
|
|
||
| try { | ||
| await server.connect(transport); | ||
| await transport.handleRequest(req, res, req.body); | ||
| } catch (error) { | ||
| console.error("MCP error:", error); | ||
| if (!res.headersSent) { | ||
| res.status(500).json({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32603, message: "Internal server error" }, | ||
| id: null, | ||
| }); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| const httpServer = app.listen(port, () => { | ||
| console.log(`Arcade MCP Server listening on http://localhost:${port}/mcp`); | ||
| }); | ||
| httpServer.setMaxListeners(20); | ||
|
|
||
| const shutdown = () => { | ||
| console.log("\nShutting down..."); | ||
| httpServer.close(() => process.exit(0)); | ||
| // Force exit after 2 seconds if connections don't close gracefully | ||
| setTimeout(() => process.exit(0), 2000).unref(); | ||
| }; | ||
|
|
||
| process.on("SIGINT", shutdown); | ||
| process.on("SIGTERM", shutdown); | ||
| } | ||
|
|
||
| main().catch((e) => { | ||
| console.error(e); | ||
| process.exit(1); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.