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 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:

  1. Introduce a small helper function (within this file) that:

    • Accepts a string URL.
    • Uses the built-in URL constructor to parse it safely (relative to window.location).
    • Enforces simple rules, e.g.:
      • Allow only http: / https: schemes (block javascript:, 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.
    • Returns either a safe, normalized URL string, or null if the input is invalid or unsafe.
  2. Update handleOpenInBrowser to use this helper:

    • Compute const safeUrl = sanitizeDesktopUrl(extractedInfo.url);
    • Only call app.openLink / window.open if safeUrl is not null.

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 above handleOpenInBrowser (e.g., near other helper functions, before ViewDesktopInner or just above handleOpenInBrowser), add a sanitizeDesktopUrl function.
  • Modify the handleOpenInBrowser useCallback so it validates extractedInfo.url through this helper before redirecting.
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
@@ -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]);
EOF
@@ -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]);
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-scenario-modeler

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-scenario-modeler@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-wiki-explorer

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

commit: 8f60116

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.
…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.
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