Content
# spark-mcp-relay
A two-box, self-hosted MCP system: one connector, relayed web search and
bounded, cited web research, with **zero internet-facing code on the box
that holds the model**.
```
Users (any plan) ── one connector ──▶ SPARK (DGX Spark, intelligence + tool host)
│ MCP tools: ask_local_model, summarize_docs,
Anthropic cloud ─▶ Cloudflare Tunnel ──▶│ review_code, web_search, web_research
(outbound-only, inbound path) │
├── vLLM + model (127.0.0.1 only)
└── relay_client.py ── private seam (Tailscale)
│ Spark reaches ONLY the Relay
▼
RELAY (disposable VPS — the only box on the open internet)
Brave client (safesearch hardcoded strict)
hardened fetcher (SSRF guard, both families)
rules engine (robots, rate limit, stop-on-block)
│
Brave Search API / public websites
```
## Threat model in one paragraph
Two directions of distrust meet at the seam. **Outbound**, the Spark can
reach exactly one host — the Relay — enforced below the application by a
default-deny, per-process egress firewall in both address families, so a
buggy or subverted web tool cannot become general egress. **Inbound**, the
Relay accepts requests only from the Spark, authenticated with the seam
token (and, over Tailscale, with no public inbound port at all), so it never
becomes an open proxy. No tool on either box writes files, executes code, or
acts on the world; the one sanctioned filesystem capability is read-only —
the local-model tools can load operator-uploaded documents from a single
configured directory (writable only via authenticated admin SSH), through
`spark/docs.py` alone. Fetched, searched, and loaded-document content is
untrusted text, wrapped in markers, that can shape output but can never
trigger an action — that structural absence of actuators is the
prompt-injection containment. Every hostile parser of *internet* content
(HTML extraction, HTTP handling) lives on the Relay, whose total loss is an
afternoon's rebuild, and all web egress carries the datacenter's IP, never
the home IP. (The one parser on the Spark — pypdf for uploaded PDFs — reads
only operator-chosen files, never fetched ones.)
## What is enforced where
| Property | Mechanism | Where |
|---|---|---|
| Spark reaches only the Relay | per-process (owner-matched) nftables, both families | `spark/firewall/egress.nft` |
| MCP user cannot exfiltrate via DNS | **no resolver rule at all** for that user; it dials a pinned tailnet IP | `egress.nft`, `relay_client.py` |
| Tunnel cannot readmit the CDN'd web | cloudflared restricted to port 7844 toward a **maintained set** of Cloudflare's published ranges — never a hostname, never blanket 443 | `egress.nft` + `refresh-argotunnel.sh` |
| Relay accepts only the Spark | tailnet ACL (single rule) + seam bearer token, constant-time | `tailscale/acl.hujson`, `relay/relay.py` |
| Safe search cannot be relaxed | hardcoded `strict` constant; the seam has **no safe-search parameter** (unknown body fields → 422, query params → 400) | `relay/brave_client.py`, `relay/relay.py` |
| No SSRF | resolved-address validation (both families, CGNAT, v4-mapped unwrap, metadata), literal-IP refusal pre-resolution **and at every redirect hop**, IP pinning against rebinding, per-hop re-validation | `relay/fetcher.py` |
| Scraping ethics | one rules engine on every fetch: robots, identified UA with `CONTACT_MAILTO`, ~1 req/1.5 s/domain, conditional requests, exponential backoff, stop-on-block, **no evasion tooling** | `relay/rules.py` |
| Fetching is bounded | the seam's fetch endpoint takes at most 3 URLs; `web_research` fetches only search-result URLs (model selects by index), hard `RESEARCH_MAX_PAGES` cap | `relay/relay.py`, `spark/server.py` |
| Brave credit survives a subverted Spark | relay-side daily ceiling, deliberately redundant with Spark per-person caps | `relay/relay.py` |
| A restart cannot re-arm the day's cap | Spark quota counters persist in a SQLite quota store (counters only — no queries, URLs, or call records) | `spark/quota_store.py`, `spark/auth.py` |
| claude.ai web/mobile connect without weakening per-person identity | OAuth 2.1 + PKCE + DCR front door; consent = the person's own `tokens.json` key; derived tokens stored hashed, re-checked against the live token set on every use | `spark/oauth.py`, `spark/server.py` |
| No usage records | no activity log on either box; caller identity never crosses the seam; the relay logs no routine request content (only rules-engine policy events — robots refusals, stop signals — reach its ephemeral stdout) | `spark/server.py`, `relay/relay.py` |
| Document reads are contained | strict name resolution inside one configured directory (traversal and symlink escapes refused), byte cap checked before any read/parse, char cap after, untrusted-content wrapping; the docs dir is writable only via authenticated admin SSH — no tool can write it | `spark/docs.py` |
**Scope note, stated as plainly as the strength:** the egress firewall's
owner matching binds *service accounts*. It is a guarantee against buggy or
subverted tool code, **not** against an attacker who reaches root on the
Spark, who can rewrite the rules that bind it. Relatedly: `tokens.json`
and `oauth.db` hold credential material, and the server-side docs
directory now puts *document content* at rest on the box too — disk
encryption on the Spark is strongly recommended.
## The five tools
| Tool | What it does | Networking |
|---|---|---|
| `ask_local_model` | prompt the local model, optionally with an uploaded document attached (`doc='<name>'`) | localhost vLLM only |
| `summarize_docs` | summarize supplied text or an uploaded document (`doc='<name>'`) | localhost vLLM only |
| `review_code` | review supplied code or an uploaded file (`doc='<name>'`) | localhost vLLM only |
| `web_search` | strict-safe-search query via the Relay; titles/snippets/URLs only | seam only |
| `web_research` | search → bounded fetch → cited synthesis on the local model; only the cited answer returns | seam only |
**Five tools — and the surface stays there.** The next capability must
merge into an existing tool, not join the list. A workload that needs bulk
scraping, crawling, datasets, or recurring jobs is the escalation path to a
different (Version 3) system — not a reason to grow this one back.
### Server-side documents
Uploading a document to the Spark (over the tailnet's admin SSH — rsync,
the `scripts/spark-upload` drop-folder helper, or a mobile SFTP app; see
`docs/DEPLOYMENT.md`) lets the local-model trio reference it by name:
`summarize_docs(doc="report.pdf")` instead of pasting the report through
chat, and `doc="?"` lists what is uploaded. The capability merged into the
existing tools — it is not a sixth tool. Document content is fed to the
*local* model only and only the summary/answer/review returns to the
caller, so a large document costs frontier-model context nothing. (Honest
framing: that is the default data path, not a guarantee — a caller can
still ask for verbatim quotes.) Plain-text formats and PDF are supported;
pypdf runs on the Spark, a deliberate deviation from "hostile parsers live
on the Relay" justified because the docs directory is writable only via
authenticated admin SSH — every file is operator-chosen, not fetched.
Document reads are not recorded, matching the no-usage-records stance.
### Revision 5.0: the scraping subsystem is gone
Revisions 4.1–4.4 grew a bounded batch-scraping pipeline (`scrape_urls`,
`scrape_status`, `analyze_scraped`, `fetch_cached_page`, a relay job runner,
and two ephemeral caches with a destructive-collect handoff between them).
Revision 4.4 had already fixed its worst property — page bodies flooding the
caller's context — by adding a manifest layer and two more tools. Revision
5.0 concludes that the capability was not worth the pipeline it carried:
four of nine tools, a poll/collect job protocol, state in four places, and
most of the operational surface existed to serve bulk collection that
`web_research` covers for the actual research use case. Removing it deletes
the batch runner, both caches, the manifest builder, and the escape hatch;
the relay is now **stateless between requests** and small enough to audit in
one sitting, twice. What remains — snippet search and bounded, cited
research on the local model — is the part that was worth its complexity.
`docs/REVISION-5.0.md` records the delta and the other 5.0 changes
(parallel research fetches, boilerplate-stripping extraction, token
hot-reload, persistent quotas, CI). The activity log that shipped through
5.0 has since been removed entirely: no tool call is recorded on either
box, and the only per-person state kept is the daily search-quota counter.
## Deploying the Spark
> **Step-by-step from zero:** `docs/DEPLOYMENT.md` walks the whole
> pipeline in order — vLLM + Nemotron 3 Super 120B on the DGX Spark, the
> relay VPS, Tailscale, the tunnel, the egress firewall, and connecting
> Claude — including the smoke test at every stage and the ordering traps.
> The sections below are the reference summary.
1. Python 3.11+. `pip install -r spark/requirements.txt`.
2. Full-disk encryption on (strongly recommended — `tokens.json` and
`oauth.db` hold credential material).
3. Run vLLM bound to `127.0.0.1` and set `VLLM_BASE_URL`.
4. `cp .env.example .env` and fill in; `cp spark/tokens.json.example
spark/tokens.json` and mint one long random token per person
(`python -c "import secrets; print(secrets.token_urlsafe(32))"`).
The token file hot-reloads: adding or revoking a person is an edit, not
a restart. A broken edit keeps the last good token set in force.
5. Create service users: `mcp` (runs `spark/server.py`), `cloudflared`
(runs the tunnel). Note their UIDs.
6. Join the tailnet, tag the node `tag:spark`, enable **tailnet lock**.
Already on Tailscale (NVIDIA playbook / NVIDIA Sync)? See the "Already
on Tailscale?" note in `docs/DEPLOYMENT.md` Phase 4 before pasting the
ACL — saving a policy replaces the whole tailnet policy file.
7. Set `RELAY_BASE_URL` to the Relay's tailnet IP **literal** — the MCP
user cannot resolve names, on purpose.
8. Install the firewall (below), then run the MCP server as the `mcp` user:
`python -m spark.server`. Publish it with an outbound-only Cloudflare
Tunnel; the home router keeps zero open ports.
**Two connector auth paths, one credential.** Authentication is a static
per-person bearer token in the `Authorization` header; Claude Code and API
clients send it directly. claude.ai **web and mobile** connectors cannot
send a static header — they require the MCP OAuth flow — so setting
`SPARK_PUBLIC_URL` (the tunnel's public HTTPS origin) mounts an OAuth 2.1
front door (`spark/oauth.py`): authorization-code + PKCE, dynamic client
registration, and the RFC 8414/9728 discovery documents. The human step of
that flow is a `/connect` page where the person pastes their `tokens.json`
key once in their browser; claude.ai then holds only short-lived derived
tokens (stored hashed in `oauth.db`), every use of which re-checks that
the person is still in the live token set — offboarding stays a single
edit to `tokens.json`, whichever path a call arrived by. With
`SPARK_PUBLIC_URL` unset the OAuth
surface does not exist and the server is static-token-only, as before.
### The egress firewall (`spark/firewall/egress.nft`)
Default-deny outbound in **both** IPv4 and IPv6, permitting **per process**
(owner matching), not per port — a blanket "allow 443" cannot tell the
tunnel apart from a tool dialing anything CDN-fronted. Edit the UIDs and
addresses at the top of the file, then:
```sh
cp spark/firewall/egress.nft /etc/nftables.d/
nft -f /etc/nftables.d/egress.nft
```
The rule set, in prose:
- **`mcp` user** → the Relay's tailnet IP on port 8090. Nothing else — and
**no resolver access at all**: no DNS carve-out exists for this user, so
it cannot tunnel data through port 53, the one channel a default-deny
firewall usually leaves open. It dials a pinned address and resolves
nothing.
- **`cloudflared` user** → port 7844 (UDP for QUIC, TCP for the HTTP/2
fallback) toward the `cf_tunnel_v4`/`cf_tunnel_v6` sets only.
- **tailscaled** → the Tailscale control plane (443, pinned /24), WireGuard
(41641/udp), STUN (3478/udp). DERP relays are deliberately **not** carved
out — the seam depends on direct WireGuard reachability (see the Phase 4
note in `docs/DEPLOYMENT.md`).
- **System users (never `mcp`)** → one pinned resolver on 53, pinned NTP
servers, the pinned distro mirror. Every carve-out is enumerated; nothing
is implicit.
- Everything else, either family: logged and dropped.
### Argotunnel set refresh
A hostname in a firewall rule resolves once at insertion and then drifts
silently. The cloudflared endpoint sets are therefore refreshed from
Cloudflare's **published** ranges (`https://api.cloudflare.com/client/v4/ips`)
by `spark/firewall/refresh-argotunnel.sh`, which applies the new sets
atomically, fails closed on an empty answer, and stamps
`/var/lib/spark-firewall/argotunnel-refreshed`.
The script maintains its own egress path: root's only 443 carve-out is the
`cf_api_v4`/`cf_api_v6` sets, which the script re-seeds from the pinned
resolver at the start of every run — so the first run (immediately after
`nft -f egress.nft`) and every scheduled run pass the very firewall they
maintain. The sets are empty after every reboot, so schedule the refresh
at **boot and monthly** (a systemd timer with `OnBootSec` +
`OnCalendar=monthly`; `docs/DEPLOYMENT.md` §8.5 has the unit files), or at
minimum monthly cron:
```
# /etc/cron.d/refresh-argotunnel
0 4 1 * * root /usr/local/sbin/refresh-argotunnel.sh
```
`tests/verify_deployment.sh` fails if the stamp is older than 31 days.
## Deploying the Relay
1. Any small VPS. Apply `relay/HARDENING.md` (SSH keys only, non-root,
default-deny inbound, fail2ban, unattended upgrades).
2. Join the tailnet, tag `tag:relay`. Load `tailscale/acl.hujson` as the
tailnet policy: the only permitted path to the relay is
`tag:spark → tag:relay:8090`.
3. Point DNS at Cloudflare Gateway with adult/malware/phishing categories
blocked, or `1.1.1.3` as the zero-config fallback (already set in
`compose.yaml`).
4. `cp .env.example relay/.env` — docker compose reads the `.env` **next
to the compose file**, not the repo root. Set `BRAVE_API_KEY`,
`RELAY_SEAM_TOKEN` (same value as the Spark's), `CONTACT_MAILTO`, and
`RELAY_TAILNET_IP`. The compose file refuses to start while
`RELAY_TAILNET_IP` is unset (a `${VAR:?}` guard): an empty value would
bind the port publish to `0.0.0.0`, and Docker's NAT bypasses ufw —
the seam would be on the public internet.
5. `docker compose -f relay/compose.yaml --env-file relay/.env up -d
--build`. The container is read-only, mounts nothing, and keeps no
state between requests.
**Ordering:** the port publish binds the tailnet address, which exists
only once tailscaled is up — order docker after tailscaled (systemd
drop-in) or let `restart: unless-stopped` retry; see the note in
`compose.yaml`.
The relay's only secrets are the Brave key and the seam credential. It
stores nothing at all and is rebuilt, not repaired.
Uptime monitoring: `GET /livez` answers without authentication and reveals
nothing (the port is on the tailnet only); `/healthz` stays behind the seam
token.
### Fallback seam without Tailscale
A public HTTPS endpoint (reverse proxy with a real certificate) in front of
port 8090, with the bearer token — or mTLS — plus an IP allow-list pinned
to the Spark's egress address. The seam credential stays mandatory either
way. Tailscale remains the recommendation: zero public inbound ports and
stable addressing despite a dynamic home IP.
## Tests
```sh
pip install -r relay/requirements.txt -r spark/requirements.txt -r tests/requirements.txt
pytest
```
The unit suite runs against the real code with fake transports: SSRF guards
(v4-mapped, CGNAT, metadata, literal-IP at submission and per redirect hop,
rebinding pin), hardcoded strict safe search and the absent seam parameter,
the seam's fetch bound and the relay daily ceiling, robots stops, rate
limiting, the research bounds, an AST tripwire asserting no
proxy/rotation/evasion identifier exists in any relay module, a
characterization layer (`tests/test_characterization.py`) that pins every
security property end-to-end so a refactor cannot silently drop one, the
token hot-reload / persistent-quota behaviors, and the OAuth front door
end-to-end (discovery documents, dynamic registration, PKCE, the consent
page's attempt limit, refresh rotation, hashes-only token storage, and
revocation-by-tokens.json of derived tokens). CI runs the suite on
every push and pull request (`.github/workflows/ci.yml`) and fails if a
secret-shaped file is ever committed.
`tests/verify_deployment.sh` is the other half: **live-infrastructure**
checks that cannot and must not be faked in pytest — per-process egress
against a *CDN-fronted* target (an arbitrary target would pass while the
CDN gap stood open), the v6 path, the MCP user's missing resolver, DNS
category filtering, tailnet lock, public unreachability of the fetch port,
and argotunnel-set freshness. Run its sections on the boxes they are
labeled for.
## Secrets discipline
`tokens.json`, `.env`, `*.db`, key material, and local blocklist overrides
are gitignored; only `*.example` templates are committed. Before any
commit: `git ls-files | grep -E 'tokens\.json$|\.env$|\.db$'` must return
nothing (CI enforces the same check).
## Usage notice (shown to users)
Tool calls are not recorded: the server keeps only a per-person daily
search counter (no queries, URLs, or call history). Document reads are
likewise unrecorded. Web traffic egresses from the relay VPS's datacenter
address.
## License
MIT — see `LICENSE`.
Connection Info
You Might Also Like
markitdown
Python tool for converting files and office documents to Markdown.
OpenAI Whisper
OpenAI Whisper MCP Server - 基于本地 Whisper CLI 的离线语音识别与翻译,无需 API Key,支持...
oh-my-opencode
Background agents · Curated agents like oracle, librarians, frontend...
Train-in-Silence
The first Task-Aware MCP server and automated VRAM calculator for LLM...
stacklit
108,000 lines of code. 4,000 tokens of index. One command makes any repo...
AppClaw
AI-powered mobile automation agent — describe what you want in plain...