examples: add MCP Apps arcade server (#346)
This commit is contained in:
parent
e93f8a0d8b
commit
2187bd4498
10 changed files with 1018 additions and 6 deletions
2
examples/arcade-server/.gitignore
vendored
Normal file
2
examples/arcade-server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
83
examples/arcade-server/README.md
Normal file
83
examples/arcade-server/README.md
Normal file
|
|
@ -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
examples/arcade-server/game-processor.ts
Normal file
187
examples/arcade-server/game-processor.ts
Normal file
|
|
@ -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
examples/arcade-server/index.ts
Normal file
105
examples/arcade-server/index.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
46
examples/arcade-server/package.json
Normal file
46
examples/arcade-server/package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"name": "@modelcontextprotocol/server-arcade",
|
||||||
|
"version": "0.4.1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "MCP Apps server for playing arcade games (archive.org)",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/modelcontextprotocol/ext-apps",
|
||||||
|
"directory": "examples/arcade-server"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"types": "dist/server.d.ts",
|
||||||
|
"bin": {
|
||||||
|
"mcp-server-arcade": "dist/index.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/server.d.ts",
|
||||||
|
"default": "./dist/server.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx index.ts",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/ext-apps": "^1.0.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.24.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"zod": "^4.1.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
examples/arcade-server/search.ts
Normal file
112
examples/arcade-server/search.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* Archive.org Search
|
||||||
|
*
|
||||||
|
* Searches archive.org for arcade/emulation games with smart fallbacks
|
||||||
|
* for underscores, spaces, and partial matches.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ArchiveOrgSearchResult {
|
||||||
|
identifier: string;
|
||||||
|
title: string;
|
||||||
|
mediatype: string;
|
||||||
|
description?: string;
|
||||||
|
year?: string;
|
||||||
|
creator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchiveOrgSearchResponse {
|
||||||
|
response: {
|
||||||
|
docs: ArchiveOrgSearchResult[];
|
||||||
|
numFound: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const GAME_COLLECTIONS = [
|
||||||
|
"collection:internetarcade",
|
||||||
|
"collection:consolelivingroom",
|
||||||
|
"collection:softwarelibrary_msdos_games",
|
||||||
|
"collection:atari_2600_library",
|
||||||
|
"collection:tosec",
|
||||||
|
"collection:emularity_engine_jsmess",
|
||||||
|
].join(" OR ");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches archive.org for arcade games matching the given term.
|
||||||
|
*/
|
||||||
|
export async function searchArchiveOrgGames(
|
||||||
|
searchTerm: string,
|
||||||
|
maxResults: number = 10,
|
||||||
|
): Promise<ArchiveOrgSearchResult[]> {
|
||||||
|
const normalizedTerm = searchTerm.trim();
|
||||||
|
const termWithSpaces = normalizedTerm.replace(/_/g, " ");
|
||||||
|
const termWithUnderscores = normalizedTerm.replace(/\s+/g, "_");
|
||||||
|
const words = normalizedTerm.split(/[_\s]+/).filter((w) => w.length > 0);
|
||||||
|
|
||||||
|
const exactVariations = [
|
||||||
|
normalizedTerm,
|
||||||
|
termWithSpaces,
|
||||||
|
termWithUnderscores,
|
||||||
|
].filter((term, index, self) => term && self.indexOf(term) === index);
|
||||||
|
|
||||||
|
const exactQuery = `title:(${exactVariations.join(" OR ")})`;
|
||||||
|
|
||||||
|
// Step 1: Search with exact terms in game collections
|
||||||
|
let games = await performSearch(exactQuery, true, maxResults);
|
||||||
|
|
||||||
|
// Step 2: Broader search with individual words in title
|
||||||
|
if (games.length === 0 && words.length > 1) {
|
||||||
|
const broaderQuery = `title:(${exactVariations.join(" OR ")} OR ${words.join(" OR ")})`;
|
||||||
|
games = await performSearch(broaderQuery, true, maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Search without collection filter as final fallback
|
||||||
|
if (games.length === 0) {
|
||||||
|
const fallbackQuery =
|
||||||
|
words.length > 1
|
||||||
|
? `title:(${exactVariations.join(" OR ")} OR ${words.join(" OR ")})`
|
||||||
|
: exactQuery;
|
||||||
|
|
||||||
|
try {
|
||||||
|
games = await performSearch(fallbackQuery, false, maxResults);
|
||||||
|
games = games.filter(
|
||||||
|
(doc) =>
|
||||||
|
doc.identifier.startsWith("arcade_") ||
|
||||||
|
doc.identifier.startsWith("msdos_") ||
|
||||||
|
doc.identifier.includes("game") ||
|
||||||
|
doc.mediatype === "software",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Continue with empty results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSearch(
|
||||||
|
query: string,
|
||||||
|
withCollectionFilter: boolean,
|
||||||
|
maxResults: number,
|
||||||
|
): Promise<ArchiveOrgSearchResult[]> {
|
||||||
|
const fullQuery = withCollectionFilter
|
||||||
|
? `${query} AND (${GAME_COLLECTIONS})`
|
||||||
|
: query;
|
||||||
|
|
||||||
|
const searchUrl = `https://archive.org/advancedsearch.php?q=${encodeURIComponent(fullQuery)}&fl[]=identifier&fl[]=title&fl[]=mediatype&fl[]=description&fl[]=year&fl[]=creator&output=json&rows=${encodeURIComponent(String(maxResults))}`;
|
||||||
|
|
||||||
|
const response = await fetch(searchUrl, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "MCP-Arcade-Server/1.0",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Archive.org API returned status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ArchiveOrgSearchResponse;
|
||||||
|
if (!data?.response?.docs) return [];
|
||||||
|
|
||||||
|
return data.response.docs.filter((doc) => doc.identifier);
|
||||||
|
}
|
||||||
266
examples/arcade-server/server.ts
Normal file
266
examples/arcade-server/server.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* Arcade MCP Server
|
||||||
|
*
|
||||||
|
* MCP server for browsing and playing arcade games from archive.org.
|
||||||
|
* Fetches game HTML server-side and serves it as an inline MCP App resource.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
RESOURCE_MIME_TYPE,
|
||||||
|
registerAppResource,
|
||||||
|
} from "@modelcontextprotocol/ext-apps/server";
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { searchArchiveOrgGames } from "./search.js";
|
||||||
|
|
||||||
|
const GAME_VIEWER_RESOURCE_URI = "ui://arcade/game-viewer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an archive.org game identifier.
|
||||||
|
*/
|
||||||
|
export function validateGameId(gameId: string): boolean {
|
||||||
|
return (
|
||||||
|
gameId.length > 0 &&
|
||||||
|
!gameId.includes("/") &&
|
||||||
|
!gameId.includes("?") &&
|
||||||
|
!gameId.includes("#") &&
|
||||||
|
!gameId.includes("..") &&
|
||||||
|
!/\s/.test(gameId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new MCP server instance with tools and resources registered.
|
||||||
|
*/
|
||||||
|
export function createServer(port: number): McpServer {
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "arcade-mcp-server",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool: Search for arcade games (no UI)
|
||||||
|
server.registerTool(
|
||||||
|
"search_games",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Searches archive.org for arcade games matching the search term. Returns a list of game identifiers and titles sorted by relevance.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
searchTerm: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'The game name or search term (e.g., "doom", "pacman", "mario").',
|
||||||
|
),
|
||||||
|
maxResults: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.default(10)
|
||||||
|
.describe(
|
||||||
|
"Maximum number of results to return (default: 10, max: 50)",
|
||||||
|
),
|
||||||
|
}) as any,
|
||||||
|
},
|
||||||
|
async (args: any): Promise<CallToolResult> => {
|
||||||
|
const { searchTerm, maxResults } = args;
|
||||||
|
if (!searchTerm || searchTerm.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Error: Search term cannot be empty." },
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(maxResults || 10, 1), 50);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const games = await searchArchiveOrgGames(searchTerm.trim(), limit);
|
||||||
|
|
||||||
|
if (games.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `No arcade games found for "${searchTerm}". Try a different search term.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameData = games.map((game) => ({
|
||||||
|
identifier: game.identifier,
|
||||||
|
title: game.title,
|
||||||
|
description: game.description || null,
|
||||||
|
year: game.year || null,
|
||||||
|
creator: game.creator || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{ total: games.length, searchTerm, games: gameData },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error searching for games: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: Load and play a specific game (with UI)
|
||||||
|
server.registerTool(
|
||||||
|
"get_game_by_id",
|
||||||
|
{
|
||||||
|
title: "Play Arcade Game",
|
||||||
|
description:
|
||||||
|
"Loads and displays a playable arcade game from archive.org by its identifier.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
gameId: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'The archive.org identifier (e.g., "doom-play", "arcade_20pacgal").',
|
||||||
|
),
|
||||||
|
}) as any,
|
||||||
|
_meta: {
|
||||||
|
ui: { resourceUri: GAME_VIEWER_RESOURCE_URI },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (args: any): Promise<CallToolResult> => {
|
||||||
|
const { gameId } = args;
|
||||||
|
if (!gameId || !validateGameId(gameId)) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error: Invalid game ID "${gameId}". Must be a non-empty identifier without path separators.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Loading arcade game: ${gameId}` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resource: Serves the processed game HTML with CSP configuration
|
||||||
|
registerAppResource(
|
||||||
|
server,
|
||||||
|
"Game Viewer",
|
||||||
|
GAME_VIEWER_RESOURCE_URI,
|
||||||
|
{},
|
||||||
|
async () => {
|
||||||
|
// Static view shell with inline MCP Apps protocol handling.
|
||||||
|
// Performs the initialization handshake, then waits for tool input
|
||||||
|
// to know which game to load. Fetches game HTML from the server's
|
||||||
|
// HTTP endpoint keyed by game ID.
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="UTF-8"></head>
|
||||||
|
<body style="margin:0;background:#111;color:#0f0;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh">
|
||||||
|
<div id="status">Loading game\u2026</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var SERVER = "http://localhost:${port}";
|
||||||
|
var initialized = false;
|
||||||
|
|
||||||
|
function send(msg) { window.parent.postMessage(msg, "*"); }
|
||||||
|
|
||||||
|
window.addEventListener("message", function(event) {
|
||||||
|
var msg = event.data;
|
||||||
|
if (!msg || msg.jsonrpc !== "2.0") return;
|
||||||
|
|
||||||
|
// Handle initialize response
|
||||||
|
if (msg.id && msg.result && !initialized) {
|
||||||
|
initialized = true;
|
||||||
|
send({jsonrpc: "2.0", method: "ui/notifications/initialized"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ping
|
||||||
|
if (msg.method === "ping" && msg.id) {
|
||||||
|
send({jsonrpc: "2.0", id: msg.id, result: {}});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tool input - load the game
|
||||||
|
if (msg.method === "ui/notifications/tool-input" && msg.params) {
|
||||||
|
var gameId = msg.params.arguments && msg.params.arguments.gameId;
|
||||||
|
if (gameId) loadGame(gameId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initialize request
|
||||||
|
send({jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {
|
||||||
|
appInfo: {name: "Arcade Game Viewer", version: "1.0.0"},
|
||||||
|
appCapabilities: {},
|
||||||
|
protocolVersion: "2025-11-21"
|
||||||
|
}});
|
||||||
|
|
||||||
|
function loadGame(gameId) {
|
||||||
|
document.getElementById("status").textContent = "Loading " + gameId + "\\u2026";
|
||||||
|
fetch(SERVER + "/game-html/" + encodeURIComponent(gameId))
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then(function(h) {
|
||||||
|
document.open();
|
||||||
|
document.write(h);
|
||||||
|
document.close();
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
document.getElementById("status").textContent = "Failed to load game: " + e.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
17
examples/arcade-server/tsconfig.json
Normal file
17
examples/arcade-server/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
79
package-lock.json
generated
79
package-lock.json
generated
|
|
@ -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": {
|
"examples/basic-host": {
|
||||||
"name": "@modelcontextprotocol/ext-apps-basic-host",
|
"name": "@modelcontextprotocol/ext-apps-basic-host",
|
||||||
"version": "1.0.1",
|
"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": {
|
"node_modules/@modelcontextprotocol/server-basic-preact": {
|
||||||
"resolved": "examples/basic-server-preact",
|
"resolved": "examples/basic-server-preact",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
copyFileSync,
|
copyFileSync,
|
||||||
chmodSync,
|
chmodSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
|
statSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { join, dirname } from "path";
|
import { join, dirname } from "path";
|
||||||
import { spawnSync } from "child_process";
|
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) {
|
function setupBinLink(bunPath) {
|
||||||
if (!existsSync(binDir)) {
|
if (!existsSync(binDir)) {
|
||||||
mkdirSync(binDir, { recursive: true });
|
mkdirSync(binDir, { recursive: true });
|
||||||
|
|
@ -197,17 +218,111 @@ function setupBinLink(bunPath) {
|
||||||
const bunLink = join(binDir, bunExe);
|
const bunLink = join(binDir, bunExe);
|
||||||
const bunxLink = join(binDir, isWindows ? "bunx.exe" : "bunx");
|
const bunxLink = join(binDir, isWindows ? "bunx.exe" : "bunx");
|
||||||
|
|
||||||
// Remove existing links
|
// Check if files already exist and are valid (same size as source)
|
||||||
for (const link of [bunLink, bunxLink]) {
|
// This can help avoid unnecessary copy operations that might fail
|
||||||
|
let needsCopy = true;
|
||||||
|
if (existsSync(bunLink) && existsSync(bunxLink)) {
|
||||||
try {
|
try {
|
||||||
unlinkSync(link);
|
const sourceStat = statSync(bunPath);
|
||||||
} catch {}
|
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) {
|
if (isWindows) {
|
||||||
// On Windows, copy the binary (symlinks may not work without admin)
|
// On Windows, copy the binary (symlinks may not work without admin)
|
||||||
copyFileSync(bunPath, bunLink);
|
// Use retry logic to handle file locking issues
|
||||||
copyFileSync(bunPath, bunxLink);
|
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 {
|
} else {
|
||||||
// On Unix, use symlinks
|
// On Unix, use symlinks
|
||||||
symlinkSync(bunPath, bunLink);
|
symlinkSync(bunPath, bunLink);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue