diff --git a/examples/arcade-server/.gitignore b/examples/arcade-server/.gitignore new file mode 100644 index 00000000..b9470778 --- /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 00000000..557f368a --- /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 00000000..79159589 --- /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 00000000..63853bf3 --- /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 9907c20f..aaf1f1b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,38 @@ } } }, + "examples/arcade-server": { + "name": "@modelcontextprotocol/server-arcade", + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.4.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/@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": "0.4.1", @@ -600,13 +632,6 @@ "undici-types": "~6.21.0" } }, - "examples/quickstart/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/say-server": { "name": "@modelcontextprotocol/server-say", "version": "0.4.1", @@ -936,7 +961,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2382,7 +2406,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -2417,6 +2440,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 @@ -3462,7 +3489,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -3669,7 +3695,6 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3694,7 +3719,6 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4097,7 +4121,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4439,7 +4462,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5155,7 +5177,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5628,7 +5649,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7178,7 +7198,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -7388,7 +7407,6 @@ "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7510,7 +7528,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -7801,7 +7818,6 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", "integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -7980,7 +7996,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz", "integrity": "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -9070,7 +9085,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9164,7 +9178,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -9459,7 +9472,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -9620,7 +9632,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -9671,7 +9682,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/scripts/setup-bun.mjs b/scripts/setup-bun.mjs index 3dc46a0c..3314cacc 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);