A small, configurable HTTP/REST MCP server for talking to everything in your
homelab — and beyond. Check a Prometheus metric, poke a container's API, chat
with a local LLM, or call an authenticated external service and pipe the result
somewhere else. One universal http_request tool, named per-host profiles,
per-host TLS control, and a security policy built for a network where reaching
internal hosts is the point.
Status: v1.0. MIT licensed. Issues and PRs welcome.
The existing ones are close but trip on three things for homelab use:
- TLS is all-or-nothing. Internal boxes use self-signed certs or an internal
CA; most servers either verify strictly (and fail) or expose a single global
"disable SSL" switch that also weakens your public calls. subwire resolves
verifyper scope — point the global default at your internal CA so the whole*.home.lanfleet validates with verification on, skip it for one un-reissuable appliance, keep strict verification for public hosts — all independently. See Internal CA / wildcard certs. - Single base-URL lock-in. A homelab hits many hosts. subwire lets you define as many named targets as you like, or just pass an absolute URL with no target.
- URL-as-blind-string → SSRF. Security reviews of the MCP ecosystem flag that an LLM-supplied URL can be steered (via prompt injection) at internal-only services or the cloud-metadata endpoint. subwire keeps the deliberate internal access but blocks link-local/metadata by default and gives you allow/deny lists, a read-only mode, and per-target method allowlists.
No shell-out to curl; the request engine is httpx.
From source (recommended while it's young):
git clone https://github.com/Corundex/subwire && cd subwire
make install # = pip install .Or, once published to PyPI:
pip install subwireRequires Python 3.11+ (for local install) or just Docker (for the server setup —
nothing to install on the host but Docker itself). Dependencies: httpx, mcp,
pyyaml. Run make help to see all the shortcuts.
First, make your config (this copies the example; edit it to list your hosts):
make config # creates config.yaml from config.example.yaml
# or: cp config.example.yaml config.yamlThen pick how you want to run it:
make install # installs the `subwire` command
make run # runs on stdio, using config.yamlThen add it to your client — Claude Code or Claude Desktop (see below).
Config is baked into the image, so there's nothing to mount and it behaves the same locally or remotely:
make up # builds the image (with your config.yaml inside) and starts it
# equivalently: docker compose up -d --buildThe MCP endpoint is then http://<host>:8080/mcp. Point your client at it
(see below) — claude mcp add for Claude Code, or
mcp-remote for Claude Desktop.
No config at all?
subwirestill runs — it just has no named targets, and you pass full URLs directly. Default policy: reach LAN + public, block cloud metadata.
One command — claude mcp add writes the entry for you:
# Local stdio (subwire installed on the same machine)
claude mcp add --scope user subwire subwire
# Remote streamable-HTTP (subwire running in Docker on your homelab)
claude mcp add --transport http --scope user subwire http://subwire.home.lan/mcp--scope user makes the server available across all your projects. Verify with
claude mcp list or the /mcp slash command. Restart Claude Code (new chat
or reopen the extension) after the first add — MCP servers are loaded at session
startup.
Local (stdio):
{
"mcpServers": {
"subwire": {
"command": "subwire",
"args": ["--config", "/path/to/config.yaml"]
}
}
}Remote (HTTP server on your LAN, via mcp-remote):
{
"mcpServers": {
"subwire": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://subwire.home.lan/mcp",
"--transport", "http-only", "--allow-http"]
}
}
}Make a request. Either pass an absolute url, or a target + relative url.
| arg | type | notes |
|---|---|---|
url |
string | absolute, or relative to the target's base_url |
method |
string | default GET |
headers |
object | merged over target defaults |
params |
object | query string |
json_body |
any | JSON body (sets Content-Type) |
body |
string | raw body (non-JSON) |
target |
string | named profile to use |
timeout |
number | seconds |
verify |
bool | string | TLS override: true, false, or a CA-bundle path |
max_bytes |
int | response body cap |
Returns JSON: {request, status, ok, elapsed_ms, headers, body, truncated}.
JSON response bodies are parsed into structured data automatically.
Lists configured targets (base URL, allowed methods, TLS posture, auth type — no secrets) and the active security policy. Good first call so the model knows what it may reach.
YAML. Every setting resolves by precedence explicit arg > target > default.
See config.example.yaml for an annotated copy.
defaults:
timeout: 30
verify: /etc/subwire/home-ca.pem # internal CA root: whole-LAN trust anchor
# (use `true` for public-only, `false` to disable)
allow_http: true # permit plain http://
read_only: false # true => only GET/HEAD/OPTIONS
max_response_bytes: 100000
follow_redirects: true
security:
allow_private: true # RFC1918 + *.home.lan etc.
allow_loopback: true
allow_metadata: false # block 169.254.169.254 (keep false)
allow_hosts: [] # extra explicit allow globs
deny_hosts: [] # explicit deny globs (win over all)
targets:
prometheus:
base_url: http://prometheus.home.lan:9090
allowed_methods: [GET] # monitoring: read-only
llama:
base_url: http://llama.home.lan
dozzle:
base_url: https://dozzle.home.lan # CA-signed => inherits default, verifies
legacy-appliance:
base_url: https://nas.home.lan
verify: false # the exception: a cert you can't re-issue
smartoffs:
base_url: https://www.smartoffs.com
auth: { type: bearer, token_env: SMARTOFFS_TOKEN }Credentials are never in the file — auth blocks reference environment variable names, resolved at request time:
auth: { type: basic, username_env: SVC_USER, password_env: SVC_PASS }
auth: { type: bearer, token_env: SVC_TOKEN }
auth: { type: apikey, header: X-API-Key, value_env: SVC_KEY }Need a scheme that isn't built in? auth.py is a small, self-contained module
with a clear shape — add a case and you're done.
The cleanest way to get verified TLS across a whole homelab is one internal CA
at defaults.verify — not a pile of per-host verify: false. Sign each service's
cert with your CA, point the global default at the CA root, and every
*.home.lan host validates with verification on. Override per-target only for
the odd box you can't re-issue a cert for.
defaults:
verify: /etc/subwire/home-ca.pem # whole-LAN trust anchorWhen verify points at a CA bundle, subwire merges it with the standard
public trust store (certifi) at request time — so the same setting trusts
both your internal CA and every public root, and https://api.github.com
keeps working alongside https://dozzle.home.lan. You don't need to override
verify per call for external hosts.
Prefer a single self-signed wildcard cert over a CA? That works too — a
self-signed cert is its own trust anchor, so point verify at the cert file.
Two caveats:
- It must carry the name in
subjectAltName, not just CN. Modern OpenSSL (and thus httpx) ignores CN for hostname matching — a CN-only or no-SAN cert fails validation no matter what you trust. This is the usual reason a hand-rolled self-signed cert "doesn't work." - A wildcard matches exactly one label.
*.home.lancoversdozzle.home.lanbut not the apexhome.lannora.b.home.lan. Add extra SAN entries (or use the CA approach) for those.
Generating a wildcard self-signed cert with the right SANs:
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout home-lan.key -out home-lan.pem \
-subj "/CN=*.home.lan" \
-addext "subjectAltName=DNS:*.home.lan,DNS:home.lan"If verify points at a CA/cert path that doesn't exist, subwire fails the
request with a clear message naming the path (rather than an opaque SSL error) —
handy when a Docker volume mount is wrong.
Note: subwire keeps
verifyat just two scopes — global default and per-target — on purpose. The global default is the wildcard; per-target handles exceptions. There's deliberately no "trust CA X for hosts matching pattern Y" map, since it adds config surface for no capability the two scopes don't already cover.
SUBWIRE_CONFIG, SUBWIRE_HOST, SUBWIRE_PORT, SUBWIRE_READ_ONLY,
SUBWIRE_ALLOW_HTTP, SUBWIRE_VERIFY (true/false/CA path),
SUBWIRE_MAX_RESPONSE_BYTES, SUBWIRE_ALLOWED_HOSTS (comma-separated, for
the --http transport allowlist), SUBWIRE_DISABLE_DNS_REBINDING_PROTECTION.
There are two ways to run subwire in Docker, depending on who you are:
Use the deploy/ template. It keeps your config and certs in a
folder you own, pulls a pinned subwire version from GitHub at build time, and
bakes them together — so updating subwire never touches your data, and there are
no volume mounts.
cp -r deploy ~/subwire-deploy # copy the template out of the code repo
cd ~/subwire-deploy
nano config.yaml # add your targets
docker compose up -d --build # pulls subwire + bakes your dataUpdating later is a one-line version bump in deploy/docker-compose.yaml, then
rebuild — see deploy/README.md. This is the cleanest path
for a homelab, especially for shipping to a remote host (clone your deploy repo
on the server and up).
If you've cloned this repo to work on subwire itself, the root Dockerfile builds
straight from the local source, baking in config.yaml and certs/ from here:
make config # create config.yaml (once), then edit it
make up # build from local source, start detachedIn both cases, secrets referenced by your targets (e.g. token_env: SMARTOFFS_TOKEN)
are passed as environment variables — never baked into the image. Using an
internal CA? Drop the .pem in the relevant certs/ folder before building and
reference it as /etc/subwire/certs/home-ca.pem.
subwire is designed to reach internal hosts on purpose, so it can't just block private IPs. Instead, evaluated in order:
deny_hostsglob match → refused.http://withallow_http: false→ refused.- method not permitted (read-only mode and/or target allowlist) → refused.
- address class: link-local/cloud-metadata blocked by default
(
allow_metadata: false); loopback/private allowed if enabled; public allowed.
TLS verification is per-target and never silently disabled globally. Secrets stay in env vars and configured auth headers are redacted from echoed requests.
Known limitations: no DNS-rebinding defense in the outbound classifier (hosts are matched by literal/pattern, not re-resolved at request time, so an attacker who controls a name in your allow-list could rebind it to a denied address); allow/deny are globs, not regex.
subwire doesn't show up in the client. MCP servers are loaded at startup.
For Claude Code: open a new chat or restart the VS Code extension after
claude mcp add; claude mcp list or /mcp should show it Connected. For
Claude Desktop: fully quit and reopen (don't just close the window), then
Settings → Connectors. For remote/HTTP setups, make sure the container is up
(make logs) and the URL ends in /mcp.
"config file not found". The path you passed with --config doesn't exist.
Run make config to create config.yaml, or just run subwire with no flags —
it auto-creates ./config.yaml from the bundled example on first run, so the
server starts with a few demo targets you can immediately try.
A request fails with "TLS verify is set to CA bundle ... but that file does not
exist". Your verify: path is wrong, or (in Docker) the cert wasn't baked in.
Put the file in certs/, reference it as
/etc/subwire/certs/<file>, and rebuild with make up.
A request fails with a certificate error against an internal host. The host's
cert almost certainly lacks a subjectAltName (CN alone is ignored). Re-issue the
cert with proper SANs — see Internal CA / wildcard certs.
Clients get "421 Misdirected Request" / server logs "Invalid Host header".
The MCP streamable-HTTP transport rejects any Host header that isn't in its
allowlist (default: localhost only) — DNS-rebinding protection. List the
hostnames clients use under defaults.allowed_hosts (e.g. subwire.home.lan,
master.home.lan:8081; host:* port-wildcard supported), or set
defaults.disable_dns_rebinding_protection: true for a trusted LAN. The same
knobs are available as --allowed-host / --disable-dns-rebinding-protection
on the CLI and SUBWIRE_ALLOWED_HOSTS / SUBWIRE_DISABLE_DNS_REBINDING_PROTECTION
in the environment.
"blocked by security policy". Working as intended — the request hit a gate.
The message says which one (deny-list, read-only mode, a target's method
allowlist, or the metadata block). Adjust the relevant setting in config.yaml
if it's a host you do want to reach.
Plain http:// is refused. You (or an env override) set allow_http: false.
Set it back to true for homelab use.
MIT — see LICENSE.