MCP server that gives remote agents local tools
README.md

Spooky — spec #

Spooky, the MCP server for action at a distance.

As a warning, this project is very vibecoded so take it as a proof of concept more than something secure enough to put sensitive traffic through.

Status: draft, decisions settled. M0–M3 built.

What it is #

Spooky lets a remote agent harness run tools on a local device. A harness with MCP support (the "remote" side) gains the ability to act on machines it cannot otherwise reach.

Two pieces:

  • spooky-relay — runs next to the harness. Speaks MCP to the harness and holds websocket connections from clients. Ships as a second binary/service in this repo.
  • spooky — runs on the local device. Dials out to the relay, loads tool plugins from a directory, and asks the human before running anything it wasn't told to auto-allow.

The local device dials out, so the relay never needs to reach it. Tailnet is the assumed transport; a plain public relay works too if the client can reach it.

harness ──MCP(stdio|http)──> spooky-relay <──wss/ws──> spooky (device) ──> plugin scripts
                                  │
                                  └─ tokens.json, device registry

Non-goals for v1 #

Named so they don't creep in:

  • TLS / transport encryption (scheme written down below, not built).
  • A web UI, a TUI transcript, conversation storage, memory. The harness owns all of that.
  • Sandboxing plugins. They run as the local user with the local user's access.
  • Streaming partial tool output. A call returns once, whole.
  • Multiple humans per device, roles, or any user model. A token is a device.
  • Windows.

Repo layout #

spooky/            # client binary, local device side
  src/
    main.rs        # CLI: pair, run, plugins, status, unpair
    plugins/       # manifest loading, invocation, spill
    link.rs        # websocket client, envelope, reconnect, reload
    state.rs       # token.json, pidfile, spill dir
    policy/        # approval prompts, autoallow, yolo (M3)
relay/             # relay binary, harness side
  src/
    main.rs        # CLI: serve, pair, tokens, devices
    mcp/           # MCP server surface (stdio; http deferred)
    registry.rs    # device registry, call routing, queue
    link.rs        # ws accept, hello/pair, frame loop
    admin.rs       # unix-socket admin channel for the CLI
    state.rs       # tokens.json, pairing.json
    digest.rs      # schema digest
proto/             # wire frames and argument validation, shared by both binaries
plugins/           # reference plugins: run, read-file, write-file, job-*
tests/             # end-to-end suite per milestone

Rust workspace, two binaries. tokio, no database — JSON files under a state dir. The whole thing should be readable in an afternoon.

Relay: MCP surface #

The relay is an MCP server with a fixed three-tool surface. It never changes at runtime.

  • spooky_list — no arguments: every connected device with its tool names and one-line descriptions. {device: "laptop"}: that device's tools with descriptions and requires hints, plus its platform. No schemas; those are the next tool.
  • spooky_schema — {device, tool}: a compact digest of one tool's arguments. {device, tool, full: true}: the raw JSON Schema, for anything the digest flattens away.
  • spooky_call — {device, tool, args, reason}: run tool on device with args. reason is the agent's stated purpose and is shown to the human in the approval prompt.

Why a fixed surface #

The obvious alternative — flattening every device's plugins into tools/list as laptop__run, phone__read_file — makes the tool set a function of who is connected. MCP has notifications/tools/list_changed for exactly that, but client support is uneven: some re-list, some cache the set for the session, and CLI harnesses typically spawn the server per session and list once. That yields a surface that is sometimes live, which is worse than one that is honestly static.

Three stable meta-tools instead: the harness's cached list is always correct, a device can connect mid-session and be usable immediately, and there is no per-device tool-name budget to fight. The cost is that the agent reads a schema before calling instead of getting it pre-loaded — one extra call, and the schemas stay authoritative because the relay serves them from the client's live hello.

spooky_list stays small: names and one-line descriptions only. Schemas come per tool, on demand. A device with ten plugins should cost a few hundred tokens to enumerate, not a few thousand.

Schema digest #

spooky_schema returns a compact form, not a JSON Schema dump:

{
  "device": "laptop",
  "tool": "run",
  "desc": "Run a shell command on this device.",
  "requires": ["shell", "net"],
  "args": [
    {"name": "cmd", "type": "string", "required": true, "desc": "the command line"},
    {"name": "timeout_s", "type": "integer", "required": false, "default": 60}
  ],
  "complex": false
}

Derived by walking properties/required one level deep. Argument descriptions come from the plugin's own schema description fields; a plugin that ships none gets desc: null and the agent should fall back to full: true. complex: true means the schema has nested objects, arrays or unions that the digest flattened, so the digest is lossy and the full schema is the truth. Saying so explicitly is cheaper than an agent guessing wrong on a nested argument.

Calls #

spooky_call validates before routing: unknown device → device <name> is not connected; unknown tool → device <name> has no tool <tool>, listing what it does have; args that fail the plugin's schema → the validation error, without waking the device. A routed call is then subject to the device's approval policy.

Every failure — validation, denial, timeout, plugin error — comes back as an MCP tool result with isError: true and a plain-text message. MCP protocol errors are reserved for malformed MCP itself; harnesses treat the two differently and some abort a session on a protocol error.

One call in flight per device. Further calls queue FIFO, eight deep; past that they fail at once with device <name> is busy. Serialising calls is what keeps the approval sink sane: prompts never stack, and the human never answers a call that has already been superseded.

The relay's tool descriptions should state the order plainly — spooky_list, then spooky_schema for the tool you picked, then spooky_call — so a model doesn't invent device or tool names or guess at arguments.

Transport for the harness side: spooky-relay serve --stdio --listen 127.0.0.1:7777 (harness spawns it as a subprocess) and spooky-relay serve --listen 127.0.0.1:7777 (streamable HTTP). Both bind the same registry. Default to stdio; http mode is for harnesses that want a persistent relay shared across sessions.

--listen is required in both modes. A device can only dial out, so the relay needs a listening socket in stdio mode too, and the harness has to know that address to give the device at pair time. MCP travels on stdio, the device link on the websocket; the two never share a channel.

Relay: device protocol #

Websocket, JSON frames, one frame per message. proto: 1. Server-assigned ids on server frames, client-assigned on client frames; a reply echoes ref.

Client → relay:

type fields
pair code, device — first frame on a throwaway socket: exchange the pairing code for a token.
hello token, proto, device, platform, tools[] — first frame. tools is {name, desc, schema}. A device held by a live socket is taken over on a matching token and rejected with device_taken otherwise (see liveness, below). Re-sending hello on an accepted socket replaces the tool set (plugin reload).
tool_result ref, ok, output, summary?, spilled?
ping

Relay → client:

type fields
paired device, token — answer to pair; the socket closes after it.
ready device, proto, policy_hint
call ref, tool, args, reason, agent? — run this. reason is the agent's stated purpose, shown to the human. agent is the harness's MCP clientInfo name, when it sent one: a hint for the prompt, not a security boundary.
deny ref, why — the relay refused before the device ran anything (unknown tool, device offline mid-flight, cancelled, timeout).
err ref, code, msg, code in proto, auth, device_taken, malformed, too_large
pong

A dropped connection is not fatal: the client reconnects with backoff and re-sends hello.

Pairing rides the device link because the relay has no other listener for a client to reach: spooky pair opens a socket, sends pair, takes paired, and closes. A code is single-use and a fresh one is minted after every successful pair, so the printed code is always the current one.

Frames over 1 MiB are rejected with too_large; a call whose args exceed that fails without waking the device.

Liveness: the relay pings every 20s and reaps a socket after two unanswered pings. A hello naming a device already held by a live socket is accepted — and takes that socket over — when it carries the same token, and rejected with device_taken when the token differs. Same token means the same client reconnecting; preemption is what stops a half-open socket from locking a device out until TCP keepalive finally notices. A different token means a second client claiming the same device name, which only the human can resolve, by renaming or revoking.

Call timeout: the relay gives up 120s after sending call, returns an isError tool result, and sends deny with why: "timeout" on the socket so a client blocked on a human prompt withdraws it instead of answering a call nobody is waiting for. The client may keep running; its late tool_result is discarded. MCP notifications/cancelled is forwarded as a deny with why: "cancelled" so a client waiting on a human unblocks.

The approval budget sits inside the call budget, not beside it: approval_timeout_ms defaults to 90s, and a plugin's timeout_ms is clamped to what is left of the 120s when approval returns. Otherwise a human answering at t=119 leaves the plugin one second to run.

Relay: admin channel #

spooky-relay devices and tokens revoke have to reach the process holding the live sockets, so serve also listens on a unix socket at <state>/admin.sock (mode 0600, removed and rebound at start). One JSON line in, one JSON line out: {cmd: "devices"}, {cmd: "tokens"}, {cmd: "revoke", device}. Local-only by construction, so it needs no token of its own. Revocation drops the device's tokens, closes its socket and fails its in-flight calls.

Pairing and auth #

  • Relay generates a pairing code at first boot, printed once to stderr. In stdio mode stdout is MCP framing, and one stray line corrupts the transport. spooky-relay pair reprints the code, so a harness-spawned relay never needs to print it at all.
  • spooky pair <relay-url> <code> --device <name> → relay mints a token, stores {device, token_hash, created}, client stores {relay_url, device, token} at ~/.local/share/spooky/token.json, mode 0600.
  • spooky-relay tokens list|revoke <device>. Revocation closes the live socket.
  • Tokens are opaque random 32 bytes, no expiry, no user model. Device names match [a-z0-9-]{1,32} and are unique per relay.

Threat model v1: tailnet is the security boundary. A token is a bearer credential that lets its holder answer tool calls; anyone holding it can impersonate a device.

Plugins #

A plugin is a directory under the plugin root, which is $SPOOKY_PLUGIN_ROOT, default ~/.config/spooky/plugins:

<plugin-root>/<plugin-name>/
  manifest.json
  <entry script>

manifest.json:

{
  "name": "run",
  "description": "Run a shell command on this device.",
  "entry": "run.py",
  "interpreter": "python3",
  "schema": { "type": "object", "properties": { "cmd": {"type": "string"} }, "required": ["cmd"] },
  "timeout_ms": 60000,
  "cwd": "inherit",
  "summary_hint": "cmd",
  "requires": ["net", "fs"]
}
  • name must match [a-z0-9-]{1,32} and must equal its directory name. Not cosmetic: the spill path is built from device and tool names, so a tool called run/exec would write outside the spill dir.
  • entry is resolved inside the plugin dir and must not escape it.
  • schema is what spooky_list serves and what the relay validates against. The client validates again before invoking — a plugin should not have to re-check its own arguments.
  • summary_hint names the argument to render in the approval prompt and to echo back as the call's one-line summary. A display hint only; nothing is validated against it.
  • cwd is inherit (the client's cwd) or a literal path.
  • requires is ["net", "fs", "shell"] at most, advisory only: it is shown in the approval prompt and in spooky_list, and enforced by nothing. It exists so the human sees that run wants a shell.
  • Invocation: spawn interpreter entry, write one JSON object to stdin {"args": {...}, "context": {"device": "...", "cwd": "...", "call_ref": "...", "jail": "..." | null}}, read one JSON object from stdout {"ok": true, "output": "...", "summary": "..."}. The result is the last line of stdout that parses as a JSON object; every other stdout line is ignored, and diagnostics belong on stderr. A nonzero exit, an unparseable stdout, or no parseable line at all becomes a tool error carrying the stderr tail.
  • output is a string. Above 64 KiB the client writes it to ~/.local/share/spooky/spill/<device>-<tool>.txt and returns head (64 lines) + tail (64 lines)
    • the path + byte count, with spilled: true in the frame. One path per device+tool, so a second spill overwrites the first: growth is bounded and the file always holds the most recent big output. Read an old one before the next call if you need it.
  • Plugins are scanned at client start and on SIGHUP / spooky plugins reload. Drop a dir in, reload. No hot-add without a reload — the human should see what appeared. The client records its pid at <state>/spooky.pid; spooky plugins reload sends SIGHUP to it, and the client rescans, swaps its tool set and re-sends hello.
  • A manifest that fails to parse is reported in spooky status and skipped; it does not stop the client.

Reference plugins, which double as the acceptance test for the contract: run, read-file, write-file, and the job-* set below. Hyphens, not underscores: the name rule above forbids _, so the plugin directories are plugins/read-file/ and plugins/write-file/. The job-* plugins share one core, plugins/joblib.py: a plain file at the plugin root, which the scanner ignores because it only reads directories.

Background jobs #

run blocks: the call holds the device until the command ends and the relay gives up at 120s. The job-* plugins hand a command to the background instead. One job per call, named job-<8 hex>:

  • job-start {cmd} — spawns the command in a new session and returns the id at once. Output goes to a log file, never to the caller.
  • job-poll {id} — running, exited with N, killed by SIGTERM, or not running with no exit code.
  • job-tail {id, lines=64} — the last lines of the log, read backwards from the end, capped at 200.
  • job-wait {id, timeout_s=30} — blocks until the job ends or the timeout runs out, then returns the status and the last lines. Clamped below the relay's call timeout.
  • job-kill {id, grace_s=5} — SIGTERM to the job's process group, SIGKILL if it ignores that.

State sits next to the spill files, <state>/jobs/<id>/: cmd, launcher, pid, log, exit, signal. The pid is the shell that leads the job's process group. The command runs in a subshell inside launcher so that an exit in it cannot skip the exit-code write. A job outlives the plugin process that started it and keeps running across client restarts and dropped links; nothing stops it but job-kill or the command ending. Jobs run in the jail, like every other plugin.

Two things this shape forces: a command that ends with exec leaves no exit code, and job-poll says so rather than guessing; and the log grows without limit, so a job that prints forever fills the disk.

Approval policy #

Default: prompt. On the device:

<agent> wants to run laptop:run
  curl example.com > /tmp/example
  reason: fetching the page to check the header
  needs: shell, net
run? [Y/n/a=always] >

The header names the calling agent from MCP clientInfo when the harness sends one, falling back to the device name — MCP carries no other identity, which is why <agent> is a hint and not a security boundary. The body line is summary_hint's argument rendered verbatim; a plugin with no summary_hint shows its args as compact JSON.

  • a appends the tool to the autoallow list in ~/.config/spooky/policy.json (per device, per tool name), mode 0600. The file holds autoallow as <device>:<tool> entries and approval_timeout_ms; $SPOOKY_POLICY overrides the path.
  • spooky --yolo auto-allows everything. spooky --allow run,read_file allows by name.
  • No answer within approval_timeout_ms (default 90s, configurable, clamped below the relay's 120s call timeout) = deny.
  • Non-tty stdin (daemon) = autoallow list only, plus --allow and --yolo; anything else is denied with why: "no human attached". Daemon deployments should run with an explicit autoallow list or --yolo. A desktop notification (notify-send / libnotify) carrying the same prompt is the intended next step, opt-in because headless boxes have no notification daemon — not v1.
  • CLI mode defaults to a cwd jail: the client resolves context.jail to its working directory and passes it to every plugin. The reference read_file/write_file refuse paths outside it unless --no-jail is passed, which sends jail: null. The jail is a path check before the plugin runs, not a sandbox, and the client does not special-case plugin names to enforce it — a plugin that ignores jail is simply not jailed. Daemon mode sends jail: null.

CLI surface #

spooky

  • pair <url> <code> --device <name>
  • run [--yolo] [--allow a,b] [--no-jail] [--cwd <dir>] [--verbose] — foreground, connects, prompts on tty. --cwd is where plugins run and what the jail is rooted at; without it the jail is the directory the client was started in.
  • daemon [--verbose] — no tty, autoallow only; meant for systemd.
  • plugins list|reload — list scans the plugin root; reload signals a running client.
  • status — relay reachability, loaded plugins, policy.
  • unpair

spooky-relay

  • serve --stdio --listen <addr> | --listen <addr> — the MCP endpoint. --listen is required in both modes; devices dial it.
  • pair — reprint/mint a pairing code.
  • tokens list|revoke <device>
  • devices — who's connected.

Service units: a systemd unit for the relay and one for spooky daemon, templated in packaging/. spooky daemon and spooky run share one connection layer; only the approval sink differs.

Milestones #

M0 — skeleton. Relay --stdio --listen with spooky_list, spooky_schema and spooky_call over a hardcoded registry; client connects with a hardcoded token, no pairing, no plugins. Proves the frame loop, the digest derivation and MCP wiring.

M1 — pairing + registry. Pairing codes, tokens, revocation, device registry, live hello tool sets, offline and unknown-tool error paths. Two clients, one relay.

M2 — plugins. Manifest loader, validation, invocation, spill, reload via SIGHUP and CLI. run/read_file/write_file as plugins.

M3 — approval. TTY prompt with requires hints, autoallow persistence, yolo, timeouts, cwd jail in CLI mode.

M4 — daemon. No-tty policy, systemd units, reconnect/backoff hardening, status.

M5 — MCP forwarding. A plugin type mcp in the manifest pointing at a local MCP server (stdio command or http url); the client proxies tools/list and tools/call through it and re-exports its tools under the device. Same approval policy applies.

Later and cheap: TLS, output streaming, desktop-notification approvals, per-plugin env vars.

Deferred: TLS #

Written down, not built. When the relay is not on a trusted network: relay serves wss with a certificate whose SHA-256 fingerprint is captured at pair time and pinned in the client's token.json; pairing happens over that pinned channel with a one-time code. A private channel without a CA and without a new trust store. Rejected for v1: mutual TLS with ssh keys converted to certs — more moving parts, same property.