diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5c4f7709..fcf090f5 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -115,6 +115,7 @@ jobs: - threejs-server - transcript-server - video-resource-server + - virtual-desktop-server - wiki-explorer-server steps: diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index e319e35d..b72f2952 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -253,3 +253,29 @@ background-color: #ddd; color: #d00; } + +.toolResultPanel { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.textBlock { + margin: 0; + padding: 1rem; + border-radius: 4px; + background-color: #f5f5f5; + white-space: pre-wrap; + word-break: break-word; + font-family: monospace; + font-size: 0.9rem; + overflow: auto; + max-height: 400px; +} + +.imageBlock { + max-width: 100%; + height: auto; + border-radius: 4px; + border: 1px solid #ddd; +} diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index d3331fe3..f5214d32 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -428,6 +428,39 @@ interface ToolResultPanelProps { } function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) { const result = use(toolCallInfo.resultPromise); + + // Render content blocks nicely instead of raw JSON + if (result.content && Array.isArray(result.content)) { + return ( +
+ {result.isError &&
Error
} + {result.content.map((block, i) => { + if (block.type === "text") { + return ( +
+                {block.text}
+              
+ ); + } + if (block.type === "image") { + const src = `data:${block.mimeType};base64,${block.data}`; + return ( + Tool result + ); + } + // Fallback for unknown content types + return ; + })} +
+ ); + } + + // Fallback for non-standard results return ; } diff --git a/examples/virtual-desktop-server/mcp-app.html b/examples/virtual-desktop-server/mcp-app.html new file mode 100644 index 00000000..50e3d129 --- /dev/null +++ b/examples/virtual-desktop-server/mcp-app.html @@ -0,0 +1,14 @@ + + + + + + + Virtual Desktop Viewer + + + +
+ + + diff --git a/examples/virtual-desktop-server/package.json b/examples/virtual-desktop-server/package.json new file mode 100644 index 00000000..88c78710 --- /dev/null +++ b/examples/virtual-desktop-server/package.json @@ -0,0 +1,48 @@ +{ + "name": "@anthropic/mcp-server-virtual-desktop", + "version": "0.1.0", + "type": "module", + "description": "MCP server for managing virtual desktops using LinuxServer webtop containers", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "examples/virtual-desktop-server" + }, + "license": "MIT", + "main": "server.ts", + "files": [ + "server.ts", + "src", + "dist" + ], + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve": "bun server.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "cross-env": "^10.1.0", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/virtual-desktop-server/server-utils.ts b/examples/virtual-desktop-server/server-utils.ts new file mode 100644 index 00000000..9fe9745a --- /dev/null +++ b/examples/virtual-desktop-server/server-utils.ts @@ -0,0 +1,72 @@ +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import cors from "cors"; +import type { Request, Response } from "express"; + +export interface ServerOptions { + port: number; + name?: string; +} + +/** + * Starts an MCP server with Streamable HTTP transport in stateless mode. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. + */ +export async function startServer( + createServer: () => McpServer, + options: ServerOptions, +): Promise { + const { port, name = "MCP Server" } = options; + + const app = createMcpExpressApp({ host: "0.0.0.0" }); + app.use(cors()); + + app.all("/mcp", async (req: Request, res: Response) => { + const server = createServer(); + 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, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } + console.log(`${name} listening on http://localhost:${port}/mcp`); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} diff --git a/examples/virtual-desktop-server/server.ts b/examples/virtual-desktop-server/server.ts new file mode 100644 index 00000000..21aa18a0 --- /dev/null +++ b/examples/virtual-desktop-server/server.ts @@ -0,0 +1,1288 @@ +/** + * MCP Server for managing virtual desktops using LinuxServer webtop containers. + * + * Tools: + * - ListDesktops: List all virtual desktop containers + * - CreateDesktop: Create a new virtual desktop + * - ViewDesktop: View a virtual desktop (has MCP App UI) + * - ShutdownDesktop: Stop and remove a virtual desktop + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { startServer } from "./server-utils.js"; +import { + listDesktops, + createDesktop, + getDesktop, + shutdownDesktop, + checkDocker, + getPortConfig, + CONTAINER_PREFIX, + DESKTOP_VARIANTS, + DEFAULT_VARIANT, + DEFAULT_RESOLUTION, + VIRTUAL_DESKTOPS_DIR, + type DesktopInfo, + type DesktopVariant, +} from "./src/docker.js"; + +const DIST_DIR = path.join(import.meta.dirname, "dist"); + +// ============================================================================ +// Schemas +// ============================================================================ + +const ResolutionSchema = z.object({ + width: z.number().min(640).max(3840).describe("Width in pixels"), + height: z.number().min(480).max(2160).describe("Height in pixels"), +}); + +const MountSchema = z.object({ + hostPath: z.string().describe("Path on the host machine"), + containerPath: z.string().describe("Path inside the container"), + readonly: z.boolean().optional().describe("Mount as read-only"), +}); + +const DEFAULT_DESKTOP_NAME = "my-desktop"; + +const CreateDesktopInputSchema = z.object({ + name: z + .string() + .default(DEFAULT_DESKTOP_NAME) + .describe( + `Name for the desktop (will be sanitized and prefixed with '${CONTAINER_PREFIX}')`, + ), + variant: z + .enum(DESKTOP_VARIANTS) + .default(DEFAULT_VARIANT) + .describe( + `Desktop variant. Options: xfce (lightweight), webtop-ubuntu-xfce, webtop-alpine-xfce`, + ), + resolution: ResolutionSchema.optional().describe( + `Initial resolution (default: ${DEFAULT_RESOLUTION.width}x${DEFAULT_RESOLUTION.height})`, + ), + commands: z + .array(z.string()) + .optional() + .describe("Commands to run on startup"), + mounts: z.array(MountSchema).optional().describe("Additional volume mounts"), +}); + +const ViewDesktopInputSchema = z.object({ + name: z + .string() + .default(DEFAULT_DESKTOP_NAME) + .describe("Name of the desktop to view (e.g., 'my-desktop')"), +}); + +const ShutdownDesktopInputSchema = z.object({ + name: z.string().describe("Name of the desktop to shutdown"), + cleanup: z + .boolean() + .optional() + .describe( + "Delete the desktop's data directory (default: false, preserves data)", + ), +}); + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Format desktop info for display. + */ +function formatDesktopInfo(desktop: DesktopInfo): string { + const lines = [ + `Name: ${desktop.name}`, + `Status: ${desktop.status}`, + `Container ID: ${desktop.containerId}`, + `Variant: ${desktop.variant}`, + `Resolution: ${desktop.resolution.width}x${desktop.resolution.height}`, + `Commands: ${desktop.commands.join(", ")}`, + ]; + + if (desktop.port) { + lines.push(`Port: ${desktop.port}`); + lines.push(`URL: http://localhost:${desktop.port}`); + } + + lines.push(`Created: ${desktop.createdAt}`); + + return lines.join("\n"); +} + +// ============================================================================ +// Server +// ============================================================================ + +/** + * Creates a new MCP server instance with virtual desktop tools. + */ +export function createVirtualDesktopServer(): McpServer { + const server = new McpServer({ + name: "Virtual Desktop Server", + version: "0.1.0", + }); + + // ==================== ListDesktops ==================== + server.tool( + "list-desktops", + "List all virtual desktop containers", + {}, + async (): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktops = await listDesktops(); + + if (desktops.length === 0) { + return { + content: [ + { + type: "text", + text: "No virtual desktops found. Use create-desktop to create one.", + }, + ], + }; + } + + const text = desktops + .map((d, i) => `[${i + 1}] ${formatDesktopInfo(d)}`) + .join("\n\n"); + + return { + content: [ + { + type: "text", + text: `Found ${desktops.length} virtual desktop(s):\n\n${text}`, + }, + ], + }; + }, + ); + + // ==================== CreateDesktop ==================== + server.tool( + "create-desktop", + "Create a new virtual desktop container", + CreateDesktopInputSchema.shape, + async (args): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + try { + const result = await createDesktop({ + name: args.name, + variant: args.variant, + resolution: args.resolution, + commands: args.commands, + mounts: args.mounts, + }); + + return { + content: [ + { + type: "text", + text: [ + `Virtual desktop created successfully!`, + ``, + `Name: ${result.name}`, + `Container ID: ${result.containerId}`, + `Port: ${result.port}`, + `URL: ${result.url}`, + ``, + `The desktop is starting up. Use view-desktop to connect.`, + ].join("\n"), + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to create desktop: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }, + ); + + // ==================== ViewDesktop ==================== + const viewDesktopResourceUri = "ui://view-desktop/mcp-app.html"; + + registerAppTool( + server, + "view-desktop", + { + title: "View Desktop", + description: "View and interact with a virtual desktop", + inputSchema: ViewDesktopInputSchema.shape, + _meta: { ui: { resourceUri: viewDesktopResourceUri } }, + }, + async (args: { name: string }): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktop = await getDesktop(args.name); + + if (!desktop) { + // Extract the base name from the full container name for the suggestion + const baseName = args.name.startsWith(CONTAINER_PREFIX) + ? args.name.slice(CONTAINER_PREFIX.length) + : args.name; + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Create it first with: create-desktop { "name": "${baseName}" }. Or use list-desktops to see available desktops.`, + }, + ], + }; + } + + if (desktop.status !== "running") { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" is not running (status: ${desktop.status}). Please start it first.`, + }, + ], + }; + } + + if (!desktop.port) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" does not have a port assigned. This may indicate a configuration issue.`, + }, + ], + }; + } + + const url = `http://localhost:${desktop.port}`; + const wsUrl = `ws://localhost:${desktop.port}/websockify`; + const portConfig = getPortConfig(desktop.variant as DesktopVariant); + + return { + content: [ + { + type: "text", + text: [ + `Desktop "${desktop.name}" is ready.`, + ``, + `Open in browser: ${url}`, + `WebSocket URL: ${wsUrl}`, + ``, + `Status: ${desktop.status}`, + `Variant: ${desktop.variant}`, + `Resolution: ${desktop.resolution.width}x${desktop.resolution.height}`, + ].join("\n"), + }, + ], + // Pass structured data to the MCP App + structuredContent: { + name: desktop.name, + url, + wsUrl, + resolution: desktop.resolution, + variant: desktop.variant, + password: portConfig.password, + homeFolder: path.join(VIRTUAL_DESKTOPS_DIR, desktop.name, "home"), + }, + _meta: {}, + }; + }, + ); + + // CSP configuration for the MCP App + const viewDesktopCsp = { + // Allow loading noVNC library from jsdelivr CDN + resourceDomains: ["https://cdn.jsdelivr.net"], + // Allow WebSocket connections to localhost for VNC, and HTTPS for source maps + connectDomains: [ + "ws://localhost:*", + "wss://localhost:*", + "https://cdn.jsdelivr.net", + ], + }; + + registerAppResource( + server, + viewDesktopResourceUri, + viewDesktopResourceUri, + { + mimeType: RESOURCE_MIME_TYPE, + description: "Virtual Desktop Viewer", + }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { + uri: viewDesktopResourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + // CSP must be in content._meta for hosts to read it + _meta: { + ui: { + csp: viewDesktopCsp, + }, + }, + }, + ], + }; + }, + ); + + // ==================== ShutdownDesktop ==================== + server.tool( + "shutdown-desktop", + "Stop and remove a virtual desktop container", + ShutdownDesktopInputSchema.shape, + async (args): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktop = await getDesktop(args.name); + + if (!desktop) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Use list-desktops to see available desktops.`, + }, + ], + }; + } + + const success = await shutdownDesktop(args.name, args.cleanup ?? false); + + if (success) { + const cleanupMessage = args.cleanup + ? " Data directory has been deleted." + : " Data directory has been preserved."; + + return { + content: [ + { + type: "text", + text: `Desktop "${args.name}" has been shut down and removed.${cleanupMessage}`, + }, + ], + }; + } else { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to shutdown desktop "${args.name}". Check Docker logs for details.`, + }, + ], + }; + } + }, + ); + + // ==================== OpenHomeFolder ==================== + const OpenHomeFolderInputSchema = z.object({ + name: z.string().describe("Name of the desktop"), + }); + + registerAppTool( + server, + "open-home-folder", + { + title: "Open Home Folder", + description: + "Open the desktop's home folder on the host machine's file manager", + inputSchema: OpenHomeFolderInputSchema.shape, + _meta: { + ui: { + visibility: ["apps"], + }, + }, + }, + async (args: { name: string }): Promise => { + const desktop = await getDesktop(args.name); + + if (!desktop) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Use list-desktops to see available desktops.`, + }, + ], + }; + } + + // Construct the host path to the desktop's home folder + // Use the resolved container name for the path + const homeFolder = path.join(VIRTUAL_DESKTOPS_DIR, desktop.name, "home"); + + try { + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsync = promisify(exec); + + // Use platform-specific open command + const platform = process.platform; + let openCmd: string; + if (platform === "darwin") { + openCmd = `open "${homeFolder}"`; + } else if (platform === "win32") { + openCmd = `explorer "${homeFolder}"`; + } else { + // Linux and others + openCmd = `xdg-open "${homeFolder}"`; + } + + await execAsync(openCmd); + + return { + content: [ + { + type: "text", + text: `Opened home folder: ${homeFolder}`, + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to open home folder (${homeFolder}): ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }, + ); + + // ==================== TakeScreenshot ==================== + const TakeScreenshotInputSchema = z.object({ + name: z.string().describe("Name of the desktop"), + }); + + server.tool( + "take-screenshot", + "Take a screenshot of the virtual desktop and return it as an image", + TakeScreenshotInputSchema.shape, + async (args): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktop = await getDesktop(args.name); + + if (!desktop) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Use list-desktops to see available desktops.`, + }, + ], + }; + } + + if (desktop.status !== "running") { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" is not running (status: ${desktop.status}).`, + }, + ], + }; + } + + try { + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsync = promisify(exec); + + // Use the resolved container name from desktop + const containerName = desktop.name; + + // Take screenshot using scrot or import (ImageMagick) and output to stdout as PNG + // Try scrot first, fall back to import (ImageMagick) + const { stdout } = await execAsync( + `docker exec ${containerName} bash -c "DISPLAY=:1 scrot -o /tmp/screenshot.png && base64 /tmp/screenshot.png" 2>/dev/null || ` + + `docker exec ${containerName} bash -c "DISPLAY=:1 import -window root /tmp/screenshot.png && base64 /tmp/screenshot.png"`, + { maxBuffer: 50 * 1024 * 1024 }, // 50MB buffer for large screenshots + ); + + return { + content: [ + { + type: "image", + data: stdout.trim(), + mimeType: "image/png", + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to take screenshot: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }, + ); + + // ==================== Click ==================== + const ClickInputSchema = z.object({ + name: z.string().describe("Name of the desktop"), + x: z.number().describe("X coordinate to click"), + y: z.number().describe("Y coordinate to click"), + button: z + .enum(["left", "middle", "right"]) + .optional() + .describe("Mouse button to click (default: left)"), + clicks: z + .number() + .min(1) + .max(3) + .optional() + .describe("Number of clicks (1=single, 2=double, 3=triple; default: 1)"), + }); + + server.tool( + "click", + "Click at a specific position on the virtual desktop", + ClickInputSchema.shape, + async (args): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktop = await getDesktop(args.name); + + if (!desktop) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Use list-desktops to see available desktops.`, + }, + ], + }; + } + + if (desktop.status !== "running") { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" is not running (status: ${desktop.status}).`, + }, + ], + }; + } + + try { + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsync = promisify(exec); + + // Use the resolved container name + const containerName = desktop.name; + + const button = args.button || "left"; + const clicks = args.clicks || 1; + const buttonNum = button === "left" ? 1 : button === "middle" ? 2 : 3; + + // Use xdotool to click at the specified position + const clickCmd = + clicks === 1 + ? `xdotool mousemove ${args.x} ${args.y} click ${buttonNum}` + : `xdotool mousemove ${args.x} ${args.y} click --repeat ${clicks} --delay 100 ${buttonNum}`; + + await execAsync( + `docker exec ${containerName} bash -c "DISPLAY=:1 ${clickCmd}"`, + ); + + return { + content: [ + { + type: "text", + text: `Clicked ${button} button${clicks > 1 ? ` ${clicks} times` : ""} at (${args.x}, ${args.y}) on ${desktop.name}.`, + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to click: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }, + ); + + // ==================== TypeText ==================== + const TypeTextInputSchema = z.object({ + name: z.string().describe("Name of the desktop"), + text: z.string().describe("Text to type"), + delay: z + .number() + .min(0) + .max(1000) + .optional() + .describe("Delay between keystrokes in milliseconds (default: 12)"), + }); + + server.tool( + "type-text", + "Type text on the virtual desktop (simulates keyboard input)", + TypeTextInputSchema.shape, + async (args): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktop = await getDesktop(args.name); + + if (!desktop) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Use list-desktops to see available desktops.`, + }, + ], + }; + } + + if (desktop.status !== "running") { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" is not running (status: ${desktop.status}).`, + }, + ], + }; + } + + try { + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsync = promisify(exec); + + // Use the resolved container name + const containerName = desktop.name; + + const delay = args.delay ?? 12; + + // Escape the text for shell and use xdotool to type it + // Using --clearmodifiers to ensure modifier keys don't interfere + const escapedText = args.text.replace(/'/g, "'\\''"); + await execAsync( + `docker exec ${containerName} bash -c "DISPLAY=:1 xdotool type --clearmodifiers --delay ${delay} '${escapedText}'"`, + ); + + return { + content: [ + { + type: "text", + text: `Typed "${args.text.length > 50 ? args.text.substring(0, 50) + "..." : args.text}" on ${desktop.name}.`, + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to type text: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }, + ); + + // ==================== PressKey ==================== + const PressKeyInputSchema = z.object({ + name: z.string().describe("Name of the desktop"), + key: z + .string() + .describe( + "Key to press (e.g., 'Return', 'Tab', 'Escape', 'ctrl+c', 'alt+F4', 'super')", + ), + }); + + server.tool( + "press-key", + "Press a key or key combination on the virtual desktop", + PressKeyInputSchema.shape, + async (args): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktop = await getDesktop(args.name); + + if (!desktop) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Use list-desktops to see available desktops.`, + }, + ], + }; + } + + if (desktop.status !== "running") { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" is not running (status: ${desktop.status}).`, + }, + ], + }; + } + + try { + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsync = promisify(exec); + + // Use the resolved container name + const containerName = desktop.name; + + // Use xdotool to press the key + await execAsync( + `docker exec ${containerName} bash -c "DISPLAY=:1 xdotool key --clearmodifiers ${args.key}"`, + ); + + return { + content: [ + { + type: "text", + text: `Pressed key "${args.key}" on ${desktop.name}.`, + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to press key: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }, + ); + + // ==================== MoveMouse ==================== + const MoveMouseInputSchema = z.object({ + name: z.string().describe("Name of the desktop"), + x: z.number().describe("X coordinate to move to"), + y: z.number().describe("Y coordinate to move to"), + }); + + server.tool( + "move-mouse", + "Move the mouse cursor to a specific position on the virtual desktop", + MoveMouseInputSchema.shape, + async (args): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktop = await getDesktop(args.name); + + if (!desktop) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Use list-desktops to see available desktops.`, + }, + ], + }; + } + + if (desktop.status !== "running") { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" is not running (status: ${desktop.status}).`, + }, + ], + }; + } + + try { + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsync = promisify(exec); + + // Use the resolved container name + const containerName = desktop.name; + + await execAsync( + `docker exec ${containerName} bash -c "DISPLAY=:1 xdotool mousemove ${args.x} ${args.y}"`, + ); + + return { + content: [ + { + type: "text", + text: `Moved mouse to (${args.x}, ${args.y}) on ${desktop.name}.`, + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to move mouse: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }, + ); + + // ==================== Scroll ==================== + const ScrollInputSchema = z.object({ + name: z.string().describe("Name of the desktop"), + direction: z + .enum(["up", "down", "left", "right"]) + .describe("Scroll direction"), + amount: z + .number() + .min(1) + .max(10) + .optional() + .describe("Number of scroll clicks (default: 3)"), + }); + + server.tool( + "scroll", + "Scroll on the virtual desktop", + ScrollInputSchema.shape, + async (args): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktop = await getDesktop(args.name); + + if (!desktop) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Use list-desktops to see available desktops.`, + }, + ], + }; + } + + if (desktop.status !== "running") { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" is not running (status: ${desktop.status}).`, + }, + ], + }; + } + + try { + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsync = promisify(exec); + + // Use the resolved container name + const containerName = desktop.name; + + const amount = args.amount || 3; + // xdotool uses button 4 for scroll up, 5 for scroll down, 6 for left, 7 for right + const buttonMap = { up: 4, down: 5, left: 6, right: 7 }; + const button = buttonMap[args.direction]; + + await execAsync( + `docker exec ${containerName} bash -c "DISPLAY=:1 xdotool click --repeat ${amount} --delay 50 ${button}"`, + ); + + return { + content: [ + { + type: "text", + text: `Scrolled ${args.direction} ${amount} times on ${desktop.name}.`, + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to scroll: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }, + ); + + // ==================== Exec ==================== + const ExecInputSchema = z.object({ + name: z.string().describe("Name of the desktop"), + command: z + .string() + .describe( + "Command to execute (e.g., 'firefox', 'xfce4-terminal', 'ls -la ~')", + ), + background: z + .boolean() + .optional() + .describe( + "Run in background (default: false). Use true for GUI apps that don't exit.", + ), + timeout: z + .number() + .min(1000) + .max(300000) + .optional() + .describe("Timeout in milliseconds (default: 30000, max: 300000)"), + }); + + server.tool( + "exec", + "Execute a command inside the virtual desktop container. Commands run with DISPLAY=:1 so GUI apps appear in VNC.", + ExecInputSchema.shape, + async (args): Promise => { + const dockerAvailable = await checkDocker(); + if (!dockerAvailable) { + return { + isError: true, + content: [ + { + type: "text", + text: "Docker is not available. Please ensure Docker is installed and running.", + }, + ], + }; + } + + const desktop = await getDesktop(args.name); + + if (!desktop) { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" not found. Use list-desktops to see available desktops.`, + }, + ], + }; + } + + if (desktop.status !== "running") { + return { + isError: true, + content: [ + { + type: "text", + text: `Desktop "${args.name}" is not running (status: ${desktop.status}).`, + }, + ], + }; + } + + try { + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsync = promisify(exec); + + // Use the resolved container name + const containerName = desktop.name; + + const timeout = args.timeout ?? 30000; + const background = args.background ?? false; + + // Escape single quotes in the command + const escapedCommand = args.command.replace(/'/g, "'\\''"); + + // Build the docker exec command + // DISPLAY=:1 ensures GUI apps show in the VNC display + const dockerCmd = background + ? `docker exec -d ${containerName} bash -c "DISPLAY=:1 ${escapedCommand}"` + : `docker exec ${containerName} bash -c "DISPLAY=:1 ${escapedCommand}"`; + + if (background) { + // For background commands, just start them and return + await execAsync(dockerCmd); + return { + content: [ + { + type: "text", + text: `Started in background: ${args.command}`, + }, + ], + }; + } else { + // For foreground commands, capture output + const { stdout, stderr } = await execAsync(dockerCmd, { + timeout, + maxBuffer: 10 * 1024 * 1024, // 10MB + }); + + const output = []; + if (stdout.trim()) { + output.push(`stdout:\n${stdout.trim()}`); + } + if (stderr.trim()) { + output.push(`stderr:\n${stderr.trim()}`); + } + + return { + content: [ + { + type: "text", + text: + output.length > 0 + ? output.join("\n\n") + : `Command completed: ${args.command}`, + }, + ], + }; + } + } catch (error: unknown) { + // Handle exec errors (non-zero exit codes, timeouts, etc.) + const execError = error as { + stdout?: string; + stderr?: string; + code?: number; + killed?: boolean; + message?: string; + }; + + if (execError.killed) { + return { + isError: true, + content: [ + { + type: "text", + text: `Command timed out after ${args.timeout ?? 30000}ms: ${args.command}`, + }, + ], + }; + } + + // Include stdout/stderr even on error + const output = []; + if (execError.stdout?.trim()) { + output.push(`stdout:\n${execError.stdout.trim()}`); + } + if (execError.stderr?.trim()) { + output.push(`stderr:\n${execError.stderr.trim()}`); + } + if (execError.code !== undefined) { + output.push(`exit code: ${execError.code}`); + } + + return { + isError: true, + content: [ + { + type: "text", + text: + output.length > 0 + ? `Command failed: ${args.command}\n\n${output.join("\n\n")}` + : `Command failed: ${execError.message || String(error)}`, + }, + ], + }; + } + }, + ); + + return server; +} + +// ============================================================================ +// Server Startup +// ============================================================================ + +async function main() { + if (process.argv.includes("--stdio")) { + await createVirtualDesktopServer().connect(new StdioServerTransport()); + } else { + const port = parseInt(process.env.PORT ?? "3002", 10); + await startServer(createVirtualDesktopServer, { + port, + name: "Virtual Desktop Server", + }); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/virtual-desktop-server/src/docker.ts b/examples/virtual-desktop-server/src/docker.ts new file mode 100644 index 00000000..0d215137 --- /dev/null +++ b/examples/virtual-desktop-server/src/docker.ts @@ -0,0 +1,489 @@ +/** + * Docker utilities for managing virtual desktop containers. + */ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +const execAsync = promisify(exec); + +/** Prefix for all virtual desktop container names */ +export const CONTAINER_PREFIX = "mcp-apps-vd-"; + +/** Base directory for virtual desktop data */ +export const VIRTUAL_DESKTOPS_DIR = path.join( + homedir(), + ".mcp-virtual-desktops-app", +); + +/** Docker label key for identifying our containers */ +export const LABEL_KEY = "vd.managed"; + +/** Available desktop variants */ +export const DESKTOP_VARIANTS = [ + // Standard webvnc-docker (TigerVNC + noVNC, XFCE) + "xfce", + // LinuxServer webtop variants (older KasmVNC-based tags) + "webtop-ubuntu-xfce", + "webtop-alpine-xfce", +] as const; + +export type DesktopVariant = (typeof DESKTOP_VARIANTS)[number]; + +export const DEFAULT_VARIANT: DesktopVariant = "xfce"; +export const DEFAULT_RESOLUTION = { width: 1280, height: 720 }; +export const DEFAULT_COMMANDS: string[] = []; + +/** Docker image for each variant */ +const VARIANT_IMAGES: Record = { + // ConSol's docker-headless-vnc-container: Ubuntu with XFCE, TigerVNC + noVNC + // https://github.com/ConSol/docker-headless-vnc-container + // Port 6901 for noVNC web UI and websockify + xfce: "consol/ubuntu-xfce-vnc:latest", + // LinuxServer webtop with KasmVNC (pin to old tag before Selkies migration) + "webtop-ubuntu-xfce": + "lscr.io/linuxserver/webtop:ubuntu-xfce-version-2024.01.01", + "webtop-alpine-xfce": + "lscr.io/linuxserver/webtop:alpine-xfce-version-2024.01.01", +}; + +/** Port configuration for each variant type */ +interface PortConfig { + httpPort: number; // Web UI port inside container + vncPort?: number; // Raw VNC port (if available) + websocketPath: string; // Path for websocket connection + password: string; // VNC password (empty if none required) +} + +export function getPortConfig(variant: DesktopVariant): PortConfig { + if (variant === "xfce") { + // ConSol's ubuntu-xfce-vnc uses port 6901 for noVNC, 5901 for VNC + // Default password is "vncpassword" + return { + httpPort: 6901, + vncPort: 5901, + websocketPath: "/websockify", + password: "vncpassword", + }; + } + // LinuxServer webtop (KasmVNC) + return { httpPort: 3000, websocketPath: "/websockify", password: "" }; +} + +export interface DesktopInfo { + name: string; + containerId: string; + status: "running" | "exited" | "paused" | "created" | "unknown"; + port: number | null; + variant: string; + resolution: { width: number; height: number }; + commands: string[]; + createdAt: string; +} + +export interface CreateDesktopOptions { + name: string; + variant?: DesktopVariant; + resolution?: { width: number; height: number }; + commands?: string[]; + mounts?: Array<{ + hostPath: string; + containerPath: string; + readonly?: boolean; + }>; +} + +export interface CreateDesktopResult { + containerId: string; + name: string; + port: number; + url: string; +} + +/** + * Regex for valid Docker container names. + * Must start with alphanumeric, followed by alphanumeric, underscore, dot, or dash. + */ +const VALID_CONTAINER_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; + +/** + * Validate a container name for use in shell commands. + * Throws an error if the name contains invalid characters that could allow injection. + * + * @param name - The container name to validate + * @throws Error if the name is invalid + */ +export function validateContainerName(name: string): void { + if (!name || typeof name !== "string") { + throw new Error("Container name must be a non-empty string"); + } + if (!VALID_CONTAINER_NAME_REGEX.test(name)) { + throw new Error( + `Invalid container name "${name}". Must match [a-zA-Z0-9][a-zA-Z0-9_.-]*`, + ); + } + // Additional safety: reject names that could be interpreted as shell options + if (name.startsWith("-")) { + throw new Error("Container name cannot start with a dash"); + } +} + +/** + * Resolve a logical desktop name to the full container name. + * Accepts either: + * - Logical name (e.g., "my-desktop") → resolves to "mcp-apps-vd-my-desktop" + * - Full name (e.g., "mcp-apps-vd-my-desktop") → returns as-is + * + * @param name - The desktop name (logical or full) + * @returns The full container name with prefix + */ +export function resolveContainerName(name: string): string { + validateContainerName(name); + if (name.startsWith(CONTAINER_PREFIX)) { + return name; + } + return CONTAINER_PREFIX + name; +} + +/** + * Sanitize a name to be valid as a Docker container name. + * Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]* + */ +export function sanitizeName(name: string): string { + // Replace invalid chars with dash + let sanitized = name.replace(/[^a-zA-Z0-9_.-]/g, "-"); + // Remove consecutive dashes + sanitized = sanitized.replace(/-+/g, "-"); + // Remove leading/trailing dashes + sanitized = sanitized.replace(/^-+|-+$/g, ""); + // Ensure starts with alphanumeric + if (!/^[a-zA-Z0-9]/.test(sanitized)) { + sanitized = "x" + sanitized; + } + // If empty after sanitization, use a default + if (!sanitized) { + sanitized = "desktop"; + } + return CONTAINER_PREFIX + sanitized; +} + +/** + * Get a unique container name by incrementing if needed. + */ +export async function getUniqueName(baseName: string): Promise { + const sanitized = sanitizeName(baseName); + const existing = await listContainerNames(); + + if (!existing.has(sanitized)) { + return sanitized; + } + + // Try incrementing a number suffix + const match = sanitized.match(/^(.+)-(\d+)$/); + if (match) { + const base = match[1]; + let num = parseInt(match[2], 10); + while (existing.has(`${base}-${++num}`)) { + // Keep incrementing + } + return `${base}-${num}`; + } + + // Append a number + let num = 1; + while (existing.has(`${sanitized}-${++num}`)) { + // Keep incrementing + } + return `${sanitized}-${num}`; +} + +/** + * List all container names (including stopped ones). + */ +async function listContainerNames(): Promise> { + try { + const { stdout } = await execAsync( + `docker ps -a --filter "label=${LABEL_KEY}" --format "{{.Names}}"`, + ); + return new Set(stdout.trim().split("\n").filter(Boolean)); + } catch { + return new Set(); + } +} + +/** + * Find an available port in the given range. + */ +export async function findAvailablePort( + startPort: number = 13000, + endPort: number = 14000, +): Promise { + // Get list of ports currently in use by our containers + const usedPorts = new Set(); + + try { + const { stdout } = await execAsync( + `docker ps --filter "label=${LABEL_KEY}" --format "{{.Ports}}"`, + ); + // Match any port mapping: 0.0.0.0:HOSTPORT->CONTAINERPORT + const portMatches = stdout.matchAll(/0\.0\.0\.0:(\d+)->\d+/g); + for (const match of portMatches) { + usedPorts.add(parseInt(match[1], 10)); + } + } catch { + // Ignore errors, just start from startPort + } + + // Find first available port + for (let port = startPort; port <= endPort; port++) { + if (!usedPorts.has(port)) { + // Double-check by trying to see if anything is listening + try { + await execAsync(`lsof -i :${port} -t`); + // Port is in use + } catch { + // Port is available (lsof returns error when nothing found) + return port; + } + } + } + + throw new Error(`No available ports in range ${startPort}-${endPort}`); +} + +/** + * Create the autostart script for the given commands. + */ +async function createAutostartScript( + desktopDir: string, + commands: string[], +): Promise { + const autostartDir = path.join(desktopDir, "autostart"); + await mkdir(autostartDir, { recursive: true }); + + const scriptContent = `#!/bin/bash +# Auto-generated startup script for virtual desktop +sleep 2 # Wait for desktop to initialize + +${commands.map((cmd) => `${cmd} &`).join("\n")} + +# Keep script running to prevent immediate exit +wait +`; + + const scriptPath = path.join(autostartDir, "startup.sh"); + await writeFile(scriptPath, scriptContent, { mode: 0o755 }); +} + +/** + * List all virtual desktop containers. + */ +export async function listDesktops(): Promise { + try { + const { stdout } = await execAsync( + `docker ps -a --filter "label=${LABEL_KEY}" --format "{{json .}}"`, + ); + + const lines = stdout.trim().split("\n").filter(Boolean); + const desktops: DesktopInfo[] = []; + + for (const line of lines) { + const container = JSON.parse(line); + + // Get labels from inspect + const { stdout: inspectOut } = await execAsync( + `docker inspect --format "{{json .Config.Labels}}" ${container.ID}`, + ); + const labels = JSON.parse(inspectOut.trim()); + + // Parse port from Ports field (e.g., "0.0.0.0:13000->6901/tcp") + let port: number | null = null; + const portMatch = container.Ports?.match(/0\.0\.0\.0:(\d+)->\d+/); + if (portMatch) { + port = parseInt(portMatch[1], 10); + } + + // Parse resolution from label + let resolution = DEFAULT_RESOLUTION; + if (labels["vd.resolution"]) { + const [w, h] = labels["vd.resolution"].split("x").map(Number); + if (w && h) resolution = { width: w, height: h }; + } + + // Parse commands from label + let commands = DEFAULT_COMMANDS; + if (labels["vd.commands"]) { + commands = labels["vd.commands"].split(","); + } + + // Normalize status + let status: DesktopInfo["status"] = "unknown"; + const state = container.State?.toLowerCase() || ""; + if (state === "running") status = "running"; + else if (state === "exited") status = "exited"; + else if (state === "paused") status = "paused"; + else if (state === "created") status = "created"; + + desktops.push({ + name: container.Names, + containerId: container.ID, + status, + port, + variant: labels["vd.variant"] || DEFAULT_VARIANT, + resolution, + commands, + createdAt: labels["vd.created"] || container.CreatedAt, + }); + } + + return desktops; + } catch (error) { + console.error("Error listing desktops:", error); + return []; + } +} + +/** + * Create a new virtual desktop container. + */ +export async function createDesktop( + options: CreateDesktopOptions, +): Promise { + const { + name, + variant = DEFAULT_VARIANT, + resolution = DEFAULT_RESOLUTION, + commands = DEFAULT_COMMANDS, + mounts = [], + } = options; + + // Get unique container name + const containerName = await getUniqueName(name); + + // Find available port + const port = await findAvailablePort(); + + // Get image and port config for this variant + const image = VARIANT_IMAGES[variant]; + const portConfig = getPortConfig(variant); + + // Create desktop directory + const desktopDir = path.join(VIRTUAL_DESKTOPS_DIR, containerName); + const homeDir = path.join(desktopDir, "home"); + await mkdir(homeDir, { recursive: true }); + + // Create autostart script if commands provided + if (commands.length > 0) { + await createAutostartScript(desktopDir, commands); + } + + // Build docker run command + const labels = [ + `--label ${LABEL_KEY}=true`, + `--label vd.variant=${variant}`, + `--label vd.resolution=${resolution.width}x${resolution.height}`, + `--label vd.commands=${commands.join(",")}`, + `--label vd.websocketPath=${portConfig.websocketPath}`, + `--label vd.created=${new Date().toISOString()}`, + ]; + + // Volume mounts differ by variant + const volumes: string[] = []; + if (variant.startsWith("webtop-")) { + // LinuxServer webtop uses /config for home + volumes.push(`-v "${homeDir}:/config"`); + if (commands.length > 0) { + volumes.push( + `-v "${path.join(desktopDir, "autostart")}:/config/autostart"`, + ); + } + } + // Add custom mounts + volumes.push( + ...mounts.map( + (m) => `-v "${m.hostPath}:${m.containerPath}${m.readonly ? ":ro" : ""}"`, + ), + ); + + // Environment variables differ by variant + const envVars: string[] = ["-e TZ=Etc/UTC"]; + if (variant.startsWith("webtop-")) { + envVars.push("-e PUID=1000", "-e PGID=1000"); + envVars.push(`-e CUSTOM_RES=${resolution.width}x${resolution.height}`); + } else if (variant === "xfce") { + // webvnc-docker uses RESOLUTION env var + envVars.push(`-e RESOLUTION=${resolution.width}x${resolution.height}`); + } + + const dockerCmd = [ + "docker run -d", + `--name ${containerName}`, + '--shm-size="1gb"', + `-p ${port}:${portConfig.httpPort}`, + ...labels, + ...volumes, + ...envVars, + image, + ].join(" "); + + const { stdout } = await execAsync(dockerCmd); + const containerId = stdout.trim(); + + return { + containerId, + name: containerName, + port, + url: `http://localhost:${port}`, + }; +} + +/** + * Get information about a specific desktop. + */ +export async function getDesktop(name: string): Promise { + const fullName = resolveContainerName(name); + const desktops = await listDesktops(); + return desktops.find((d) => d.name === fullName) || null; +} + +/** + * Shutdown and remove a virtual desktop container. + */ +export async function shutdownDesktop( + name: string, + cleanup: boolean = false, +): Promise { + const fullName = resolveContainerName(name); + try { + // Stop the container + await execAsync(`docker stop ${fullName}`).catch(() => {}); + + // Remove the container + await execAsync(`docker rm ${fullName}`); + + // Optionally clean up the data directory + if (cleanup) { + const desktopDir = path.join(VIRTUAL_DESKTOPS_DIR, fullName); + await rm(desktopDir, { recursive: true, force: true }); + } + + return true; + } catch (error) { + console.error("Error shutting down desktop:", error); + return false; + } +} + +/** + * Check if Docker is available. + */ +export async function checkDocker(): Promise { + try { + await execAsync("docker info"); + return true; + } catch { + return false; + } +} diff --git a/examples/virtual-desktop-server/src/global.css b/examples/virtual-desktop-server/src/global.css new file mode 100644 index 00000000..f3356f78 --- /dev/null +++ b/examples/virtual-desktop-server/src/global.css @@ -0,0 +1,25 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; + background: transparent; + color: #fff; + height: 100%; + overflow: hidden; +} + +#root { + height: 100%; + background: transparent; +} + +code { + font-size: 1em; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} diff --git a/examples/virtual-desktop-server/src/mcp-app.module.css b/examples/virtual-desktop-server/src/mcp-app.module.css new file mode 100644 index 00000000..000627a9 --- /dev/null +++ b/examples/virtual-desktop-server/src/mcp-app.module.css @@ -0,0 +1,330 @@ +/* Default (dark theme) */ +:root { + --vnc-bg: #1a1a1a; + --vnc-bg-secondary: #252525; + --vnc-border: #333; + --vnc-text: #fff; + --vnc-text-muted: #888; + --vnc-text-subtle: #666; + --vnc-button-bg: transparent; + --vnc-button-border: #444; + --vnc-button-hover-bg: #333; + --vnc-button-hover-border: #555; +} + +/* Light theme */ +@media (prefers-color-scheme: light) { + :root { + --vnc-bg: #f5f5f5; + --vnc-bg-secondary: #e8e8e8; + --vnc-border: #ddd; + --vnc-text: #1a1a1a; + --vnc-text-muted: #666; + --vnc-text-subtle: #888; + --vnc-button-bg: transparent; + --vnc-button-border: #ccc; + --vnc-button-hover-bg: #ddd; + --vnc-button-hover-border: #bbb; + } +} + +.container { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + min-height: 500px; + background: transparent; + color: var(--vnc-text); + overflow: hidden; + border-radius: 8px; +} + +.loading, +.error, +.waiting { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + padding: 2rem; + text-align: center; + background: var(--vnc-bg); + border-radius: 8px; +} + +.error { + color: #ff6b6b; +} + +.waiting { + color: var(--vnc-text-muted); +} + +.hint { + font-size: 0.875rem; + margin-top: 0.5rem; +} + +.hint code { + background: var(--vnc-bg-secondary); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-family: monospace; +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + background: var(--vnc-bg-secondary); + border-bottom: 1px solid var(--vnc-border); + flex-shrink: 0; + border-radius: 8px 8px 0 0; +} + +.desktopName { + font-weight: 600; + font-size: 0.875rem; +} + +.status { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; +} + +.statusConnected { + background: #22c55e20; + color: #22c55e; +} + +.statusConnecting { + background: #f59e0b20; + color: #f59e0b; +} + +.toolbarActions { + margin-left: auto; + display: flex; + gap: 0.5rem; +} + +.toolbarButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--vnc-button-bg); + border: 1px solid var(--vnc-button-border); + border-radius: 4px; + color: var(--vnc-text-muted); + cursor: pointer; + transition: all 0.15s ease; +} + +.toolbarButton:hover:not(:disabled) { + background: var(--vnc-button-hover-bg); + color: var(--vnc-text); + border-color: var(--vnc-button-hover-border); +} + +.toolbarButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* VNC Container */ +.vncContainer { + flex: 1 1 0; + min-height: 0; + position: relative; + overflow: hidden; + background: var(--vnc-bg); + border-radius: 0 0 8px 8px; +} + +.connectingOverlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.8); + z-index: 10; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid #333; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.vncCanvas { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* Let noVNC handle canvas sizing with scaleViewport. + Canvas is absolute positioned so it doesn't affect parent layout. */ +.vncCanvas canvas { + max-width: 100%; + max-height: 100%; +} + +/* Desktop Card (CSP blocked fallback) */ +.desktopCard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 2rem; + text-align: center; + background: var(--vnc-bg); + border-radius: 8px; +} + +.desktopIcon { + color: #3b82f6; + margin-bottom: 1rem; +} + +.desktopTitle { + margin: 0 0 0.75rem; + font-size: 1.5rem; + font-weight: 600; + color: var(--vnc-text); +} + +.desktopMeta { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.badge { + background: var(--vnc-bg-secondary); + color: var(--vnc-text-muted); + padding: 0.25rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.desktopUrl { + color: var(--vnc-text-subtle); + font-size: 0.875rem; + margin: 0 0 1.5rem; + font-family: ui-monospace, monospace; +} + +.openButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.875rem 1.5rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.openButton:hover { + background: #2563eb; + transform: translateY(-1px); +} + +.openButton:active { + transform: translateY(0); +} + +/* Disconnected State */ +.disconnected { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 2rem; + text-align: center; + background: var(--vnc-bg); + border-radius: 8px; +} + +.disconnected .icon { + color: var(--vnc-text-subtle); + margin-bottom: 1rem; +} + +.disconnected h2 { + margin: 0 0 0.5rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--vnc-text); +} + +.errorText { + color: var(--vnc-text-muted); + margin: 0 0 1.5rem; + font-size: 0.875rem; +} + +.actions { + display: flex; + gap: 0.75rem; +} + +.primaryButton, +.secondaryButton { + padding: 0.625rem 1.25rem; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.primaryButton { + background: #3b82f6; + color: #fff; + border: none; +} + +.primaryButton:hover { + background: #2563eb; +} + +.secondaryButton { + background: transparent; + color: var(--vnc-text-muted); + border: 1px solid var(--vnc-button-border); +} + +.secondaryButton:hover { + background: var(--vnc-button-hover-bg); + color: var(--vnc-text); + border-color: var(--vnc-button-hover-border); +} diff --git a/examples/virtual-desktop-server/src/mcp-app.tsx b/examples/virtual-desktop-server/src/mcp-app.tsx new file mode 100644 index 00000000..aa9b130e --- /dev/null +++ b/examples/virtual-desktop-server/src/mcp-app.tsx @@ -0,0 +1,938 @@ +/** + * ViewDesktop MCP App - Virtual desktop viewer using noVNC. + * + * Connects to VNC desktops via websocket using the noVNC library. + * Falls back to "Open in Browser" when connection fails. + */ +import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + StrictMode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createRoot } from "react-dom/client"; +import styles from "./mcp-app.module.css"; + +const IMPLEMENTATION = { name: "Virtual Desktop Viewer", version: "1.0.0" }; + +// noVNC RFB type (loaded dynamically) +interface RFBInstance { + scaleViewport: boolean; + resizeSession: boolean; + disconnect(): void; + addEventListener(type: string, listener: (event: CustomEvent) => void): void; + sendCredentials(credentials: { password: string }): void; +} + +interface DesktopInfo { + name: string; + url: string; + wsUrl: string; + resolution: { width: number; height: number }; + variant: string; + password?: string; + homeFolder?: string; +} + +type ConnectionState = + | "loading" + | "connecting" + | "connected" + | "disconnected" + | "error" + | "csp-blocked"; + +const log = { + info: console.log.bind(console, "[VNC]"), + warn: console.warn.bind(console, "[VNC]"), + error: console.error.bind(console, "[VNC]"), +}; + +// noVNC loading state +let RFBClass: new ( + target: HTMLElement, + url: string, + options?: { credentials?: { password?: string } }, +) => RFBInstance; +let rfbLoadPromise: Promise | null = null; +let rfbLoadFailed = false; + +async function loadNoVNC(): Promise { + if (RFBClass) return; + if (rfbLoadFailed) throw new Error("VNC library blocked by CSP"); + if (rfbLoadPromise) return rfbLoadPromise; + + // Use jsDelivr's ESM bundler endpoint which auto-bundles all dependencies + const NOVNC_CDN_URL = "https://cdn.jsdelivr.net/npm/@novnc/novnc@1.6.0/+esm"; + + rfbLoadPromise = new Promise((resolve, reject) => { + // Try loading via dynamic import in a module script + const script = document.createElement("script"); + script.type = "module"; + // jsDelivr's +esm may wrap the default export - handle both cases + script.textContent = ` + import * as noVNC from "${NOVNC_CDN_URL}"; + console.log("[VNC] noVNC module loaded, keys:", Object.keys(noVNC)); + // Try default export, then .default property (CJS->ESM conversion) + let RFB = noVNC.default; + if (RFB && typeof RFB !== 'function' && RFB.default) { + RFB = RFB.default; + } + console.log("[VNC] RFB type:", typeof RFB, RFB?.name); + window.__noVNC_RFB = RFB; + window.dispatchEvent(new Event("novnc-loaded")); + `; + + const timeoutId = setTimeout(() => { + rfbLoadFailed = true; + window.removeEventListener("novnc-loaded", handleLoad); + reject(new Error("VNC library load timeout - likely blocked by CSP")); + }, 10000); + + const handleLoad = () => { + clearTimeout(timeoutId); + RFBClass = (window as unknown as { __noVNC_RFB: typeof RFBClass }) + .__noVNC_RFB; + window.removeEventListener("novnc-loaded", handleLoad); + if (RFBClass && typeof RFBClass === "function") { + log.info("noVNC loaded successfully"); + resolve(); + } else { + rfbLoadFailed = true; + reject( + new Error( + "VNC library failed to initialize - RFB is not a constructor", + ), + ); + } + }; + + window.addEventListener("novnc-loaded", handleLoad); + + // CSP will block this and throw an error + try { + document.head.appendChild(script); + } catch (e) { + clearTimeout(timeoutId); + rfbLoadFailed = true; + window.removeEventListener("novnc-loaded", handleLoad); + reject(new Error("VNC library blocked by CSP")); + } + }); + + return rfbLoadPromise; +} + +/** + * Parse query params for standalone testing mode. + * URL format: ?wsUrl=ws://localhost:13000/websockify&name=test&password=vncpassword + */ +function getStandaloneDesktopInfo(): DesktopInfo | null { + const params = new URLSearchParams(window.location.search); + const wsUrl = params.get("wsUrl"); + const name = params.get("name") || "standalone"; + + if (!wsUrl) return null; + + // Derive HTTP URL from WebSocket URL + const url = wsUrl.replace(/^ws/, "http").replace(/\/websockify$/, ""); + + return { + name, + url, + wsUrl, + resolution: { + width: parseInt(params.get("width") || "1280", 10), + height: parseInt(params.get("height") || "720", 10), + }, + variant: params.get("variant") || "xfce", + password: params.get("password") || "", + }; +} + +// Standalone mode component - no host connection +function ViewDesktopStandalone({ desktopInfo }: { desktopInfo: DesktopInfo }) { + log.info("Running in standalone mode with:", desktopInfo); + return ( + + ); +} + +// Hosted mode component - connects to MCP host +function ViewDesktopHosted() { + const [toolResult, setToolResult] = useState(null); + const [hostContext, setHostContext] = useState< + McpUiHostContext | undefined + >(); + const [desktopInfo, setDesktopInfo] = useState(null); + + const { app, error } = useApp({ + appInfo: IMPLEMENTATION, + capabilities: {}, + onAppCreated: (app) => { + app.onteardown = async () => { + log.info("App is being torn down"); + return {}; + }; + + app.ontoolinput = async (input) => { + log.info("Received tool input:", input); + }; + + app.ontoolresult = async (result) => { + log.info("Received tool result:", result); + setToolResult(result); + + // Extract desktop info from structuredContent + const structured = result.structuredContent as DesktopInfo | undefined; + if (structured?.name && structured?.wsUrl) { + setDesktopInfo(structured); + } + }; + + app.onerror = log.error; + + app.onhostcontextchanged = (params) => { + setHostContext((prev) => ({ ...prev, ...params })); + }; + }, + }); + + useEffect(() => { + if (app) { + setHostContext(app.getHostContext()); + } + }, [app]); + + if (error) { + return ( +
+ Error: {error.message} +
+ ); + } + + if (!app) { + return
Connecting to host...
; + } + + return ( + + ); +} + +function ViewDesktopApp() { + // Check for standalone mode via query params + const standaloneInfo = useMemo(() => getStandaloneDesktopInfo(), []); + + if (standaloneInfo) { + return ; + } + + return ; +} + +interface ViewDesktopInnerProps { + app: App | null; + toolResult: CallToolResult | null; + hostContext?: McpUiHostContext; + desktopInfo: DesktopInfo | null; +} + +function ViewDesktopInner({ + app, + toolResult, + hostContext, + desktopInfo, +}: ViewDesktopInnerProps) { + const [connectionState, setConnectionState] = + useState("loading"); + const [errorMessage, setErrorMessage] = useState(null); + const containerRef = useRef(null); + const rfbRef = useRef(null); + const isConnectingRef = useRef(false); // Guard against duplicate connections + + // Extract desktop info from tool result text if not in metadata + const extractedInfo = useExtractDesktopInfo(toolResult, desktopInfo); + + // Track if noVNC is loaded + const [noVncReady, setNoVncReady] = useState(false); + + // Load noVNC library + useEffect(() => { + loadNoVNC() + .then(() => { + log.info("noVNC loaded successfully"); + setNoVncReady(true); + setConnectionState("connecting"); + }) + .catch((e) => { + log.warn("noVNC load failed:", e.message); + setConnectionState("csp-blocked"); + setErrorMessage( + "Embedded viewer not available. Please open in browser.", + ); + }); + }, []); + + // Connect to VNC server + const connect = useCallback(() => { + if (!extractedInfo || !containerRef.current || !RFBClass) return; + + // Guard against duplicate connection attempts + if (isConnectingRef.current) { + log.info("Connection already in progress, skipping"); + return; + } + isConnectingRef.current = true; + + // Disconnect existing connection and clear container + if (rfbRef.current) { + rfbRef.current.disconnect(); + rfbRef.current = null; + } + // Clear any leftover canvas elements from previous connection + containerRef.current.innerHTML = ""; + + setConnectionState("connecting"); + setErrorMessage(null); + + try { + log.info("Connecting to", extractedInfo.wsUrl); + + // Password is provided by the server based on the container variant + // wsProtocols: ['binary'] is required for websockify to accept the connection + const password = extractedInfo.password ?? ""; + log.info( + "Using password:", + password ? "(set)" : "(empty)", + "for variant:", + extractedInfo.variant, + ); + const rfb = new RFBClass(containerRef.current, extractedInfo.wsUrl, { + credentials: { password }, + wsProtocols: ["binary"], + } as { credentials?: { password?: string }; wsProtocols?: string[] }); + + rfb.scaleViewport = true; + // Don't resize session - can cause disconnects with "Invalid screen layout" + rfb.resizeSession = false; + + // Log all RFB events for debugging + const logEvent = (name: string) => (e: CustomEvent) => { + log.info(`RFB event [${name}]:`, e.detail ?? "(no detail)"); + }; + + rfb.addEventListener("connect", () => { + log.info("Connected to VNC server"); + isConnectingRef.current = false; + setConnectionState("connected"); + setErrorMessage(null); + }); + + rfb.addEventListener( + "disconnect", + (e: CustomEvent<{ clean: boolean; reason?: string }>) => { + log.info( + "Disconnected from VNC server, clean:", + e.detail.clean, + "reason:", + e.detail.reason || "none", + ); + isConnectingRef.current = false; + + if (e.detail.clean) { + setConnectionState("disconnected"); + setErrorMessage(`Desktop disconnected. ${e.detail.reason || ""}`); + } else { + setConnectionState("disconnected"); + setErrorMessage("Connection lost. Click Reconnect to try again."); + } + }, + ); + + rfb.addEventListener("securityfailure", (e: CustomEvent) => { + log.error("Security failure:", e.detail); + isConnectingRef.current = false; + setConnectionState("error"); + setErrorMessage( + `Security failure: ${(e.detail as { reason?: string })?.reason || "Unknown"}`, + ); + }); + + rfb.addEventListener("credentialsrequired", () => { + log.info("Credentials required, sending password"); + rfb.sendCredentials({ password }); + }); + + // Additional debug events + rfb.addEventListener( + "serververification", + logEvent("serververification"), + ); + rfb.addEventListener("clipboard", logEvent("clipboard")); + rfb.addEventListener("bell", logEvent("bell")); + rfb.addEventListener("desktopname", logEvent("desktopname")); + rfb.addEventListener("capabilities", logEvent("capabilities")); + + rfbRef.current = rfb; + } catch (e) { + log.error("Failed to connect:", e); + isConnectingRef.current = false; + setConnectionState("error"); + setErrorMessage( + `Failed to connect: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, [extractedInfo]); + + // Connect when library is ready and state is "connecting" + useEffect(() => { + if ( + noVncReady && + extractedInfo && + containerRef.current && + RFBClass && + connectionState === "connecting" + ) { + log.info("Ready to connect, initiating VNC connection..."); + connect(); + } + }, [noVncReady, extractedInfo, connectionState, connect]); + + // Resize desktop when container size changes + useEffect(() => { + if (!app || !extractedInfo || connectionState !== "connected") return; + + const container = containerRef.current; + if (!container) return; + + // Get the parent container - canvas is now absolute positioned + // so it won't affect the parent's layout + const parentContainer = container.parentElement; + if (!parentContainer) return; + + let resizeTimeout: ReturnType | null = null; + let lastSize = { width: 0, height: 0 }; + const RESIZE_DEBOUNCE = 300; // ms + + const handleResize = () => { + // Use parent's dimensions directly since canvas is absolutely positioned + const rect = parentContainer.getBoundingClientRect(); + const width = Math.floor(rect.width); + const height = Math.floor(rect.height); + + // Ignore very small sizes (layout not ready) + if (width < 100 || height < 100) { + return; + } + + log.info(`Container size: ${width}x${height}`); + + // Use the exact container dimensions for the desktop + // This ensures the desktop fills the space without letterboxing + const newWidth = Math.floor(width); + const newHeight = Math.floor(height); + + // Skip if size hasn't changed significantly + if (newWidth === lastSize.width && newHeight === lastSize.height) { + return; + } + + if (resizeTimeout) clearTimeout(resizeTimeout); + + resizeTimeout = setTimeout(async () => { + lastSize = { width: newWidth, height: newHeight }; + log.info(`Resizing desktop to ${newWidth}x${newHeight}`); + try { + // Create a custom xrandr mode and apply it using a single-line command + const modeName = `${newWidth}x${newHeight}_60`; + const cmd = `modeline=$(cvt ${newWidth} ${newHeight} 60 2>/dev/null | grep Modeline | cut -d' ' -f3-) && [ -n "$modeline" ] && (xrandr --newmode ${modeName} $modeline 2>/dev/null || true) && (xrandr --addmode VNC-0 ${modeName} 2>/dev/null || true) && xrandr --output VNC-0 --mode ${modeName}`; + log.info("Executing resize command:", cmd); + const result = await app.callServerTool({ + name: "exec", + arguments: { + name: extractedInfo.name, + command: cmd, + background: false, + timeout: 10000, + }, + }); + log.info("Resize result:", result); + } catch (e) { + log.warn("Failed to resize desktop:", e); + } + }, RESIZE_DEBOUNCE); + }; + + const observer = new ResizeObserver(handleResize); + observer.observe(parentContainer); + + // Also trigger an initial resize check + handleResize(); + + return () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + observer.disconnect(); + }; + }, [app, extractedInfo, connectionState]); + + // Cleanup on unmount only + useEffect(() => { + return () => { + if (rfbRef.current) { + rfbRef.current.disconnect(); + rfbRef.current = null; + } + }; + }, []); + + // Periodic screenshot updates to model context + useEffect(() => { + if (!app || connectionState !== "connected") return; + + // Check if host supports image updates + const hostCapabilities = app.getHostCapabilities(); + if (!hostCapabilities?.updateModelContext?.image) { + log.info("Host does not support image updates to model context"); + return; + } + + const container = containerRef.current; + if (!container) return; + + let lastScreenshotHash: string | null = null; + let consecutiveFailures = 0; + const MAX_FAILURES = 3; + const SCREENSHOT_INTERVAL = 2000; // 2 seconds + + // Simple hash function for deduplication (faster than comparing full base64) + const hashString = (str: string): string => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return hash.toString(16); + }; + + const captureAndSendScreenshot = async () => { + // Stop after too many consecutive failures + if (consecutiveFailures >= MAX_FAILURES) { + return; + } + + const canvas = container.querySelector("canvas"); + if (!canvas) return; + + try { + // Use JPEG for smaller size (5-10x smaller than PNG) + const dataUrl = canvas.toDataURL("image/jpeg", 0.7); + const base64Data = dataUrl.replace(/^data:image\/jpeg;base64,/, ""); + + // Use hash for efficient deduplication + const currentHash = hashString(base64Data); + if (currentHash === lastScreenshotHash) { + return; + } + + lastScreenshotHash = currentHash; + + // Send screenshot to model context + await app.updateModelContext({ + content: [ + { + type: "image", + data: base64Data, + mimeType: "image/jpeg", + }, + ], + }); + + consecutiveFailures = 0; // Reset on success + log.info("Sent screenshot to model context"); + } catch (e) { + consecutiveFailures++; + if (consecutiveFailures >= MAX_FAILURES) { + log.warn("Disabling screenshot updates after repeated failures:", e); + } + } + }; + + // Start periodic capture + const intervalId = setInterval( + captureAndSendScreenshot, + SCREENSHOT_INTERVAL, + ); + + // Capture initial screenshot after a short delay + const initialTimeout = setTimeout(captureAndSendScreenshot, 500); + + return () => { + clearInterval(intervalId); + clearTimeout(initialTimeout); + }; + }, [app, connectionState]); + + const handleReconnect = useCallback(() => { + // Set state to connecting, which will render the VNC container + // The useEffect will then trigger the actual connection + setConnectionState("connecting"); + setErrorMessage(null); + }, []); + + const handleOpenInBrowser = useCallback(() => { + if (extractedInfo?.url) { + if (app) { + app.openLink({ url: extractedInfo.url }); + } else { + window.open(extractedInfo.url, "_blank"); + } + } + }, [app, extractedInfo]); + + // Track fullscreen from host context (preferred) or document state (standalone) + const isFullscreen = + hostContext?.displayMode === "fullscreen" || + (typeof document !== "undefined" && !!document.fullscreenElement); + + const handleToggleFullscreen = useCallback(async () => { + try { + if (isFullscreen) { + if (app) { + await app.requestDisplayMode({ mode: "inline" }); + } else { + await document.exitFullscreen(); + } + } else if (app) { + await app.requestDisplayMode({ mode: "fullscreen" }); + } else { + await document.documentElement.requestFullscreen(); + } + } catch (e) { + log.warn("Fullscreen toggle failed:", e); + } + }, [app, isFullscreen]); + + const handleDisconnect = useCallback(() => { + if (rfbRef.current) { + rfbRef.current.disconnect(); + rfbRef.current = null; + setConnectionState("disconnected"); + setErrorMessage("Disconnected. Click Reconnect to connect again."); + } + }, []); + + const handleShutdown = useCallback(async () => { + if (app && extractedInfo) { + await app.sendMessage({ + role: "user", + content: [ + { + type: "text", + text: `Please shutdown the ${extractedInfo.name} virtual desktop`, + }, + ], + }); + } + }, [app, extractedInfo]); + + const handleOpenHomeFolder = useCallback(async () => { + if (app && extractedInfo) { + try { + await app.callServerTool({ + name: "open-home-folder", + arguments: { name: extractedInfo.name }, + }); + } catch (e) { + log.error("Failed to open home folder:", e); + } + } + }, [app, extractedInfo]); + + // If no desktop info, show waiting state + if (!extractedInfo) { + return ( +
+
+

Waiting for desktop information...

+

+ Use the view-desktop tool with a desktop name. +

+
+
+ ); + } + + // CSP blocked - show friendly UI with Open in Browser + if (connectionState === "csp-blocked") { + return ( +
+
+
+ + + +
+

{extractedInfo.name}

+
+ {extractedInfo.variant} + + {extractedInfo.resolution.width}x{extractedInfo.resolution.height} + +
+

{extractedInfo.url}

+ +
+
+ ); + } + + // Loading state - waiting for noVNC library + if (!noVncReady) { + return ( +
+
+
+

Loading VNC library...

+
+
+ ); + } + + // Disconnected or error state - show reconnect button + if (connectionState === "disconnected" || connectionState === "error") { + return ( +
+
+
+ + + +
+

{extractedInfo.name}

+

{errorMessage}

+
+ + +
+
+
+ ); + } + + // Connected or connecting - show VNC viewer + return ( +
+
+ {extractedInfo.name} + + {connectionState === "connected" ? "Connected" : "Connecting..."} + +
+ + {app && ( + <> + + + + )} + + +
+
+ +
+ {connectionState === "connecting" && ( +
+
+

Connecting to {extractedInfo.name}...

+
+ )} +
+
+
+ ); +} + +/** + * Hook to extract desktop info from tool result or metadata. + */ +function useExtractDesktopInfo( + toolResult: CallToolResult | null, + desktopInfo: DesktopInfo | null, +): DesktopInfo | null { + const [extracted, setExtracted] = useState(desktopInfo); + + useEffect(() => { + if (desktopInfo) { + setExtracted(desktopInfo); + return; + } + + if (!toolResult) return; + + // Try to extract from text content + const textContent = toolResult.content?.find((c) => c.type === "text"); + if (!textContent || textContent.type !== "text") return; + + const text = textContent.text; + + // Parse the text output + const nameMatch = text.match(/Desktop "([^"]+)"/); + const urlMatch = text.match(/Open in browser: (http[^\s]+)/); + const wsUrlMatch = text.match(/WebSocket URL: (ws[^\s]+)/); + const resMatch = text.match(/Resolution: (\d+)x(\d+)/); + const variantMatch = text.match(/Variant: ([^\s]+)/); + + if (nameMatch && urlMatch && wsUrlMatch) { + setExtracted({ + name: nameMatch[1], + url: urlMatch[1], + wsUrl: wsUrlMatch[1], + resolution: resMatch + ? { width: parseInt(resMatch[1]), height: parseInt(resMatch[2]) } + : { width: 1280, height: 720 }, + variant: variantMatch?.[1] || "xfce", + }); + } + }, [toolResult, desktopInfo]); + + return extracted; +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/virtual-desktop-server/src/vite-env.d.ts b/examples/virtual-desktop-server/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/virtual-desktop-server/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/virtual-desktop-server/tsconfig.json b/examples/virtual-desktop-server/tsconfig.json new file mode 100644 index 00000000..80f6c8b4 --- /dev/null +++ b/examples/virtual-desktop-server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "typeRoots": ["./src", "./node_modules/@types"] + }, + "include": ["src", "server.ts", "server-utils.ts"] +} diff --git a/examples/virtual-desktop-server/vite.config.ts b/examples/virtual-desktop-server/vite.config.ts new file mode 100644 index 00000000..4ab1d283 --- /dev/null +++ b/examples/virtual-desktop-server/vite.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + target: "esnext", // Support top-level await + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, + optimizeDeps: { + include: ["@novnc/novnc/lib/rfb"], + esbuildOptions: { + target: "esnext", + }, + }, + esbuild: { + target: "esnext", + }, +}); diff --git a/package-lock.json b/package-lock.json index 379526fe..94f7b056 100644 --- a/package-lock.json +++ b/package-lock.json @@ -783,6 +783,50 @@ "dev": true, "license": "MIT" }, + "examples/virtual-desktop-server": { + "name": "@anthropic/mcp-server-virtual-desktop", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "cross-env": "^10.1.0", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } + }, + "examples/virtual-desktop-server/node_modules/@types/node": { + "version": "22.19.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", + "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "examples/virtual-desktop-server/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/wiki-explorer-server": { "name": "@modelcontextprotocol/server-wiki-explorer", "version": "0.4.0", @@ -820,6 +864,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@anthropic/mcp-server-virtual-desktop": { + "resolved": "examples/virtual-desktop-server", + "link": true + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", diff --git a/package.json b/package.json index ebe9102f..9f77e415 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:docker": "docker run --rm -v $(pwd):/work -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'npm i -g bun && npm ci && npx playwright test'", "test:e2e:docker:update": "docker run --rm -v $(pwd):/work -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'npm i -g bun && npm ci && npx playwright test --update-snapshots'", + "test:e2e:docker:dind": "docker run --rm -v $(pwd):/work -v /var/run/docker.sock:/var/run/docker.sock -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update && apt-get install -y docker.io && npm i -g bun && npm ci && npx playwright test'", + "test:e2e:docker:dind:update": "docker run --rm -v $(pwd):/work -v /var/run/docker.sock:/var/run/docker.sock -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update && apt-get install -y docker.io && npm i -g bun && npm ci && npx playwright test --update-snapshots'", "preexamples:build": "npm run build", "examples:build": "bun examples/run-all.ts build", "examples:start": "NODE_ENV=development npm run build && bun examples/run-all.ts start", diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index 73f2812e..7dcb34d3 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Implementation } from "@modelcontextprotocol/sdk/types.js"; -import { Client } from "@modelcontextprotocol/sdk/client"; import { App, McpUiAppCapabilities, PostMessageTransport } from "../app"; export * from "../app"; @@ -112,6 +111,11 @@ export function useApp({ const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); + // Track the app instance in a ref so we can close it on cleanup. + // This is necessary because the app is created inside an async function + // and wouldn't otherwise be accessible from the cleanup function. + const appRef = useRef(null); + useEffect(() => { let mounted = true; @@ -121,17 +125,23 @@ export function useApp({ window.parent, window.parent, ); - const app = new App(appInfo, capabilities); + const newApp = new App(appInfo, capabilities); + + // Store in ref immediately so cleanup can access it + appRef.current = newApp; // Register handlers BEFORE connecting - onAppCreated?.(app); + onAppCreated?.(newApp); - await app.connect(transport); + await newApp.connect(transport); if (mounted) { - setApp(app); + setApp(newApp); setIsConnected(true); setError(null); + } else { + // Component unmounted during connection - close the app + void newApp.close(); } } catch (error) { if (mounted) { @@ -148,6 +158,13 @@ export function useApp({ return () => { mounted = false; + // Close the app connection to stop the PostMessageTransport listener. + // This prevents duplicate listeners in React StrictMode where effects + // run twice, and ensures proper cleanup when the component unmounts. + if (appRef.current) { + void appRef.current.close(); + appRef.current = null; + } }; }, []); // Intentionally not including options to avoid reconnection diff --git a/tests/e2e/virtual-desktop.spec.ts b/tests/e2e/virtual-desktop.spec.ts new file mode 100644 index 00000000..b205fd3a --- /dev/null +++ b/tests/e2e/virtual-desktop.spec.ts @@ -0,0 +1,327 @@ +import { test, expect, type Page } from "@playwright/test"; +import { execSync, exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); + +const TEST_CONTAINER_NAME = "vd-e2e-test"; +const TIMEOUT = 120000; // 2 minutes for container startup + +/** + * Check if Docker is available + */ +function isDockerAvailable(): boolean { + try { + execSync("docker ps", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * Create a test virtual desktop container + */ +async function createTestDesktop(): Promise<{ port: number }> { + // Find an available port + const port = 3500 + Math.floor(Math.random() * 100); + + // Create container using Docker directly (not through MCP server) + // Using ConSol's ubuntu-xfce-vnc which is the default "xfce" variant + // Port 6901 for noVNC web UI and websockify, password: vncpassword + // Must add vd.managed label for MCP server to recognize it + const cmd = [ + "docker run -d", + `--name ${TEST_CONTAINER_NAME}`, + `-p ${port}:6901`, + "--shm-size=256m", + "--label vd.managed=true", + "--label vd.variant=xfce", + `--label vd.resolution=1280x720`, + `--label vd.commands=[]`, + "consol/ubuntu-xfce-vnc:latest", + ].join(" "); + + await execAsync(cmd); + + // Wait for container to be ready (noVNC service to start on port 6901) + const maxWait = 90000; + const startTime = Date.now(); + while (Date.now() - startTime < maxWait) { + try { + const { stdout } = await execAsync( + `curl -s http://localhost:${port}/ || true`, + ); + if (stdout.length > 0) { + break; + } + } catch { + // Container not ready yet + } + await new Promise((r) => setTimeout(r, 3000)); + } + + // Extra wait for VNC to fully initialize + await new Promise((r) => setTimeout(r, 3000)); + + return { port }; +} + +/** + * Cleanup test container + */ +async function cleanupTestDesktop(): Promise { + try { + await execAsync(`docker rm -f ${TEST_CONTAINER_NAME}`); + } catch { + // Ignore errors if container doesn't exist + } +} + +/** + * Helper to get the app frame locator (nested: sandbox > app) + */ +function getAppFrame(page: Page) { + return page.frameLocator("iframe").first().frameLocator("iframe").first(); +} + +/** + * Wait for the MCP App to load inside nested iframes + */ +async function waitForAppLoad(page: Page) { + const outerFrame = page.frameLocator("iframe").first(); + await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30000 }); +} + +// Check Docker availability +const dockerAvailable = isDockerAvailable(); + +// Basic tests that don't require Docker +test.describe("Virtual Desktop Server - Basic", () => { + test("server is listed in host dropdown", async ({ page }) => { + await page.goto("/"); + + // Wait for servers to connect + await expect(page.locator("select").first()).toBeEnabled({ + timeout: 30000, + }); + + // Get all options from the server dropdown + const options = await page + .locator("select") + .first() + .locator("option") + .allTextContents(); + + // Virtual Desktop Server should be in the list + expect(options).toContain("Virtual Desktop Server"); + }); + + test("list-desktops tool works", async ({ page }) => { + await page.goto("/"); + + // Wait for servers to connect + await expect(page.locator("select").first()).toBeEnabled({ + timeout: 30000, + }); + + // Select the Virtual Desktop Server + await page + .locator("select") + .first() + .selectOption({ label: "Virtual Desktop Server" }); + + // Wait for tools to load + await page.waitForTimeout(500); + + // Select list-desktops tool + await page + .locator("select") + .nth(1) + .selectOption({ label: "list-desktops" }); + + // Call the tool + await page.click('button:has-text("Call Tool")'); + + // Should show a result (either no desktops, Docker not available, or a list of desktops) + // Wait for any response from the tool call + await expect( + page + .locator('text="No virtual desktops found"') + .or(page.locator('text="Docker is not available"')) + .or(page.locator('text="Found"')) + .or(page.locator('text="virtual desktop"')), + ).toBeVisible({ timeout: 15000 }); + }); +}); + +// Docker-dependent tests - only run when ENABLE_DOCKER_TESTS=1 +test.describe("Virtual Desktop Server - Docker", () => { + // Skip unless explicitly enabled via environment variable + const enableDockerTests = process.env.ENABLE_DOCKER_TESTS === "1"; + test.skip( + !enableDockerTests || !dockerAvailable, + "Docker tests disabled or Docker unavailable", + ); + + // Run tests serially to share the container + test.describe.configure({ mode: "serial" }); + + // Increase timeout for beforeAll since container creation can be slow + test.beforeAll(async () => { + test.setTimeout(180000); // 3 minutes for container startup + if (!enableDockerTests || !dockerAvailable) return; + + // Clean up any existing test container + await cleanupTestDesktop(); + + // Create fresh test container + await createTestDesktop(); + }); + + test.afterAll(async () => { + if (!enableDockerTests || !dockerAvailable) return; + + // Always clean up the test container + await cleanupTestDesktop(); + }); + + test("loads virtual desktop viewer", async ({ page }) => { + test.setTimeout(TIMEOUT); + + await page.goto("/"); + + // Wait for servers to connect + await expect(page.locator("select").first()).toBeEnabled({ + timeout: 30000, + }); + + // Select the Virtual Desktop Server + await page + .locator("select") + .first() + .selectOption({ label: "Virtual Desktop Server" }); + + // The tool dropdown should now show view-desktop + await page.waitForTimeout(500); + const toolSelect = page.locator("select").nth(1); + await toolSelect.selectOption({ label: "view-desktop" }); + + // Fill in the desktop name in the arguments + const argsTextarea = page.locator("textarea"); + await argsTextarea.fill(JSON.stringify({ name: TEST_CONTAINER_NAME })); + + // Call the tool + await page.click('button:has-text("Call Tool")'); + + // Wait for app to load + await waitForAppLoad(page); + + // Verify the VNC viewer is displayed + const appFrame = getAppFrame(page); + await expect(appFrame.locator('[class*="container"]')).toBeVisible({ + timeout: 30000, + }); + }); + + test("screenshot matches golden", async ({ page }) => { + test.setTimeout(TIMEOUT); + + await page.goto("/"); + + // Wait for servers to connect + await expect(page.locator("select").first()).toBeEnabled({ + timeout: 30000, + }); + + // Select the Virtual Desktop Server + await page + .locator("select") + .first() + .selectOption({ label: "Virtual Desktop Server" }); + + // Select view-desktop tool + await page.waitForTimeout(500); + const toolSelect = page.locator("select").nth(1); + await toolSelect.selectOption({ label: "view-desktop" }); + + // Fill in the desktop name + const argsTextarea = page.locator("textarea"); + await argsTextarea.fill(JSON.stringify({ name: TEST_CONTAINER_NAME })); + + // Call the tool + await page.click('button:has-text("Call Tool")'); + + // Wait for app to load + await waitForAppLoad(page); + + // Wait for VNC to connect and stabilize + const appFrame = getAppFrame(page); + + // Wait for the VNC canvas to appear (indicates connection) + await expect(appFrame.locator('[class*="vncCanvas"]')).toBeVisible({ + timeout: 30000, + }); + + // Extra wait for VNC to fully render + await page.waitForTimeout(3000); + + // Take screenshot - mask the VNC canvas since desktop content is dynamic + await expect(page).toHaveScreenshot("virtual-desktop.png", { + mask: [appFrame.locator('[class*="vncCanvas"]')], + maxDiffPixelRatio: 0.06, + }); + }); + + test("disconnect and reconnect works", async ({ page }) => { + test.setTimeout(TIMEOUT); + + await page.goto("/"); + + // Wait for servers to connect + await expect(page.locator("select").first()).toBeEnabled({ + timeout: 30000, + }); + + // Select the Virtual Desktop Server and view-desktop tool + await page + .locator("select") + .first() + .selectOption({ label: "Virtual Desktop Server" }); + await page.waitForTimeout(500); + await page.locator("select").nth(1).selectOption({ label: "view-desktop" }); + + // Fill in the desktop name and call tool + await page + .locator("textarea") + .fill(JSON.stringify({ name: TEST_CONTAINER_NAME })); + await page.click('button:has-text("Call Tool")'); + + // Wait for app to load + await waitForAppLoad(page); + const appFrame = getAppFrame(page); + + // Wait for VNC to connect + await expect(appFrame.locator('[class*="vncCanvas"]')).toBeVisible({ + timeout: 30000, + }); + + // Click disconnect button + const disconnectButton = appFrame.locator('button[title="Disconnect"]'); + await disconnectButton.click(); + + // Verify disconnected state shows + await expect(appFrame.locator('[class*="disconnected"]')).toBeVisible({ + timeout: 10000, + }); + + // Click reconnect button + const reconnectButton = appFrame.locator('button:has-text("Reconnect")'); + await reconnectButton.click(); + + // Verify VNC reconnects + await expect(appFrame.locator('[class*="vncCanvas"]')).toBeVisible({ + timeout: 30000, + }); + }); +});