Skip to main content
Glama

LocalHands

The reasoning runs somewhere else. This provides the hands.

A local MCP server that lets a cloud AI agent operate one real machine — read and edit files, run commands, capture the screen, move binaries in and out — over a tunnel, behind a path whitelist, a command guard, and an audit log.

Python MCP Tests License Platform

English · 简体中文


Table of contents


Related MCP server: mcp-devtools

What it is

Most AI integrations put a local agent in charge and give it cloud tools. LocalHands inverts that. The reasoning runs on a cloud platform — Feishu Aily, Kimi, Claude, or any MCP client — and this daemon is the pair of hands it borrows on your workstation.

   cloud agent  ──►  MCP over HTTPS  ──►  tunnel  ──►  LocalHands  ──►  your machine
                                                        │
                              rate limit → bearer token → path whitelist
                                       → command guard → audit log

Twenty-three tools, one config file, no telemetry, no external service beyond the tunnel you choose.

Why it exists

claude mcp serve already exposes a local machine over MCP, and for many people it is enough. LocalHands was built after measuring three things that got in the way:

claude mcp serve

LocalHands

Tool schemas on every turn

30 tools, ~70 000 characters (~20 k tokens)

23 tools, scoped to driving a workstation

Authentication

none

bearer token, constant-time compared

Filesystem scope

unrestricted

whitelist, symlinks resolved before the check

Audit trail

none

JSONL, one line per call, rotated

Binary payloads

through the model's context

out-of-band HTTP, one-time URLs

The token figure matters more than it looks: that context is spent on every single turn, and most of it is irrelevant to operating a machine (Workflow alone is 19 378 characters).


Quick start

Five minutes, from nothing to a cloud agent holding your files.

Requirements

  • Python 3.10+

  • Windows 10/11 (primary target) or Linux — the desktop tools and process handling are Windows-tuned; everything else is portable

  • A tunnel binary if you want the daemon to publish itself: ngrok or cloudflared

  • Optional: ripgrep on PATHgrep uses it automatically and falls back to a pure-Python matcher without it

1. Install

git clone https://github.com/newcovid/LocalHands.git
cd LocalHands

python -m venv .venv
.venv\Scripts\python.exe -m pip install -e .
git clone https://github.com/newcovid/LocalHands.git
cd LocalHands

python3 -m venv .venv
.venv/bin/python -m pip install -e .

Substitute .venv/bin/python for .venv\Scripts\python.exe in every command below.

2. Configure

copy config.example.yaml config.yaml

Two settings are mandatory. Everything else has a working default.

# Generate one with:
#   python -c "import secrets; print(secrets.token_urlsafe(32))"
auth_token: "paste-a-long-random-token-here"

# Every file tool is confined to these directories.
allowed_paths:
  - "C:/Users/you/projects"
  - "%TEMP%"
IMPORTANT

run_bash is arbitrary code execution by design, so auth_token is equivalent to a shell password on this machine. Generate it randomly, keep it out of version control (config.yaml is gitignored), and rotate it if it ever leaks.

%TEMP% is worth whitelisting: image staging and DLP re-exports land there.

3. Verify before serving

.venv\Scripts\python.exe -m localhands --config config.yaml --check

This validates the config, checks every dependency, confirms the port is free, confirms the tunnel binary is runnable, and prints exactly what would be served — then exits without starting anything.

============================================================
  localhands v1.0.0 — Self-Check
============================================================

✅ All checks passed.
   Port:       127.0.0.1:8765
   Tools:      23 declared (read_file, write_file, edit_file, ...)
   DLP mode:   auto (some tools are withheld when no encryption is found)
   Transports: sse, streamable_http
   ...

4. Run

.venv\Scripts\python.exe -m localhands --config config.yaml

Installed as a package, localhands --config config.yaml works too.

Flag

Effect

--config, -c

Path to the YAML config (default config.yaml)

--check

Validate and report, then exit without serving

--verbose, -v

DEBUG-level logging

--tunnel / --no-tunnel

Override tunnel.enabled for this run

Confirm it is alive:

curl http://127.0.0.1:8765/health
{"status":"ok","server":"localhands","version":"1.0.0","tool_count":22,"tools":["read_file", "..."],"dlp_handling":false}

5. Publish it

The daemon binds to loopback. Something has to put it on the internet for the cloud agent to reach.

Let the daemon manage the tunnel (recommended — one process, one lifecycle):

tunnel:
  enabled: true
  provider: ngrok          # ngrok | cloudflared | custom
  executable: ""           # empty = look on PATH
  domain: ""               # a reserved domain, if your plan has one

Now python -m localhands --config config.yaml starts both, the tunnel dies with the daemon, and public_base_url is filled in automatically from the tunnel's own API — which the transfer tools need in order to hand out absolute URLs.

Or run your own tunnel and tell the daemon where it lives:

tunnel:
  enabled: false
public_base_url: "https://your-domain.example"

Connecting a client

Register this URL with your MCP client:

https://<your-domain>/mcp?token=<auth_token>

Endpoint

Method

Purpose

/mcp

GET POST DELETE

Streamable HTTP transport — prefer this

/sse

GET

Legacy HTTP+SSE transport

/messages/

POST

JSON-RPC channel paired with /sse

/health

GET

Liveness and the live tool list — public, no token

/

GET

Server info, transports, whitelist

/download/{ticket}

GET

One-time byte pull (ticket is the credential)

/upload/{ticket}

PUT POST

One-time byte push (ticket is the credential)

Both transports run at once, so a client already registered against /sse keeps working while new ones are pointed at /mcp. Prefer Streamable HTTP for anything new: SSE holds one connection open indefinitely, tunnels drop idle connections, and the client then reuses a dead session and stalls. (A keepalive ping, sse_ping_interval, mitigates this for clients that can only speak SSE.)

Authentication accepts either form:

Authorization: Bearer <auth_token>     # preferred
?token=<auth_token>                    # for clients that can only be given a URL

Set allow_query_token: false to require the header.


Deployment

Windows — restart and verify

powershell -ExecutionPolicy Bypass -File scripts\restart.ps1

scripts/restart.ps1 reads the port out of config.yaml, kills whatever is listening on it, sweeps up orphaned ngrok/cloudflared processes, relaunches the daemon detached — and then polls /health until it actually answers before reporting success. It does not assume the process it spawned survived startup.

Exit code 0 means healthy. On failure it writes the reason and the tail of both logs to var/logs/restart.txt.

Windows — start at logon

Task Scheduler, in one line:

schtasks /Create /TN LocalHands /SC ONLOGON /RL HIGHEST /F `
  /TR "powershell -WindowStyle Hidden -ExecutionPolicy Bypass -File C:\path\to\LocalHands\scripts\restart.ps1"

Linux — systemd

# /etc/systemd/system/localhands.service
[Unit]
Description=LocalHands MCP daemon
After=network-online.target

[Service]
Type=simple
User=youruser
WorkingDirectory=/opt/localhands
ExecStart=/opt/localhands/.venv/bin/python -m localhands --config config.yaml
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now localhands

Runtime state

Everything the daemon writes lives under var/, which is gitignored:

var/logs/ops.log       JSONL audit record — one line per tool call, rotated at log_max_bytes
var/logs/runtime.log   stdout of the daemon, truncated per restart
var/logs/tunnel.log    tunnel child process output
var/logs/restart.txt   result of the last restart.ps1 run
var/trash/             recycle bin for delete_path, in timestamped folders

Reviewing what an agent actually did is one command:

Get-Content var\logs\ops.log -Tail 20 | ConvertFrom-Json | Format-Table timestamp, tool, status

Configuration

config.example.yaml is the annotated template — copy it and edit. Every key below is optional except the two named in Quick start.

Key

Default

Meaning

auth_token

(required)

Bearer credential for every MCP call. Treat as a shell password.

allowed_paths

(required)

Directories the file tools may touch. Env vars are expanded (%TEMP%, $HOME).

allow_query_token

true

Accept ?token= as well as the Authorization header.

host

127.0.0.1

Bind address. Keep it on loopback and put a tunnel in front.

port

8765

TCP port.

Key

Default

Meaning

transports

["sse", "streamable_http"]

Which MCP transports to serve. Both may run at once.

max_request_body_size

4194304

Streamable HTTP body cap (4 MiB).

sse_ping_interval

15

SSE keepalive seconds; 0 disables.

max_file_size

1048576

Largest response a single read_file returns.

bash_timeout

30

Default run_bash timeout; a call may request up to 2×.

rate_limit

60

Requests per minute, and the burst capacity.

Key

Default

Meaning

public_base_url

""

Public origin the agent can reach. Auto-filled from a managed ngrok tunnel.

transfer_ticket_ttl

300

Seconds a one-time transfer ticket stays valid.

upload_max_bytes

104857600

Largest accepted upload body (100 MB).

Key

Default

Meaning

download_allowed_schemes

["https","http"]

Permitted URL schemes.

download_allowed_hosts

[]

Empty means any public host. Listing a host also exempts it from the private-address check.

download_max_bytes

52428800

Size cap (50 MB).

download_timeout

60

Per-request timeout in seconds.

Loopback, link-local and private addresses are refused whatever the list says — without that check a remote agent could use this tool to probe your LAN. Every redirect hop is re-vetted (max 5).

Key

Default

Meaning

command_guard_enabled

true

Refuse a short list of irreversible shell commands.

denied_command_patterns

[]

Extra deny regexes, appended to the built-ins.

trash_dir

./var/trash

Where delete_path moves things. Empty makes deletes permanent.

screenshot_enabled

true

false withholds the screenshot tool entirely.

log_file

./var/logs/ops.log

JSONL audit log.

log_max_bytes

5242880

Rotate to <log_file>.1 past this size; 0 disables.

Key

Default

Meaning

image_max_edge

1568

Longest edge before downscaling; vision cost scales with resolution.

image_jpeg_quality

85

Initial JPEG quality.

max_image_bytes

1572864

Re-encode until the result fits.

ripgrep_path

""

Explicit rg binary. Empty probes PATH, then vendored copies, then falls back to Python.

dlp_mode

auto

auto samples the whitelist for ciphertext; on/off decide without sampling.

encrypted_file_markers

[]

Hex magic-byte prefixes. Empty uses the built-in defaults.

staging_dir

""

Where protected apps should export plaintext. Empty = %TEMP%/localhands_staging.

tunnel.*

see template

Provider, executable, domain, extra args, proxy stripping, startup timeout.


Tool reference

Twenty-three tools are declared; the number actually advertised depends on the machine (see Runtime-conditional tools). 👁 marks the three the person at the keyboard will notice — everything else is silent.

Tool

Notes

read_file

Line numbers by default; offset/limit pagination; refuses binaries and DLP ciphertext

read_many_files

Batch read — one round trip instead of N; each path reports its own error

write_file

Replaces whole content, creates parent directories

edit_file

Exact-string replace, unique match required by default

multi_edit

Several edits to one file, applied atomically and in order

move_path / copy_path

Whitelist-checked on both ends; copy_path recurses

delete_path

Recycle bin by default, returns trash_path; permanent=true really deletes

Tool

Notes

glob

*, ?, **; sorted newest-first

grep

ripgrep when available; content / files_with_matches / count modes

list_directory

Name, type, size, mtime in both epoch and ISO-8601

get_project_tree

Honours .gitignore, skips build and cache directories

scan_encrypted

Which files the DLP driver has made unreadable — only served when DLP is detected

Tool

Notes

Shell

run_bash

stdout, stderr, exit code; guard rail on irreversible commands; process-tree kill on timeout

Transfer

prepare_download

One-time URL to pull a local file as raw bytes

prepare_upload

One-time URL to push bytes onto this machine

Media

read_image

Downscales, then returns a URL — never pixels

process_image

Crop, resize, convert; writes to dest_path, transfers nothing

image_info

Format, mode, dimensions, alpha, EXIF orientation — no copy, no transfer

👁

screenshot

Captures whatever is on screen right now

Net

download_file

The daemon fetches the URL itself; bytes never enter the context

Desktop 👁

open_path

Opens a file or folder in its associated application

👁

notify

Puts a dialog on the user's screen

Every tool also carries MCP annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) plus two local _meta hints, userVisible and readsScreen. They live in one table — tools/policy.py — so "what can this server do to my machine without asking" is one screen of text.


How it works

Binary payloads never travel through the model's context

This is the design decision that shapes everything else.

MCP moves tool arguments and results as text, which is ruinous for bytes:

  • Model → local. The model has to emit base64 as a tool argument, so every byte is an output token. A 500 KB image is ~680 000 characters: hundreds of thousands of output tokens and tens of seconds of generation.

  • Local → model. The bytes arrive as a tool result. A client that decodes MCP ImageContent into a real image pays only vision tokens, but a client that spools results to a file and reads them back as text pays full text price. That behaviour is client-specific and cannot be assumed.

So prepare_download and prepare_upload mint a one-time URL and return a ready-to-run curl command. The agent moves the file in its own environment; only a short URL enters the context. read_image returns a ~760-character payload for an image of any size.

The channel is format-agnostic, which is the larger win: PDFs, archives and spreadsheets travel the same way, so read_file being text-only stops mattering.

One-time tickets, not the main token

The URL is handed to a remote sandbox that fetches it with curl, so whatever credential it carries lands in that sandbox's shell history. A ticket is 32 random bytes scoped to one file, one direction, one use, a few minutes. The long-lived auth_token never leaves this machine. Both minting and redemption re-check the path against the whitelist.

The tool list is not static

At startup the daemon samples the whitelisted directories for a transparent-encryption driver's ciphertext marker. On a clean machine it finds nothing, scan_encrypted is never advertised, and the DLP guidance never reaches the agent — describing ciphertext it will never meet would just spend context. screenshot_enabled: false removes screen capture the same way. /health reports what is actually served.

Server-level instructions

Beyond the per-tool schemas, the server sends an instructions block in its initialize response — clients generally fold it into the system prompt, which reaches the model before it picks a tool. It covers what applies to the whole server: that bytes move over HTTP rather than through the context, that file access is whitelisted, and which three tools the person at the machine will actually notice.


Security model

Five layers, in request order:

  1. Rate limit — token bucket, outermost, applied before anything is parsed.

  2. Bearer token — header or ?token=, constant-time compared. /health is public; /download and /upload authenticate with their own one-time ticket instead, so the long-lived token is never put in a URL.

  3. Path whitelist — every path is fully resolved (symlinks, junctions, ..) before the prefix check, so a symlink pointing outside is rejected rather than followed.

  4. Command guard — a deny list for irreversible shell commands: whole-disk operations, recursive deletion of a drive root, shutdown, registry hive deletion, and piping a download straight into a shell.

  5. Audit log — JSONL, one line per invocation, with rotation.

WARNING

Be clear about what this is not. run_bash is arbitrary code execution by design, so the auth token is equivalent to a shell password on this machine. The command guard is a rail against a model acting on a misread or injected instruction — not a boundary against an adversary. Anything with shell access can obfuscate past a regex. For a hard boundary, run the daemon in a VM or container.


Working with transparent encryption (DLP)

Skip this unless your machine has one. With dlp_mode: auto (the default) the daemon samples the whitelisted directories at startup, biased toward the document and image formats such policies actually protect. Finding nothing, it withholds the whole subject. Force the decision either way with dlp_mode: on | off.

Corporate endpoints often run a transparent-encryption filter driver. Files written by protected applications are handed to this daemon as ciphertext, which read_file would otherwise return as several kilobytes of mojibake — and the model would then reason about noise, confidently and at full token price.

Two measured facts determine what actually helps:

  • Copying never decrypts. shutil.copyfile, cmd copy and Copy-Item all produce a still-encrypted copy, in %TEMP% or anywhere else. The ciphertext is the content; location is irrelevant.

  • %TEMP% is typically excluded on write. A protected application doing "Save As" into %TEMP% writes plaintext, because the driver skips that path.

So the daemon detects the condition by magic bytes and returns a FileEncrypted error naming the one remedy that works: re-export from the owning application into staging_dir. Run scan_encrypted first to learn what is readable in one call, instead of discovering it one failure at a time.


Development

Project layout

pyproject.toml            Packaging, pytest and ruff configuration
config.example.yaml       Documented template; copy to config.yaml
scripts/restart.ps1       Restart and verify (Windows)
src/localhands/
  daemon.py               Entry point, ASGI routing, transports, tunnel lifecycle
  config.py               Typed configuration with validation
  security.py             Auth, rate limit, PathGuard, CommandGuard, tickets, audit log
  transfer.py             /download and /upload byte streaming
  tunnel.py               Tunnel supervision (ngrok / cloudflared / custom)
  tools/
    base.py               ToolProvider protocol, LocalProvider, ok()/err()
    __init__.py           Registry and dispatcher
    policy.py             Per-tool annotations: read-only, destructive, user-visible
    instructions.py       Server-level guidance sent in the initialize response
    encryption.py         DLP detection and probing
    files.py  search.py  shell.py  xfer.py  media.py  net.py  desktop.py
tests/                    pytest suite; no daemon, network, or tunnel required
var/                      Runtime state — logs, recycle bin (gitignored)

Running the tests

.venv\Scripts\python.exe -m pip install -e ".[dev]"
.venv\Scripts\python.exe -m pytest tests/ -q

222 tests, none of which need a running daemon, a network, or a tunnel. Everything is built in-process from tmp_path, and the real config.yaml is deliberately never read.

.venv\Scripts\python.exe -m pytest tests/test_tools.py::TestEditFile -q   # one class
.venv\Scripts\python.exe -m pytest tests/ -k "encrypted" -q               # by name

Lint rules are configured in pyproject.toml (ruff is not a declared dependency — install it separately):

ruff check .

There is a backlog of ~38 findings, almost all missing annotations on ASGI callbacks in daemon.py. Please don't add new ones.

Adding a tool

Subclass LocalProvider, declare name and tools, and implement one method per tool named _<tool_name>. Register the class in PROVIDER_CLASSES, and add an entry to POLICIES in tools/policy.py so it ships with correct annotations. The dispatcher, audit logging and error containment need no changes.

Synchronous methods are run on a worker thread automatically, so a slow filesystem never stalls the event loop; async def methods are awaited directly.

The same seam is how an upstream MCP server gets proxied: implement ToolProvider over a stdio or HTTP client, namespace the tool names, apply PathGuard to path arguments, and register it alongside the local providers.

A note on the mcp dependency

The floor mcp>=2.0 is load-bearing, not hygiene. 2.x is a rewrite of the 1.x API, not a compatible bump: the low-level Server takes on_* handlers instead of decorators, results are no longer auto-wrapped, and model fields are snake_case.


Troubleshooting

Use scripts/restart.ps1 rather than launching it by hand — it polls /health and reports honestly. If it fails, var/logs/restart.txt holds the reason plus the tail of both logs.

A common cause on Windows: a redirected stdout opens with the locale encoding, and non-ASCII log output kills the process before it binds the port. The daemon forces UTF-8 on stdout and stderr at startup to prevent exactly this.

That is the SSE failure mode: an idle connection is dropped somewhere in the tunnel, the client reuses the dead pooled session, and the call never returns. Point the client at /mcp (Streamable HTTP) instead — its exchanges are request-scoped, so there is no idle connection to lose. If you must stay on SSE, keep sse_ping_interval above zero.

ngrok's free tier refuses to start when http_proxy/https_proxy are set. Leave tunnel.strip_proxy_env: true (the default) — it scrubs them from the tunnel child process only, and your other tools keep their proxy settings.

ngrok's free tier serves a browser interstitial (ERR_NGROK_6024) to requests that look like a browser. The curl field returned by the transfer tools already carries the ngrok-skip-browser-warning header — run that command verbatim rather than reconstructing it.

A transparent-encryption driver owns that file. Copying it will not help. See Working with transparent encryption.

PathGuard resolves symlinks and junctions before checking the prefix, so a link that points outside allowed_paths is rejected even though its name looks fine. Add the real target directory, or move the file.


Contributing

Issues and pull requests are welcome at github.com/newcovid/LocalHands.

Before opening a PR:

  • .venv\Scripts\python.exe -m pytest tests/ -q passes

  • ruff check . reports nothing new against your changes

  • New tools carry a POLICIES entry and a test that goes through the real dispatcher

Please do not include machine-specific paths, tokens, or tunnel domains in a patch — config.yaml and var/ are gitignored for that reason.

License

MIT © newcovid

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    -
    quality
    D
    maintenance
    An MCP server that enables remote SSH command execution and bidirectional file transfers through a standardized interface. It allows AI assistants to securely manage remote servers while keeping credentials isolated and applying command-level security controls.
    ISC
  • A
    license
    A
    quality
    C
    maintenance
    A secure local MCP server that provides AI assistants controlled filesystem access and command execution with an interactive approval system for dangerous actions.
    17
    16
    MIT

View all related MCP servers

Related MCP Connectors

  • MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.

  • A paid remote MCP for AI agent browser MCP session, built to return verdicts, receipts, usage logs,

  • Personal assistant MCP server with search, execute, packages, jobs, secrets, and integrations.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/newcovid/LocalHands'

If you have feedback or need assistance with the MCP directory API, please join our Discord server