LocalHands

LocalHands

A local MCP server that gives a cloud AI agent hands on a real machine, enabling file operations, command execution, and screen capture via a secure tunnel with path whitelisting, command guards, and audit logging.

Category
访问服务器

README

<div align="center">

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 · 简体中文

</div>


Table of contents


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 trades breadth for a narrower, guarded surface:

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 .

<details> <summary>Linux / macOS</summary>

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. </details>

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.

<details open> <summary><b>Authentication and scope</b></summary>

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.

</details>

<details> <summary><b>Transports and limits</b></summary>

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.

</details>

<details> <summary><b>Binary transfer channel</b></summary>

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

</details>

<details> <summary><b>Outbound downloads (<code>download_file</code>)</b></summary>

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

</details>

<details> <summary><b>Safety rails</b></summary>

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.

</details>

<details> <summary><b>Images, search, DLP, tunnel</b></summary>

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.

</details>


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.

<details open> <summary><b>Files</b> — 8 tools</summary>

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

</details>

<details open> <summary><b>Search</b> — 5 tools</summary>

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

</details>

<details open> <summary><b>Shell, transfer, media, net, desktop</b> — 10 tools</summary>

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

</details>

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). The tree is clean; please keep it that way:

ruff check .

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 not auto-wrapped, and model fields are snake_case.


Troubleshooting

<details> <summary><b>The daemon says it started, but nothing answers</b></summary>

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. </details>

<details> <summary><b>The client stalls on the first call after an idle period</b></summary>

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. </details>

<details> <summary><b>ngrok exits immediately with <code>ERR_NGROK_9009</code></b></summary>

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. </details>

<details> <summary><b>A download saved an HTML page instead of the file</b></summary>

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. </details>

<details> <summary><b><code>read_file</code> returns <code>FileEncrypted</code></b></summary>

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

<details> <summary><b>A path inside my project is rejected</b></summary>

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. </details>


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 . is clean
  • 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

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选