Skip to main content

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 verify per scope — point the global default at your internal CA so the whole *.home.lan fleet 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? subwire still 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.lan covers dozzle.home.lan but not the apex home.lan nor a.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 verify at 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:

  1. deny_hosts glob match → refused.
  2. http:// with allow_http: false → refused.
  3. method not permitted (read-only mode and/or target allowlist) → refused.
  4. 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

subwire-1.0.0.tar.gz (30.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

subwire-1.0.0-py3-none-any.whl (24.8 kB view details)

Uploaded Python 3

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

Hashes for subwire-1.0.0.tar.gz
Algorithm Hash digest
SHA256 6ef1c8900a279c6556cdd4a32b33471e64ddb0c2e569bfdcf67faefe8ac2d9ea
MD5 f6188561561690406be99f635e93e89f
BLAKE2b-256 8e529465d363d5aba14580ca3823b417ead4244e20aa89890a8b795a3859b38a

See more details on using hashes here.

Provenance

The following attestation bundles were made for subwire-1.0.0.tar.gz:

Publisher: publish.yml on corundex/subwire

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for subwire-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 413c3763beecd8a14943a685020a9f36e75d305ceb9028fc35706dab062ec101
MD5 3a943329e4e3603fdbf130c00c3078df
BLAKE2b-256 5a97a275c407c7a3509300beb4974ba05ecf852cf6dbe8607c0cf85c01540029

See more details on using hashes here.

Provenance

The following attestation bundles were made for subwire-1.0.0-py3-none-any.whl:

Publisher: publish.yml on corundex/subwire

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page