Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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