Skip to content
Open
2 changes: 2 additions & 0 deletions examples/arcade-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
83 changes: 83 additions & 0 deletions examples/arcade-server/README.md
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
187 changes: 187 additions & 0 deletions examples/arcade-server/game-processor.ts
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> {
// 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})`;
},
);

// 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;
},
);
}
105 changes: 105 additions & 0 deletions examples/arcade-server/index.ts
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);
});
Loading
Loading