-
Notifications
You must be signed in to change notification settings - Fork 59
examples: add virtual-desktop-server with Docker-based VNC viewer #241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Add a new example MCP server that manages virtual desktops using Docker containers with VNC access: - ListDesktops/CreateDesktop/ViewDesktop/ShutdownDesktop tools - Embedded noVNC viewer as MCP App with WebSocket connection - Light/dark theme support with CSS variables - Fullscreen toggle, disconnect/shutdown buttons, open home folder - Fixed reconnect issues (resizeSession=false, separate connection state) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add basic tests (server listing, list-desktops tool) - Add Docker-dependent tests (VNC viewer, screenshot, disconnect/reconnect) - Docker tests require ENABLE_DOCKER_TESTS=1 env var - Add test:e2e:docker:dind scripts for Docker-in-Docker testing - Fix useApp cleanup to properly close app on unmount 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| if (app) { | ||
| app.openLink({ url: extractedInfo.url }); | ||
| } else { | ||
| window.open(extractedInfo.url, "_blank"); |
Check warning
Code scanning / CodeQL
Client-side URL redirect Medium
user-provided value
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 5 hours ago
To fix this, we need to ensure that extractedInfo.url is safe before using it in a redirect (window.open). The standard mitigation is to either (a) restrict redirects to a fixed set of allowed destinations, or (b) at least validate that the URL is of an expected form (e.g., same-origin, or specific schemes), and fall back or block otherwise.
Given the existing design and the need not to change overall behavior more than necessary, the minimal, targeted fix is:
-
Introduce a small helper function (within this file) that:
- Accepts a
stringURL. - Uses the built-in
URLconstructor to parse it safely (relative towindow.location). - Enforces simple rules, e.g.:
- Allow only
http:/https:schemes (blockjavascript:,data:, etc.). - Optionally, restrict to the same origin as the current page if that fits the app; if that would break legitimate use, we can at least keep the scheme check.
- Allow only
- Returns either a safe, normalized URL string, or
nullif the input is invalid or unsafe.
- Accepts a
-
Update
handleOpenInBrowserto use this helper:- Compute
const safeUrl = sanitizeDesktopUrl(extractedInfo.url); - Only call
app.openLink/window.openifsafeUrlis notnull.
- Compute
No external dependencies are needed; we can rely on the browser’s URL API. To minimize impact, we keep behavior unchanged for valid http/https URLs, and we only block clearly unsafe or malformed URLs.
Concretely:
- In
examples/virtual-desktop-server/src/mcp-app.tsx, somewhere abovehandleOpenInBrowser(e.g., near other helper functions, beforeViewDesktopInneror just abovehandleOpenInBrowser), add asanitizeDesktopUrlfunction. - Modify the
handleOpenInBrowseruseCallbackso it validatesextractedInfo.urlthrough this helper before redirecting.
-
Copy modified lines R301-R324 -
Copy modified lines R670-R673 -
Copy modified line R675 -
Copy modified line R677
| @@ -298,6 +298,30 @@ | ||
| desktopInfo: DesktopInfo | null; | ||
| } | ||
|
|
||
| /** | ||
| * Sanitize a desktop URL before opening it in the browser. | ||
| * | ||
| * - Only allows http/https schemes. | ||
| * - Returns null if the URL is invalid or uses a disallowed scheme. | ||
| */ | ||
| function sanitizeDesktopUrl(rawUrl: string | null | undefined): string | null { | ||
| if (!rawUrl) return null; | ||
|
|
||
| let parsed: URL; | ||
| try { | ||
| parsed = new URL(rawUrl, window.location.href); | ||
| } catch { | ||
| return null; | ||
| } | ||
|
|
||
| const allowedProtocols = new Set(["http:", "https:"]); | ||
| if (!allowedProtocols.has(parsed.protocol)) { | ||
| return null; | ||
| } | ||
|
|
||
| return parsed.toString(); | ||
| } | ||
|
|
||
| function ViewDesktopInner({ | ||
| app, | ||
| toolResult, | ||
| @@ -643,10 +667,14 @@ | ||
|
|
||
| const handleOpenInBrowser = useCallback(() => { | ||
| if (extractedInfo?.url) { | ||
| const safeUrl = sanitizeDesktopUrl(extractedInfo.url); | ||
| if (!safeUrl) { | ||
| return; | ||
| } | ||
| if (app) { | ||
| app.openLink({ url: extractedInfo.url }); | ||
| app.openLink({ url: safeUrl }); | ||
| } else { | ||
| window.open(extractedInfo.url, "_blank"); | ||
| window.open(safeUrl, "_blank"); | ||
| } | ||
| } | ||
| }, [app, extractedInfo]); |
@modelcontextprotocol/ext-apps
@modelcontextprotocol/server-basic-react
@modelcontextprotocol/server-basic-vanillajs
@modelcontextprotocol/server-budget-allocator
@modelcontextprotocol/server-cohort-heatmap
@modelcontextprotocol/server-customer-segmentation
@modelcontextprotocol/server-scenario-modeler
@modelcontextprotocol/server-system-monitor
@modelcontextprotocol/server-threejs
@modelcontextprotocol/server-wiki-explorer
commit: |
Add tools that allow the model to interact programmatically with virtual desktops: - take-screenshot: Capture desktop as PNG image - click: Click at specific coordinates (supports left/middle/right, single/double/triple) - type-text: Type text via keyboard simulation - press-key: Press key combinations (e.g., 'ctrl+c', 'alt+F4', 'Return') - move-mouse: Move cursor to specific position - scroll: Scroll in any direction All tools use xdotool inside the Docker container. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Allows running arbitrary commands inside the Docker container with DISPLAY=:1 so GUI apps appear in the VNC display. Examples: - exec(name, 'firefox', background=true) - Open Firefox - exec(name, 'ls -la ~') - List home directory - exec(name, 'xfce4-terminal', background=true) - Open terminal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…t result display - Add default values to create-desktop tool (name, variant) - Add default value to view-desktop tool (name) - Improve basic-host ToolResultPanel to render text and images nicely instead of raw JSON for non-UI tool results
…ages - Change container prefix from 'vd-' to 'mcp-apps-vd-' - Improve view-desktop error when desktop not found to suggest create-desktop command with correct arguments
Use ResizeObserver to detect container size changes and call xrandr to resize the desktop resolution accordingly. Includes debouncing and rate-limiting to avoid spamming resize commands.
Set html, body, and #root backgrounds to transparent so the host's rounded iframe corners show properly instead of black corners.
- Add jsdelivr to CSP connect-src for source maps - Fix reconnect by clearing container innerHTML before new connection - Change open-home-folder to open on host machine (not in container) - Add homeFolder path to structuredContent for tooltip - Remove non-working dynamic resize (TigerVNC doesn't support it)
- Fix canvas aspect ratio with object-fit: contain - Re-add resize feature using xrandr -s with predefined modes - Pick best fitting mode from available xrandr resolutions
Add isConnectingRef guard to prevent race conditions where multiple connection attempts would be made, causing the connection to drop.
- Desktop now resizes to exactly match the viewer's dimensions - Uses cvt to generate custom xrandr modes on-the-fly - Canvas fills the frame (no letterboxing since sizes match) - Reduced resize debounce to 500ms for quicker response
- Use noVNC scaleViewport to maintain aspect ratio (prevents distortion) - Dynamic resize using cvt to match container dimensions - Faster 200ms debounce for quicker resize response - Center canvas in container Note: There may be temporary letterboxing until resize completes, but this is preferable to distortion.
- Simplified multiline shell command to single line (avoids escaping issues) - Added logging for resize command and result - Increased timeout to 10 seconds
- Remove minimum size constraints that were forcing 640 width - Use absolute positioning for canvas to avoid layout feedback loop - Observe parent container dimensions directly - Add validateContainerName helper for security The desktop now resizes to match the container size, filling the space with minimal letterboxing (only due to xrandr rounding).
- Add resolveContainerName() to handle both 'my-desktop' and 'mcp-apps-vd-my-desktop' inputs - Update getDesktop() and shutdownDesktop() to use resolver - Update all tools to use desktop.name (resolved) for docker exec - Default view-desktop name to 'my-desktop' (without prefix) Users can now use either: - Logical name: 'my-desktop' - Full name: 'mcp-apps-vd-my-desktop'
- Capture canvas screenshots every 2 seconds when connected - Compare with previous screenshot to avoid sending duplicates - Send via app.updateModelContext() for model context awareness - Silently ignore errors if host doesn't support the feature
- Use JPEG instead of PNG (5-10x smaller) - Use hash for deduplication instead of full string comparison - Check host capabilities before starting screenshot interval - Disable after 3 consecutive failures (backoff) - Use getHostCapabilities() for proper capability check
Replace deprecated RESOURCE_URI_META_KEY constant with the new nested _meta.ui.resourceUri structure as done in other examples.
…istence Provides OpenAI-style privateContent workaround using localStorage with hostContext.toolInfo.id as the storage key. This allows UI state to persist across sessions without being sent to the model.
Summary
Features
Test plan
ENABLE_DOCKER_TESTS=1env var):npm run test:e2e:docker:dind🤖 Generated with Claude Code