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 (
+

+ );
+ }
+ // 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,
+ });
+ });
+});