From 5b5e848f2e3ae593a5b1a6c7a4604aa2c93f0c40 Mon Sep 17 00:00:00 2001 From: ochafik Date: Mon, 12 Jan 2026 11:06:51 +0000 Subject: [PATCH 01/23] feat(examples): add virtual-desktop-server with Docker-based VNC viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new example MCP server that manages virtual desktops using Docker containers with VNC access: - ListDesktops/CreateDesktop/ViewDesktop/ShutdownDesktop tools - Embedded noVNC viewer as MCP App with WebSocket connection - Light/dark theme support with CSS variables - Fullscreen toggle, disconnect/shutdown buttons, open home folder - Fixed reconnect issues (resizeSession=false, separate connection state) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/virtual-desktop-server/mcp-app.html | 14 + examples/virtual-desktop-server/package.json | 48 ++ .../virtual-desktop-server/server-utils.ts | 72 ++ examples/virtual-desktop-server/server.ts | 542 ++++++++++++++ examples/virtual-desktop-server/src/docker.ts | 430 +++++++++++ .../virtual-desktop-server/src/global.css | 24 + .../src/mcp-app.module.css | 332 +++++++++ .../virtual-desktop-server/src/mcp-app.tsx | 679 ++++++++++++++++++ .../virtual-desktop-server/src/vite-env.d.ts | 1 + examples/virtual-desktop-server/tsconfig.json | 21 + .../virtual-desktop-server/vite.config.ts | 34 + 11 files changed, 2197 insertions(+) create mode 100644 examples/virtual-desktop-server/mcp-app.html create mode 100644 examples/virtual-desktop-server/package.json create mode 100644 examples/virtual-desktop-server/server-utils.ts create mode 100644 examples/virtual-desktop-server/server.ts create mode 100644 examples/virtual-desktop-server/src/docker.ts create mode 100644 examples/virtual-desktop-server/src/global.css create mode 100644 examples/virtual-desktop-server/src/mcp-app.module.css create mode 100644 examples/virtual-desktop-server/src/mcp-app.tsx create mode 100644 examples/virtual-desktop-server/src/vite-env.d.ts create mode 100644 examples/virtual-desktop-server/tsconfig.json create mode 100644 examples/virtual-desktop-server/vite.config.ts 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..2945b21c --- /dev/null +++ b/examples/virtual-desktop-server/server.ts @@ -0,0 +1,542 @@ +/** + * 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, + RESOURCE_URI_META_KEY, +} from "@modelcontextprotocol/ext-apps/server"; +import { startServer } from "./server-utils.js"; +import { + listDesktops, + createDesktop, + getDesktop, + shutdownDesktop, + checkDocker, + getPortConfig, + DESKTOP_VARIANTS, + DEFAULT_VARIANT, + DEFAULT_RESOLUTION, + DEFAULT_COMMANDS, + 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 CreateDesktopInputSchema = z.object({ + name: z.string().describe("Name for the desktop (will be sanitized and prefixed with 'vd-')"), + variant: z + .enum(DESKTOP_VARIANTS) + .optional() + .describe(`Desktop variant (default: ${DEFAULT_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 (default: ${DEFAULT_COMMANDS.join(", ")})`), + mounts: z.array(MountSchema).optional().describe("Additional volume mounts"), +}); + +const ViewDesktopInputSchema = z.object({ + name: z.string().describe("Name of the desktop to view"), +}); + +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: { [RESOURCE_URI_META_KEY]: 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) { + 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}). 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, + }, + _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 + connectDomains: ["ws://localhost:*", "wss://localhost:*"], + }; + + 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 home folder in the desktop's file manager", + inputSchema: OpenHomeFolderInputSchema.shape, + _meta: { + ui: { + visibility: ["apps"], + }, + }, + }, + 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) { + 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 { + // Run file manager in the container + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsync = promisify(exec); + + // Use thunar (XFCE file manager) or xdg-open as fallback + await execAsync(`docker exec ${args.name} bash -c "DISPLAY=:1 thunar ~ || DISPLAY=:1 xdg-open ~" &`); + + return { + content: [ + { + type: "text", + text: `Opened home folder in ${args.name}.`, + }, + ], + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Failed to open home folder: ${error instanceof Error ? error.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..2eb86fa2 --- /dev/null +++ b/examples/virtual-desktop-server/src/docker.ts @@ -0,0 +1,430 @@ +/** + * 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 = "vd-"; + +/** Base directory for virtual desktop data */ +export const VIRTUAL_DESKTOPS_DIR = path.join(homedir(), ".virtual-desktops"); + +/** 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; +} + +/** + * 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 desktops = await listDesktops(); + return desktops.find((d) => d.name === name) || null; +} + +/** + * Shutdown and remove a virtual desktop container. + */ +export async function shutdownDesktop( + name: string, + cleanup: boolean = false, +): Promise { + try { + // Stop the container + await execAsync(`docker stop ${name}`).catch(() => {}); + + // Remove the container + await execAsync(`docker rm ${name}`); + + // Optionally clean up the data directory + if (cleanup) { + const desktopDir = path.join(VIRTUAL_DESKTOPS_DIR, name); + 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..dda178db --- /dev/null +++ b/examples/virtual-desktop-server/src/global.css @@ -0,0 +1,24 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; + background: #1a1a1a; + color: #fff; + height: 100%; + overflow: hidden; +} + +#root { + height: 100%; +} + +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..08574ebf --- /dev/null +++ b/examples/virtual-desktop-server/src/mcp-app.module.css @@ -0,0 +1,332 @@ +/* Make html/body transparent for rounded corners */ +:global(html), +:global(body) { + background: transparent !important; + margin: 0; + padding: 0; +} + +/* 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; + 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 { + width: 100%; + height: 100%; +} + +.vncCanvas canvas { + width: 100% !important; + height: 100% !important; +} + +/* 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..63829cda --- /dev/null +++ b/examples/virtual-desktop-server/src/mcp-app.tsx @@ -0,0 +1,679 @@ +/** + * 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; +} + +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(); + 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); + + // 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; + + // Disconnect existing connection + if (rfbRef.current) { + rfbRef.current.disconnect(); + rfbRef.current = null; + } + + 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"); + 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"); + + 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); + 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); + 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]); + + // Cleanup on unmount only + useEffect(() => { + return () => { + if (rfbRef.current) { + rfbRef.current.disconnect(); + rfbRef.current = null; + } + }; + }, []); + + 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", + }, +}); From f4d214d1ac903628b8bbb69fb3cff0e0a5f88650 Mon Sep 17 00:00:00 2001 From: ochafik Date: Mon, 12 Jan 2026 11:19:54 +0000 Subject: [PATCH 02/23] test: add e2e tests for virtual-desktop-server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add basic tests (server listing, list-desktops tool) - Add Docker-dependent tests (VNC viewer, screenshot, disconnect/reconnect) - Docker tests require ENABLE_DOCKER_TESTS=1 env var - Add test:e2e:docker:dind scripts for Docker-in-Docker testing - Fix useApp cleanup to properly close app on unmount 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/virtual-desktop-server/server.ts | 22 +- examples/virtual-desktop-server/src/docker.ts | 25 +- .../virtual-desktop-server/src/mcp-app.tsx | 137 +++++++-- package-lock.json | 48 +++ package.json | 2 + src/react/useApp.tsx | 29 +- tests/e2e/virtual-desktop.spec.ts | 278 ++++++++++++++++++ 7 files changed, 491 insertions(+), 50 deletions(-) create mode 100644 tests/e2e/virtual-desktop.spec.ts diff --git a/examples/virtual-desktop-server/server.ts b/examples/virtual-desktop-server/server.ts index 2945b21c..d116b622 100644 --- a/examples/virtual-desktop-server/server.ts +++ b/examples/virtual-desktop-server/server.ts @@ -56,18 +56,26 @@ const MountSchema = z.object({ }); const CreateDesktopInputSchema = z.object({ - name: z.string().describe("Name for the desktop (will be sanitized and prefixed with 'vd-')"), + name: z + .string() + .describe( + "Name for the desktop (will be sanitized and prefixed with 'vd-')", + ), variant: z .enum(DESKTOP_VARIANTS) .optional() - .describe(`Desktop variant (default: ${DEFAULT_VARIANT}). Options: xfce (lightweight), webtop-ubuntu-xfce, webtop-alpine-xfce`), + .describe( + `Desktop variant (default: ${DEFAULT_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 (default: ${DEFAULT_COMMANDS.join(", ")})`), + .describe( + `Commands to run on startup (default: ${DEFAULT_COMMANDS.join(", ")})`, + ), mounts: z.array(MountSchema).optional().describe("Additional volume mounts"), }); @@ -80,7 +88,9 @@ const ShutdownDesktopInputSchema = z.object({ cleanup: z .boolean() .optional() - .describe("Delete the desktop's data directory (default: false, preserves data)"), + .describe( + "Delete the desktop's data directory (default: false, preserves data)", + ), }); // ============================================================================ @@ -493,7 +503,9 @@ export function createVirtualDesktopServer(): McpServer { const execAsync = promisify(exec); // Use thunar (XFCE file manager) or xdg-open as fallback - await execAsync(`docker exec ${args.name} bash -c "DISPLAY=:1 thunar ~ || DISPLAY=:1 xdg-open ~" &`); + await execAsync( + `docker exec ${args.name} bash -c "DISPLAY=:1 thunar ~ || DISPLAY=:1 xdg-open ~" &`, + ); return { content: [ diff --git a/examples/virtual-desktop-server/src/docker.ts b/examples/virtual-desktop-server/src/docker.ts index 2eb86fa2..a9a7bcc4 100644 --- a/examples/virtual-desktop-server/src/docker.ts +++ b/examples/virtual-desktop-server/src/docker.ts @@ -38,25 +38,32 @@ 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", + 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", + "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) + 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) + 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" }; + return { + httpPort: 6901, + vncPort: 5901, + websocketPath: "/websockify", + password: "vncpassword", + }; } // LinuxServer webtop (KasmVNC) return { httpPort: 3000, websocketPath: "/websockify", password: "" }; @@ -340,7 +347,9 @@ export async function createDesktop( // LinuxServer webtop uses /config for home volumes.push(`-v "${homeDir}:/config"`); if (commands.length > 0) { - volumes.push(`-v "${path.join(desktopDir, "autostart")}:/config/autostart"`); + volumes.push( + `-v "${path.join(desktopDir, "autostart")}:/config/autostart"`, + ); } } // Add custom mounts diff --git a/examples/virtual-desktop-server/src/mcp-app.tsx b/examples/virtual-desktop-server/src/mcp-app.tsx index 63829cda..0dde9f63 100644 --- a/examples/virtual-desktop-server/src/mcp-app.tsx +++ b/examples/virtual-desktop-server/src/mcp-app.tsx @@ -38,7 +38,13 @@ interface DesktopInfo { password?: string; } -type ConnectionState = "loading" | "connecting" | "connected" | "disconnected" | "error" | "csp-blocked"; +type ConnectionState = + | "loading" + | "connecting" + | "connected" + | "disconnected" + | "error" + | "csp-blocked"; const log = { info: console.log.bind(console, "[VNC]"), @@ -89,14 +95,19 @@ async function loadNoVNC(): Promise { const handleLoad = () => { clearTimeout(timeoutId); - RFBClass = (window as unknown as { __noVNC_RFB: typeof RFBClass }).__noVNC_RFB; + 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")); + reject( + new Error( + "VNC library failed to initialize - RFB is not a constructor", + ), + ); } }; @@ -159,7 +170,9 @@ function ViewDesktopStandalone({ desktopInfo }: { desktopInfo: DesktopInfo }) { // Hosted mode component - connects to MCP host function ViewDesktopHosted() { const [toolResult, setToolResult] = useState(null); - const [hostContext, setHostContext] = useState(); + const [hostContext, setHostContext] = useState< + McpUiHostContext | undefined + >(); const [desktopInfo, setDesktopInfo] = useState(null); const { app, error } = useApp({ @@ -246,7 +259,8 @@ function ViewDesktopInner({ hostContext, desktopInfo, }: ViewDesktopInnerProps) { - const [connectionState, setConnectionState] = useState("loading"); + const [connectionState, setConnectionState] = + useState("loading"); const [errorMessage, setErrorMessage] = useState(null); const containerRef = useRef(null); const rfbRef = useRef(null); @@ -268,7 +282,9 @@ function ViewDesktopInner({ .catch((e) => { log.warn("noVNC load failed:", e.message); setConnectionState("csp-blocked"); - setErrorMessage("Embedded viewer not available. Please open in browser."); + setErrorMessage( + "Embedded viewer not available. Please open in browser.", + ); }); }, []); @@ -291,7 +307,12 @@ function ViewDesktopInner({ // 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); + log.info( + "Using password:", + password ? "(set)" : "(empty)", + "for variant:", + extractedInfo.variant, + ); const rfb = new RFBClass(containerRef.current, extractedInfo.wsUrl, { credentials: { password }, wsProtocols: ["binary"], @@ -312,22 +333,32 @@ function ViewDesktopInner({ 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"); - - 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( + "disconnect", + (e: CustomEvent<{ clean: boolean; reason?: string }>) => { + log.info( + "Disconnected from VNC server, clean:", + e.detail.clean, + "reason:", + e.detail.reason || "none", + ); + + 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); setConnectionState("error"); - setErrorMessage(`Security failure: ${(e.detail as { reason?: string })?.reason || "Unknown"}`); + setErrorMessage( + `Security failure: ${(e.detail as { reason?: string })?.reason || "Unknown"}`, + ); }); rfb.addEventListener("credentialsrequired", () => { @@ -336,7 +367,10 @@ function ViewDesktopInner({ }); // Additional debug events - rfb.addEventListener("serververification", logEvent("serververification")); + rfb.addEventListener( + "serververification", + logEvent("serververification"), + ); rfb.addEventListener("clipboard", logEvent("clipboard")); rfb.addEventListener("bell", logEvent("bell")); rfb.addEventListener("desktopname", logEvent("desktopname")); @@ -346,13 +380,21 @@ function ViewDesktopInner({ } catch (e) { log.error("Failed to connect:", e); setConnectionState("error"); - setErrorMessage(`Failed to connect: ${e instanceof Error ? e.message : String(e)}`); + 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") { + if ( + noVncReady && + extractedInfo && + containerRef.current && + RFBClass && + connectionState === "connecting" + ) { log.info("Ready to connect, initiating VNC connection..."); connect(); } @@ -386,7 +428,8 @@ function ViewDesktopInner({ }, [app, extractedInfo]); // Track fullscreen from host context (preferred) or document state (standalone) - const isFullscreen = hostContext?.displayMode === "fullscreen" || + const isFullscreen = + hostContext?.displayMode === "fullscreen" || (typeof document !== "undefined" && !!document.fullscreenElement); const handleToggleFullscreen = useCallback(async () => { @@ -420,7 +463,12 @@ function ViewDesktopInner({ if (app && extractedInfo) { await app.sendMessage({ role: "user", - content: [{ type: "text", text: `Please shutdown the ${extractedInfo.name} virtual desktop` }] + content: [ + { + type: "text", + text: `Please shutdown the ${extractedInfo.name} virtual desktop`, + }, + ], }); } }, [app, extractedInfo]); @@ -430,7 +478,7 @@ function ViewDesktopInner({ try { await app.callServerTool({ name: "open-home-folder", - arguments: { name: extractedInfo.name } + arguments: { name: extractedInfo.name }, }); } catch (e) { log.error("Failed to open home folder:", e); @@ -525,7 +573,10 @@ function ViewDesktopInner({ -
@@ -571,7 +622,12 @@ function ViewDesktopInner({ title="Open home folder" disabled={connectionState !== "connected"} > - + @@ -580,7 +636,12 @@ function ViewDesktopInner({ onClick={handleShutdown} title="Shutdown desktop container" > - + @@ -592,11 +653,21 @@ function ViewDesktopInner({ title={isFullscreen ? "Exit fullscreen" : "Fullscreen"} > {isFullscreen ? ( - + ) : ( - + )} @@ -604,7 +675,11 @@ function ViewDesktopInner({
+ {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/server.ts b/examples/virtual-desktop-server/server.ts index 0b860db5..2ad912f1 100644 --- a/examples/virtual-desktop-server/server.ts +++ b/examples/virtual-desktop-server/server.ts @@ -33,7 +33,6 @@ import { DESKTOP_VARIANTS, DEFAULT_VARIANT, DEFAULT_RESOLUTION, - DEFAULT_COMMANDS, type DesktopInfo, type DesktopVariant, } from "./src/docker.js"; @@ -58,14 +57,15 @@ const MountSchema = z.object({ const CreateDesktopInputSchema = z.object({ name: z .string() + .default("my-desktop") .describe( "Name for the desktop (will be sanitized and prefixed with 'vd-')", ), variant: z .enum(DESKTOP_VARIANTS) - .optional() + .default(DEFAULT_VARIANT) .describe( - `Desktop variant (default: ${DEFAULT_VARIANT}). Options: xfce (lightweight), webtop-ubuntu-xfce, webtop-alpine-xfce`, + `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})`, @@ -73,14 +73,15 @@ const CreateDesktopInputSchema = z.object({ commands: z .array(z.string()) .optional() - .describe( - `Commands to run on startup (default: ${DEFAULT_COMMANDS.join(", ")})`, - ), + .describe("Commands to run on startup"), mounts: z.array(MountSchema).optional().describe("Additional volume mounts"), }); const ViewDesktopInputSchema = z.object({ - name: z.string().describe("Name of the desktop to view"), + name: z + .string() + .default("vd-my-desktop") + .describe("Name of the desktop to view"), }); const ShutdownDesktopInputSchema = z.object({ From f0e229d86a8b22b95f5f53ceee22ac60a546e9ea Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 12 Jan 2026 12:59:05 +0000 Subject: [PATCH 08/23] feat(virtual-desktop): use mcp-apps-vd- prefix and improve error messages - Change container prefix from 'vd-' to 'mcp-apps-vd-' - Improve view-desktop error when desktop not found to suggest create-desktop command with correct arguments --- examples/virtual-desktop-server/server.ts | 15 +++++++++++---- examples/virtual-desktop-server/src/docker.ts | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/examples/virtual-desktop-server/server.ts b/examples/virtual-desktop-server/server.ts index 2ad912f1..aec0de08 100644 --- a/examples/virtual-desktop-server/server.ts +++ b/examples/virtual-desktop-server/server.ts @@ -30,6 +30,7 @@ import { shutdownDesktop, checkDocker, getPortConfig, + CONTAINER_PREFIX, DESKTOP_VARIANTS, DEFAULT_VARIANT, DEFAULT_RESOLUTION, @@ -54,12 +55,14 @@ const MountSchema = z.object({ readonly: z.boolean().optional().describe("Mount as read-only"), }); +const DEFAULT_DESKTOP_NAME = "my-desktop"; + const CreateDesktopInputSchema = z.object({ name: z .string() - .default("my-desktop") + .default(DEFAULT_DESKTOP_NAME) .describe( - "Name for the desktop (will be sanitized and prefixed with 'vd-')", + `Name for the desktop (will be sanitized and prefixed with '${CONTAINER_PREFIX}')`, ), variant: z .enum(DESKTOP_VARIANTS) @@ -80,7 +83,7 @@ const CreateDesktopInputSchema = z.object({ const ViewDesktopInputSchema = z.object({ name: z .string() - .default("vd-my-desktop") + .default(`mcp-apps-vd-${DEFAULT_DESKTOP_NAME}`) .describe("Name of the desktop to view"), }); @@ -269,12 +272,16 @@ export function createVirtualDesktopServer(): McpServer { 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. Use list-desktops to see available desktops.`, + text: `Desktop "${args.name}" not found. Create it first with: create-desktop { "name": "${baseName}" }. Or use list-desktops to see available desktops.`, }, ], }; diff --git a/examples/virtual-desktop-server/src/docker.ts b/examples/virtual-desktop-server/src/docker.ts index a9a7bcc4..dccb7693 100644 --- a/examples/virtual-desktop-server/src/docker.ts +++ b/examples/virtual-desktop-server/src/docker.ts @@ -10,7 +10,7 @@ import path from "node:path"; const execAsync = promisify(exec); /** Prefix for all virtual desktop container names */ -export const CONTAINER_PREFIX = "vd-"; +export const CONTAINER_PREFIX = "mcp-apps-vd-"; /** Base directory for virtual desktop data */ export const VIRTUAL_DESKTOPS_DIR = path.join(homedir(), ".virtual-desktops"); From caa0fd926f79fd758da3e0f878d7ab8067729f25 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 12 Jan 2026 13:03:03 +0000 Subject: [PATCH 09/23] feat(virtual-desktop): auto-resize desktop when app container resizes Use ResizeObserver to detect container size changes and call xrandr to resize the desktop resolution accordingly. Includes debouncing and rate-limiting to avoid spamming resize commands. --- .../virtual-desktop-server/src/mcp-app.tsx | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/examples/virtual-desktop-server/src/mcp-app.tsx b/examples/virtual-desktop-server/src/mcp-app.tsx index 0dde9f63..54be9aae 100644 --- a/examples/virtual-desktop-server/src/mcp-app.tsx +++ b/examples/virtual-desktop-server/src/mcp-app.tsx @@ -400,6 +400,76 @@ function ViewDesktopInner({ } }, [noVncReady, extractedInfo, connectionState, connect]); + // Resize desktop when container size changes + useEffect(() => { + if (!app || !extractedInfo || connectionState !== "connected") return; + + const container = containerRef.current; + if (!container) return; + + let resizeTimeout: ReturnType | null = null; + let lastResizeTime = 0; + const RESIZE_DEBOUNCE = 500; // ms + const MIN_RESIZE_INTERVAL = 2000; // ms - don't resize more often than this + + const handleResize = (entries: ResizeObserverEntry[]) => { + const entry = entries[0]; + if (!entry) return; + + const { width, height } = entry.contentRect; + // Round to nearest 8 pixels (common VNC requirement) + const newWidth = Math.round(width / 8) * 8; + const newHeight = Math.round(height / 8) * 8; + + // Ignore too-small sizes + if (newWidth < 640 || newHeight < 480) return; + + // Check if this is different from current resolution + if ( + newWidth === extractedInfo.resolution.width && + newHeight === extractedInfo.resolution.height + ) { + return; + } + + // Debounce and rate-limit + if (resizeTimeout) clearTimeout(resizeTimeout); + + const now = Date.now(); + const timeSinceLastResize = now - lastResizeTime; + const delay = Math.max(RESIZE_DEBOUNCE, MIN_RESIZE_INTERVAL - timeSinceLastResize); + + resizeTimeout = setTimeout(async () => { + lastResizeTime = Date.now(); + log.info(`Resizing desktop to ${newWidth}x${newHeight}`); + try { + // Use xrandr to resize the desktop + await app.callServerTool({ + name: "exec", + arguments: { + name: extractedInfo.name, + command: `xrandr --size ${newWidth}x${newHeight} || xrandr -s ${newWidth}x${newHeight}`, + background: false, + timeout: 5000, + }, + }); + // Update local resolution state + extractedInfo.resolution = { width: newWidth, height: newHeight }; + } catch (e) { + log.warn("Failed to resize desktop:", e); + } + }, delay); + }; + + const observer = new ResizeObserver(handleResize); + observer.observe(container); + + return () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + observer.disconnect(); + }; + }, [app, extractedInfo, connectionState]); + // Cleanup on unmount only useEffect(() => { return () => { From 3616c3b5ef270cc3f40abe5748ddb53889e69fc9 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 12 Jan 2026 13:08:31 +0000 Subject: [PATCH 10/23] fix(virtual-desktop): make corners transparent Set html, body, and #root backgrounds to transparent so the host's rounded iframe corners show properly instead of black corners. --- examples/virtual-desktop-server/src/global.css | 3 ++- examples/virtual-desktop-server/src/mcp-app.module.css | 8 -------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/examples/virtual-desktop-server/src/global.css b/examples/virtual-desktop-server/src/global.css index dda178db..f3356f78 100644 --- a/examples/virtual-desktop-server/src/global.css +++ b/examples/virtual-desktop-server/src/global.css @@ -8,7 +8,7 @@ html, body { font-family: system-ui, -apple-system, sans-serif; font-size: 1rem; - background: #1a1a1a; + background: transparent; color: #fff; height: 100%; overflow: hidden; @@ -16,6 +16,7 @@ body { #root { height: 100%; + background: transparent; } code { diff --git a/examples/virtual-desktop-server/src/mcp-app.module.css b/examples/virtual-desktop-server/src/mcp-app.module.css index 08574ebf..efff0dd4 100644 --- a/examples/virtual-desktop-server/src/mcp-app.module.css +++ b/examples/virtual-desktop-server/src/mcp-app.module.css @@ -1,11 +1,3 @@ -/* Make html/body transparent for rounded corners */ -:global(html), -:global(body) { - background: transparent !important; - margin: 0; - padding: 0; -} - /* Default (dark theme) */ :root { --vnc-bg: #1a1a1a; From b976be6ae0dbf5ad7242bacffd67a9806f4e549f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 12 Jan 2026 13:20:10 +0000 Subject: [PATCH 11/23] fix(virtual-desktop): fix CSP, reconnect, and open-home-folder - Add jsdelivr to CSP connect-src for source maps - Fix reconnect by clearing container innerHTML before new connection - Change open-home-folder to open on host machine (not in container) - Add homeFolder path to structuredContent for tooltip - Remove non-working dynamic resize (TigerVNC doesn't support it) --- examples/virtual-desktop-server/server.ts | 61 ++++++-------- .../virtual-desktop-server/src/mcp-app.tsx | 83 +++---------------- 2 files changed, 39 insertions(+), 105 deletions(-) diff --git a/examples/virtual-desktop-server/server.ts b/examples/virtual-desktop-server/server.ts index aec0de08..52cb49d5 100644 --- a/examples/virtual-desktop-server/server.ts +++ b/examples/virtual-desktop-server/server.ts @@ -34,6 +34,7 @@ import { DESKTOP_VARIANTS, DEFAULT_VARIANT, DEFAULT_RESOLUTION, + VIRTUAL_DESKTOPS_DIR, type DesktopInfo, type DesktopVariant, } from "./src/docker.js"; @@ -339,6 +340,7 @@ export function createVirtualDesktopServer(): McpServer { resolution: desktop.resolution, variant: desktop.variant, password: portConfig.password, + homeFolder: path.join(VIRTUAL_DESKTOPS_DIR, desktop.name, "home"), }, _meta: {}, }; @@ -349,8 +351,12 @@ export function createVirtualDesktopServer(): McpServer { const viewDesktopCsp = { // Allow loading noVNC library from jsdelivr CDN resourceDomains: ["https://cdn.jsdelivr.net"], - // Allow WebSocket connections to localhost for VNC - connectDomains: ["ws://localhost:*", "wss://localhost:*"], + // Allow WebSocket connections to localhost for VNC, and HTTPS for source maps + connectDomains: [ + "ws://localhost:*", + "wss://localhost:*", + "https://cdn.jsdelivr.net", + ], }; registerAppResource( @@ -456,7 +462,8 @@ export function createVirtualDesktopServer(): McpServer { "open-home-folder", { title: "Open Home Folder", - description: "Open the home folder in the desktop's file manager", + description: + "Open the desktop's home folder on the host machine's file manager", inputSchema: OpenHomeFolderInputSchema.shape, _meta: { ui: { @@ -465,19 +472,6 @@ export function createVirtualDesktopServer(): McpServer { }, }, 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) { @@ -492,34 +486,33 @@ export function createVirtualDesktopServer(): McpServer { }; } - if (desktop.status !== "running") { - return { - isError: true, - content: [ - { - type: "text", - text: `Desktop "${args.name}" is not running (status: ${desktop.status}).`, - }, - ], - }; - } + // Construct the host path to the desktop's home folder + const homeFolder = path.join(VIRTUAL_DESKTOPS_DIR, args.name, "home"); try { - // Run file manager in the container const { exec } = await import("node:child_process"); const { promisify } = await import("node:util"); const execAsync = promisify(exec); - // Use thunar (XFCE file manager) or xdg-open as fallback - await execAsync( - `docker exec ${args.name} bash -c "DISPLAY=:1 thunar ~ || DISPLAY=:1 xdg-open ~" &`, - ); + // 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 in ${args.name}.`, + text: `Opened home folder: ${homeFolder}`, }, ], }; @@ -529,7 +522,7 @@ export function createVirtualDesktopServer(): McpServer { content: [ { type: "text", - text: `Failed to open home folder: ${error instanceof Error ? error.message : String(error)}`, + text: `Failed to open home folder (${homeFolder}): ${error instanceof Error ? error.message : String(error)}`, }, ], }; diff --git a/examples/virtual-desktop-server/src/mcp-app.tsx b/examples/virtual-desktop-server/src/mcp-app.tsx index 54be9aae..10c437c3 100644 --- a/examples/virtual-desktop-server/src/mcp-app.tsx +++ b/examples/virtual-desktop-server/src/mcp-app.tsx @@ -36,6 +36,7 @@ interface DesktopInfo { resolution: { width: number; height: number }; variant: string; password?: string; + homeFolder?: string; } type ConnectionState = @@ -292,11 +293,13 @@ function ViewDesktopInner({ const connect = useCallback(() => { if (!extractedInfo || !containerRef.current || !RFBClass) return; - // Disconnect existing connection + // 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); @@ -400,75 +403,9 @@ function ViewDesktopInner({ } }, [noVncReady, extractedInfo, connectionState, connect]); - // Resize desktop when container size changes - useEffect(() => { - if (!app || !extractedInfo || connectionState !== "connected") return; - - const container = containerRef.current; - if (!container) return; - - let resizeTimeout: ReturnType | null = null; - let lastResizeTime = 0; - const RESIZE_DEBOUNCE = 500; // ms - const MIN_RESIZE_INTERVAL = 2000; // ms - don't resize more often than this - - const handleResize = (entries: ResizeObserverEntry[]) => { - const entry = entries[0]; - if (!entry) return; - - const { width, height } = entry.contentRect; - // Round to nearest 8 pixels (common VNC requirement) - const newWidth = Math.round(width / 8) * 8; - const newHeight = Math.round(height / 8) * 8; - - // Ignore too-small sizes - if (newWidth < 640 || newHeight < 480) return; - - // Check if this is different from current resolution - if ( - newWidth === extractedInfo.resolution.width && - newHeight === extractedInfo.resolution.height - ) { - return; - } - - // Debounce and rate-limit - if (resizeTimeout) clearTimeout(resizeTimeout); - - const now = Date.now(); - const timeSinceLastResize = now - lastResizeTime; - const delay = Math.max(RESIZE_DEBOUNCE, MIN_RESIZE_INTERVAL - timeSinceLastResize); - - resizeTimeout = setTimeout(async () => { - lastResizeTime = Date.now(); - log.info(`Resizing desktop to ${newWidth}x${newHeight}`); - try { - // Use xrandr to resize the desktop - await app.callServerTool({ - name: "exec", - arguments: { - name: extractedInfo.name, - command: `xrandr --size ${newWidth}x${newHeight} || xrandr -s ${newWidth}x${newHeight}`, - background: false, - timeout: 5000, - }, - }); - // Update local resolution state - extractedInfo.resolution = { width: newWidth, height: newHeight }; - } catch (e) { - log.warn("Failed to resize desktop:", e); - } - }, delay); - }; - - const observer = new ResizeObserver(handleResize); - observer.observe(container); - - return () => { - if (resizeTimeout) clearTimeout(resizeTimeout); - observer.disconnect(); - }; - }, [app, extractedInfo, connectionState]); + // Note: Dynamic resize is not supported by TigerVNC. + // Resolution is set at container creation time via the `resolution` parameter. + // The VNC viewer scales to fit using scaleViewport=true. // Cleanup on unmount only useEffect(() => { @@ -689,7 +626,11 @@ function ViewDesktopInner({