Skip to main content
Glama

Support-MCP

Remote support for Windows PCs, run by an AI agent over the Model Context Protocol. The customer pastes one line into PowerShell. A small window opens on their screen and shows every command as it runs. The operator, which in my case is Claude Code, gets seven MCP tools to run commands and move files on that PC. The session ends itself after 24 hours and removes the agent's own files, logon entry and scheduled task.

I built it because I support Windows point-of-sale machines, and every screen-sharing tool I tried wanted an installer, an account, or a person on the other end reading out codes. I wanted to send a link, have the customer paste it, and let an agent do the diagnosis while I watch.

The relay is a Cloudflare Worker with Durable Objects. The agent is a single PowerShell 5.1 script with no dependencies beyond what ships with Windows.

What a session looks like

  1. The operator calls session_create. The relay mints a single-use pairing code and returns a one-liner:

    irm https://support.example.com/j/zp5z7xuckhrm6s7t | iex
  2. The customer pastes it. The script copies itself to %LOCALAPPDATA%\SupportMCP\<id>\, opens a window titled "Support session", and connects back over a WebSocket.

  3. The operator calls run, put_file and get_file. Each one appears as a timestamped line in the customer's window.

  4. The session ends when the operator calls session_end, when the customer clicks End session, or when 24 hours pass. The agent then deletes its folder, its logon entry and its scheduled task.

The pairing link is valid for 10 minutes and can be fetched once. Only a request from PowerShell uses the code up. A chat app that previews the link gets a short harmless message instead. After the script is fetched, the PC has two hours to make its first connection. session_status reports that deadline as startup_expires_at while the session waits, and null in every other state. The agent keeps the same two-hour limit on its own clock (connect_by in its config), so a PC that never connects cleans up even if the relay is gone. Once connected, the session lasts up to 24 hours, and a reconnect doesn't extend that.

Related MCP server: Personal Device Agent

Architecture

flowchart LR
  Op["MCP client<br/>(Claude Code)"] -- "POST /mcp<br/>Cloudflare Access" --> W[Worker]
  Cust["Customer PowerShell"] -- "GET /j/&lt;code&gt;<br/>one time" --> W
  W --> S[("Session DO<br/>SQLite, alarm")]
  W --> R[("Registry DO")]
  A["agent.ps1<br/>WinForms window"] -- "WebSocket /ws<br/>X-Attach-Key" --> S

Path

Auth

Purpose

GET /healthz

public

liveness

GET /j/<code>

public, single use, https only

serves the agent script with the session values filled in, to PowerShell clients only

GET /ws?sid=

attach key in a header, https only

agent WebSocket, using the hibernation API

POST /mcp

Cloudflare Access

MCP JSON-RPC 2.0, stateless HTTP

The Worker itself is the MCP server. There is no local bridge process. Each session is one Durable Object holding its state machine, its audit log and an alarm for expiry. A second Durable Object keeps the list for session_list.

Source file

What it does

src/app.js

router, Access JWT gate, MCP handler

src/session.js

Session Durable Object: states, audit log, alarm, tagged sockets

src/registry.js

Registry Durable Object for session_list

src/mcp.js

JSON-RPC dispatch and the tool schemas

src/jwt.js

RS256 verification of the Access JWT with WebCrypto

src/template.js

builds the served script and validates every value placed in it

src/agent.ps1

the Windows agent: bootstrap, run and cleanup roles in one file

MCP tools

Tool

Does

session_create

returns {session_id, one_liner, code_expires_at}

session_list

every session with its state, expiry and machine

session_status

state, machine, user, expiry, startup_expires_at while waiting, whether the agent is attached, last audit rows

run

runs PowerShell as the signed-in user; returns exit code, stdout, stderr, timed_out

put_file

writes base64 data to a path, with append for chunking

get_file

reads a byte range as base64, with eof for chunking

session_end

tells the agent to clean up and marks the session ended

There is no screen capture and no tool that elevates. That is on purpose.

Security model

The customer pastes a script from the internet into PowerShell, so the design starts from what that script may and may not do.

  • Consent is visible. The window stays on top when it first opens, lists every command and file operation, and has an End session button. Closing the window asks before it ends the session.

  • No elevation. The agent refuses to start if its own process is elevated. If the customer pasted into an admin console, the bootstrap starts the agent through a one-shot scheduled task at the limited run level. With UAC turned off, or on the built-in Administrator account, that task still gets a full token. The agent then removes everything it installed and exits, and the logon entry is only written after the elevation check passes. When admin rights are needed, the operator can ask Windows for a UAC prompt, and the customer decides whether to click Yes.

  • Pairing codes can't be guessed. A code is 16 base32 characters, 80 bits, single use, and valid for 10 minutes. The customer pastes it and never types it, so its length costs nothing.

  • Link previews can't use up a code. Slack, Teams, WhatsApp and Telegram fetch links with a plain GET. The relay serves the script, and consumes the code, only when the User-Agent contains PowerShell/, which is what irm sends in Windows PowerShell 5.1 and PowerShell 7. Any other request gets a Write-Host line telling the reader to paste the command into PowerShell. This protects the code from bots, not from someone who sets the header on purpose.

  • https only. The script carries the attach key and runs the whole session over the origin it was served from, so the relay builds that origin as https://<host> for every host except localhost and 127.0.0.1. A /j request over plain http gets a Write-Host line asking for the https link, and the code stays unused. /ws over plain http gets a 403.

  • The attach key never goes in a URL. It is 32 random bytes, sent only in the X-Attach-Key header so it stays out of access logs, and the relay stores only its SHA-256. A request with the key in the query string is rejected.

  • The served script can't be injected into. Every value substituted into the PowerShell template is checked against a hex, base32 or URL pattern before it goes into a single-quoted literal. The script runs inside & { ... } so nothing leaks into the customer's console session.

  • The operator endpoint sits behind Cloudflare Access. The relay verifies the Access JWT itself: RS256 signature, aud, iss, exp and nbf with 60 seconds of skew. It caches signing keys for an hour and allows at most one forced refetch per minute when it sees an unknown kid. Requests carrying an Origin header get a 403 and non-JSON bodies get a 415, which blocks cross-site POSTs from a logged-in browser.

  • Sessions only move forward. The states run pending, consumed, attached, ended, and ended is final. The 24-hour limit starts at the first attach and a reattach does not extend it. Every handler checks the deadline inline, so a late alarm can't let an expired session act.

  • Cleanup has three layers. The relay alarm sends end. The agent remembers its own deadline and cleans up on restart. That deadline is the session expiry once connected, and connect_by (two hours after the script ran) until then. A per-user scheduled task runs the cleanup at the same deadline even if the agent was killed, and it still fires on battery or after sleep. The first welcome from the relay moves the task from connect_by to the real expiry.

  • Everything is audited. Each session records created, consumed, attached, every run with its command text (cut to 512 characters), file operations with path and size but not content, and the reason it ended. Rows for operator actions name the caller identity from the JWT. The machine, user and PowerShell version the agent reports are cut to 100, 100 and 40 characters, and only the first hello on each socket counts.

  • Session output is untrusted input. Everything that comes back from a session is written by the customer's PC: stdout, stderr, file contents, and the machine and user names. Treat it as untrusted text that may try to steer the operator's AI agent. That agent can have a shell on the operator's own machine, so do not run it with tool calls auto-approved.

  • The window shows, it does not enforce. The window shows what the operator does. It does not stop an operator acting in bad faith: commands run with the signed-in user's rights, and what they change stays changed. "Removes the agent's own files" covers only the agent's folder, logon entry and scheduled tasks. It does not undo files an operator wrote, software it installed or settings it changed.

A real session

The first customer session was a restaurant POS terminal whose receipt printer had been swapped from one brand to another. Nothing printed. The whole fix ran through this relay with Claude Code as the operator, from pasting the link to a receipt coming out of the printer.

What it found, in order:

  1. Windows could see the new printer on USB, but its USB printing interface never finished coming up, so Windows had no USB001 port to print to. A spooler restart didn't help. Restarting that one USB device did, with the customer approving the UAC prompt.

  2. The old print queue still used the previous vendor's driver, and that driver's language monitor sent data the new printer couldn't handle. The printer got stuck. It answered real-time status queries and dropped everything else, and neither a power cycle nor a PC reboot cleared it. The agent opened the USB device directly, bypassing the spooler, and sent the ESC/POS buffer-clear command. The printer acknowledged it and printed.

  3. A fresh queue with the right vendor driver still printed nothing, because that driver's own language monitor expects the vendor's port type. Turning bidirectional support off on the queue fixed it, and the POS software's receipts printed.

Steps 2 and 3 can't be done from a screen share. They needed raw writes to the device and reading status bytes back, which is what run makes possible.

Deploy your own

You need a Cloudflare account with a zone for the hostname, Node 22 and wrangler.

  1. Create the Access guard for /mcp. This makes a service token for the MCP client and an Access app with two policies, your email and the token.

    CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... \
    SUPPORT_HOST=support.example.com SUPPORT_OPERATOR_EMAIL=you@example.com \
    cf/access-setup.sh

    The API token needs Access: Apps and Policies (Edit) and Access: Service Tokens (Edit). The script writes the client secret to ~/.config/support-mcp/access.env with mode 600, never prints it, and prints the app's AUD tag at the end.

  2. Fill in wrangler.toml. Set the route hostname, ACCESS_TEAM_DOMAIN and ACCESS_AUD, then run npx wrangler deploy.

  3. Harden the zone. Turn on Always Use HTTPS for the zone (SSL/TLS, Edge Certificates), so a link pasted without https:// is redirected. The relay refuses /j and /ws over plain http either way. The relay does no rate limiting of its own, so add a Cloudflare rate-limiting rule on /j and /ws.

  4. Connect Claude Code. The headers helper reads the secret at connect time, so it never lands in ~/.claude.json:

    claude mcp add-json -s user support-mcp \
      '{"type":"http","url":"https://support.example.com/mcp","headersHelper":"/path/to/Support-MCP/cf/mcp-headers.sh"}'

Develop and test

node --test                          # 56 unit and route tests, no network
npx wrangler deploy --dry-run --outdir /tmp/support-mcp-dry
cp .dev.vars.example .dev.vars && npx wrangler dev --port 8787

.dev.vars skips Access and shortens the session TTL, but only when the request host is localhost or 127.0.0.1. The deployed hostname can never satisfy that check. Keep wrangler dev on loopback, its default: the bypass trusts the Host header, so anyone who can reach a dev server on another interface could send Host: localhost and skip Access.

wrangler dev presents the custom-domain route's hostname to the Worker, so to exercise /mcp, /j or /ws locally, run it against a copy of the config with the routes line removed. Over plain http the relay serves only localhost and 127.0.0.1.

The route tests import the real router. Most give it Durable Object bindings that throw if touched. That proves the Access gate, the Origin check, the method checks, the https check and the link-preview check all run before any session state is read. The few /j tests that must reach a session use a stub binding that records every call.

The wire protocol, the state machine and the acceptance checks are in docs/relay-v1-spec.md.

Responsible use

  • Use it only on a computer whose owner has asked for support and agreed to the session.

  • The visible window and the End session button are the consent mechanism. They show the customer every command and let them stop the session at any time. Please keep them visible in any fork.

  • You are responsible for following the law where you and the customer are.

Limits

  • Windows only. The agent targets Windows PowerShell 5.1, which ships with Windows 10 and 11.

  • The script isn't signed. irm | iex content has no Mark of the Web, so SmartScreen doesn't apply, but Defender heuristics could still flag it on some machines.

  • It is built for one operator, or a few people sharing one Access app. There are no per-operator permissions.

  • Command output is capped at 262,144 characters per stream, and the whole result frame is trimmed to fit the WebSocket message limit. Files move in chunks of about 512 KiB.

  • If a command leaves a background process running that keeps its output open, the agent waits about 5 seconds after the command exits and stops that process if it can find it. Output on a stream that process still holds open is discarded, and the result is marked with an [output incomplete] note. A process started with Start-Process -NoNewWindow and no -Wait is stopped about 5 seconds after the command returns, so use -Wait, or plain Start-Process (which does not share the output pipe), for programs that must keep running.

  • If Task Scheduler is unavailable, no fallback cleanup task exists, whether the agent has connected or not. An agent that is killed is then cleaned up the next time it starts after its deadline, normally at logon.

  • Where an administrator has turned on PowerShell script block logging, Windows may record the served script, including the attach key, in the PowerShell Operational event log. The key stays valid for the life of the session.

Contributing

Only collaborators can open pull requests here. To suggest a change or report a bug, open an issue. To report a vulnerability, see SECURITY.md.

License

MIT.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables MCP clients to securely inspect and control registered Windows PCs over a private Tailscale network, with risk-based approvals for actions such as launching apps, capturing screens, managing files, and simulating input.
    Apache 2.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables remote control of a Windows desktop via MCP, including screenshots, mouse and keyboard, window management, PowerShell, files, services, registry, scheduled tasks, event log, and network checks.
    15 npm
    2
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables agentic control of Windows via MCP tools for screenshots, mouse, keyboard, UI Automation, OCR, windows, and clipboard, while risky actions require real-human approval through a policy-gated dialog with audit logging.
    5
    MIT