Skip to content

Conversation

@ochafik
Copy link
Collaborator

@ochafik ochafik commented Jan 12, 2026

Summary

  • Add new example MCP server for managing virtual desktops using Docker containers
  • Embedded noVNC viewer as MCP App with WebSocket connection
  • Tools: ListDesktops, CreateDesktop, ViewDesktop, ShutdownDesktop, OpenHomeFolder
  • Light/dark theme support, fullscreen toggle, disconnect/reconnect functionality
  • Add e2e test infrastructure for virtual-desktop-server
  • Fix useApp cleanup to properly close app on unmount

Features

  • Docker Integration: Supports multiple desktop variants (ConSol ubuntu-xfce-vnc, LinuxServer webtop)
  • VNC Viewer: Embedded noVNC for viewing desktops directly in the MCP App UI
  • Theme Support: Respects system light/dark theme preference
  • Toolbar Actions: Fullscreen toggle, disconnect, shutdown, open home folder
  • CSP Configuration: Allows noVNC library from CDN and WebSocket connections to localhost

Test plan

  • Basic tests verify server is listed and list-desktops tool works
  • Docker-dependent tests (require ENABLE_DOCKER_TESTS=1 env var):
    • VNC viewer loads and connects
    • Screenshot golden comparison (masks dynamic VNC content)
    • Disconnect and reconnect functionality
  • Docker-in-Docker support via npm run test:e2e:docker:dind

🤖 Generated with Claude Code

ochafik and others added 3 commits January 12, 2026 11:06
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

Untrusted URL redirection depends on a
user-provided value
.

Copilot Autofix

AI about 7 hours ago

In general, to fix client-side URL redirect issues you should not pass user-controlled URLs directly into navigation sinks (window.location, window.open, etc.). Instead, either (1) enforce an allow-list of allowed destinations, or (2) at minimum normalize and validate the URL scheme and (optionally) hostname/path before using it, rejecting or ignoring unsafe values.

For this snippet, the best fix with minimal behavior change is to introduce a small client-side validator for DesktopInfo.url and use it both when constructing the standalone DesktopInfo (so bad URLs are never stored) and immediately before calling window.open. The validator should only accept HTTP(S) URLs and can be conservative about what hosts/paths are permitted if desired. Because we must not assume anything about the rest of the project, we will implement a simple isSafeHttpUrl helper in this file using the standard URL class and basic checks:

  • Require the URL to parse successfully.
  • Require protocol to be http: or https:.
    (If you wanted stricter checks, you could further restrict hostnames, but that would risk changing behavior for legitimate test setups.)

Concretely:

  • Add an isSafeHttpUrl function near the other helpers (e.g., above getStandaloneDesktopInfo).
  • In getStandaloneDesktopInfo, after deriving url from wsUrl, validate it with isSafeHttpUrl. If invalid, return null so standalone mode is not used.
  • In handleOpenInBrowser, before calling window.open, check isSafeHttpUrl(extractedInfo.url). If it fails, simply do nothing (or you could integrate with existing error-handling by setting setErrorMessage, but that would be a behavior change; we’ll keep it minimal and silent).

No new external dependencies are needed; we rely on the built-in URL API.

Suggested changeset 1
examples/virtual-desktop-server/src/mcp-app.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/examples/virtual-desktop-server/src/mcp-app.tsx b/examples/virtual-desktop-server/src/mcp-app.tsx
--- a/examples/virtual-desktop-server/src/mcp-app.tsx
+++ b/examples/virtual-desktop-server/src/mcp-app.tsx
@@ -129,6 +129,19 @@
 }
 
 /**
+ * Validate that a URL is a safe HTTP(S) URL before using it for navigation.
+ */
+function isSafeHttpUrl(url: string | null | undefined): boolean {
+  if (!url) return false;
+  try {
+    const parsed = new URL(url);
+    return parsed.protocol === "http:" || parsed.protocol === "https:";
+  } catch {
+    return false;
+  }
+}
+
+/**
  * Parse query params for standalone testing mode.
  * URL format: ?wsUrl=ws://localhost:13000/websockify&name=test&password=vncpassword
  */
@@ -142,6 +155,9 @@
   // Derive HTTP URL from WebSocket URL
   const url = wsUrl.replace(/^ws/, "http").replace(/\/websockify$/, "");
 
+  // Only proceed if the derived URL is a safe HTTP(S) URL
+  if (!isSafeHttpUrl(url)) return null;
+
   return {
     name,
     url,
@@ -642,7 +658,7 @@
   }, []);
 
   const handleOpenInBrowser = useCallback(() => {
-    if (extractedInfo?.url) {
+    if (extractedInfo?.url && isSafeHttpUrl(extractedInfo.url)) {
       if (app) {
         app.openLink({ url: extractedInfo.url });
       } else {
EOF
@@ -129,6 +129,19 @@
}

/**
* Validate that a URL is a safe HTTP(S) URL before using it for navigation.
*/
function isSafeHttpUrl(url: string | null | undefined): boolean {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}

/**
* Parse query params for standalone testing mode.
* URL format: ?wsUrl=ws://localhost:13000/websockify&name=test&password=vncpassword
*/
@@ -142,6 +155,9 @@
// Derive HTTP URL from WebSocket URL
const url = wsUrl.replace(/^ws/, "http").replace(/\/websockify$/, "");

// Only proceed if the derived URL is a safe HTTP(S) URL
if (!isSafeHttpUrl(url)) return null;

return {
name,
url,
@@ -642,7 +658,7 @@
}, []);

const handleOpenInBrowser = useCallback(() => {
if (extractedInfo?.url) {
if (extractedInfo?.url && isSafeHttpUrl(extractedInfo.url)) {
if (app) {
app.openLink({ url: extractedInfo.url });
} else {
Copilot is powered by AI and may make mistakes. Always verify output.
@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 12, 2026

Open in StackBlitz

@modelcontextprotocol/ext-apps

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/ext-apps@241

@modelcontextprotocol/server-basic-react

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-basic-react@241

@modelcontextprotocol/server-basic-vanillajs

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-basic-vanillajs@241

@modelcontextprotocol/server-budget-allocator

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-budget-allocator@241

@modelcontextprotocol/server-cohort-heatmap

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-cohort-heatmap@241

@modelcontextprotocol/server-customer-segmentation

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-customer-segmentation@241

@modelcontextprotocol/server-map

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-map@241

@modelcontextprotocol/server-pdf

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-pdf@241

@modelcontextprotocol/server-scenario-modeler

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-scenario-modeler@241

@modelcontextprotocol/server-shadertoy

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-shadertoy@241

@modelcontextprotocol/server-sheet-music

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-sheet-music@241

@modelcontextprotocol/server-system-monitor

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-system-monitor@241

@modelcontextprotocol/server-threejs

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-threejs@241

@modelcontextprotocol/server-transcript

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-transcript@241

@modelcontextprotocol/server-video-resource

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-video-resource@241

@modelcontextprotocol/server-wiki-explorer

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-wiki-explorer@241

commit: 9d5fa18

ochafik and others added 3 commits January 12, 2026 11:34
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>
@ochafik ochafik changed the title feat(examples): add virtual-desktop-server with Docker-based VNC viewer examples: add virtual-desktop-server with Docker-based VNC viewer Jan 12, 2026
…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.
@ochafik ochafik force-pushed the feat/virtual-desktop-server branch from c9763a7 to 4bc85b6 Compare January 13, 2026 12:57
ochafik and others added 8 commits January 14, 2026 00:25
…urning from createDesktop

- Poll container state after docker run to verify it's running
- Fail fast with logs if container exits immediately
- Timeout after 30 seconds with helpful error message
…turning from view-desktop

- Add waitForVncReady() to poll HTTP endpoint until responsive
- view-desktop tool now fails with clear message if VNC not ready
- Prevents App UI from loading when endpoint isn't reachable
TigerVNC doesn't support creating custom modes via xrandr --newmode
(returns BadName error). Instead, match container size to the closest
predefined mode that fits.

- Add AVAILABLE_MODES list with TigerVNC's predefined resolutions
- findBestMode() selects largest mode that fits within container
- Falls back to smallest mode if container is too small (noVNC scales)
…ution matching

TigerVNC doesn't support xrandr --newmode at runtime, but it does support
starting with a custom -geometry. The new resize-desktop tool restarts VNC
with the exact requested dimensions.

- Add resize-desktop server tool (visible only to apps)
- Update App resize logic to use exact resize when predefined modes don't fit well
- If a predefined mode fills < 85% of container, restart VNC with exact size
- noVNC will auto-reconnect after VNC restart
…andr

Restarting VNC in Docker breaks dbus and the desktop environment (XFCE).
The resize-desktop tool is kept for manual use but the App now only uses
xrandr to switch between predefined modes.

This is less disruptive and always keeps the desktop running.
Resolve conflicts:
- package.json: Keep dind scripts and add build step to docker:update
- examples/basic-host/src/index.tsx: Keep enhanced ToolResultView with content blocks, add back JsonBlock component
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants