A configurable HTTP/REST MCP server for homelab and external APIs.
Project description
subwire
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.
Why another HTTP MCP server?
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.
Install
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 subwire
Requires 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.
Quickstart
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.yaml
Then pick how you want to run it:
A. On the machine you use Claude Desktop from (simplest)
make install # installs the `subwire` command
make run # runs on stdio, using config.yaml
Then add it to your client — Claude Code or Claude Desktop (see below).
B. As a server on your homelab / a remote host (Docker)
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 --build
The 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.
Connecting it
Claude Code (CLI / VS Code extension)
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.
Claude Desktop
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"]
}
}
}
Tools
http_request
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.
list_targets
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.
Configuration
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 }
Auth
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.
Internal CA / wildcard certs
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 anchor
When 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.
Environment overrides
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.
Docker
There are two ways to run subwire in Docker, depending on who you are:
Run a pinned release with your own data (recommended for users)
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 data
Updating 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).
Build from a local clone (contributors / hacking on the code)
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 detached
In 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.
Security model (read this)
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.
Troubleshooting
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.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file subwire-1.0.0.tar.gz.
File metadata
- Download URL: subwire-1.0.0.tar.gz
- Upload date:
- Size: 30.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6ef1c8900a279c6556cdd4a32b33471e64ddb0c2e569bfdcf67faefe8ac2d9ea
|
|
| MD5 |
f6188561561690406be99f635e93e89f
|
|
| BLAKE2b-256 |
8e529465d363d5aba14580ca3823b417ead4244e20aa89890a8b795a3859b38a
|
Provenance
The following attestation bundles were made for subwire-1.0.0.tar.gz:
Publisher:
publish.yml on corundex/subwire
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
subwire-1.0.0.tar.gz -
Subject digest:
6ef1c8900a279c6556cdd4a32b33471e64ddb0c2e569bfdcf67faefe8ac2d9ea - Sigstore transparency entry: 2002334943
- Sigstore integration time:
-
Permalink:
corundex/subwire@4145f5064f7ee39eb9ff8fa958a6cb7ad9b1dad0 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/corundex
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4145f5064f7ee39eb9ff8fa958a6cb7ad9b1dad0 -
Trigger Event:
push
-
Statement type:
File details
Details for the file subwire-1.0.0-py3-none-any.whl.
File metadata
- Download URL: subwire-1.0.0-py3-none-any.whl
- Upload date:
- Size: 24.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
413c3763beecd8a14943a685020a9f36e75d305ceb9028fc35706dab062ec101
|
|
| MD5 |
3a943329e4e3603fdbf130c00c3078df
|
|
| BLAKE2b-256 |
5a97a275c407c7a3509300beb4974ba05ecf852cf6dbe8607c0cf85c01540029
|
Provenance
The following attestation bundles were made for subwire-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on corundex/subwire
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
subwire-1.0.0-py3-none-any.whl -
Subject digest:
413c3763beecd8a14943a685020a9f36e75d305ceb9028fc35706dab062ec101 - Sigstore transparency entry: 2002334990
- Sigstore integration time:
-
Permalink:
corundex/subwire@4145f5064f7ee39eb9ff8fa958a6cb7ad9b1dad0 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/corundex
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4145f5064f7ee39eb9ff8fa958a6cb7ad9b1dad0 -
Trigger Event:
push
-
Statement type: