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..557f368aa --- /dev/null +++ b/examples/arcade-server/README.md @@ -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 +- `` 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..79159589d --- /dev/null +++ b/examples/arcade-server/index.ts @@ -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()). + // +`; + + return { + contents: [ + { + uri: GAME_VIEWER_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + resourceDomains: [ + "https://archive.org", + "https://*.archive.org", + `http://localhost:${port}`, + ], + connectDomains: [ + "https://archive.org", + "https://*.archive.org", + `http://localhost:${port}`, + ], + baseUriDomains: ["https://archive.org"], + }, + }, + }, + }, + ], + }; + }, + ); + + return server; +} diff --git a/examples/arcade-server/tsconfig.json b/examples/arcade-server/tsconfig.json new file mode 100644 index 000000000..63853bf32 --- /dev/null +++ b/examples/arcade-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "declaration": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/package-lock.json b/package-lock.json index 7aa9ef932..77c65e810 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,81 @@ } } }, + "examples/arcade-server": { + "name": "@modelcontextprotocol/server-arcade", + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.1", + "@modelcontextprotocol/sdk": "^1.24.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "zod": "^4.1.13" + }, + "bin": { + "mcp-server-arcade": "dist/index.js" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "tsx": "^4.7.0", + "typescript": "^5.9.3" + } + }, + "examples/arcade-server/node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.1.tgz", + "integrity": "sha512-rAPzBbB5GNgYk216paQjGKUgbNXSy/yeR95c0ni6Y4uvhWI2AeF+ztEOqQFLBMQy/MPM+02pbVK1HaQmQjMwYQ==", + "hasInstallScript": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "examples/arcade-server/node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "examples/basic-host": { "name": "@modelcontextprotocol/ext-apps-basic-host", "version": "1.0.1", @@ -2617,6 +2692,10 @@ } } }, + "node_modules/@modelcontextprotocol/server-arcade": { + "resolved": "examples/arcade-server", + "link": true + }, "node_modules/@modelcontextprotocol/server-basic-preact": { "resolved": "examples/basic-server-preact", "link": true diff --git a/scripts/setup-bun.mjs b/scripts/setup-bun.mjs index 3dc46a0cf..3314cacca 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,111 @@ 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);