Metadata-Version: 2.4
Name: subwire
Version: 1.0.0
Summary: A configurable HTTP/REST MCP server for homelab and external APIs.
Project-URL: Homepage, https://github.com/Corundex/subwire
Project-URL: Issues, https://github.com/Corundex/subwire/issues
License: MIT License
        
        Copyright (c) 2026 Anatoly Butko
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: api-client,homelab,http,mcp,model-context-protocol,rest
Requires-Python: >=3.11
Requires-Dist: certifi>=2024.2.2
Requires-Dist: httpx>=0.27
Requires-Dist: mcp>=1.2.0
Requires-Dist: pyyaml>=6.0
Description-Content-Type: text/markdown

# 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](#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):

```bash
git clone https://github.com/Corundex/subwire && cd subwire
make install          # = pip install .
```

Or, once published to PyPI:

```bash
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):

```bash
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)

```bash
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](#connecting-it)).

### 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:

```bash
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](#connecting-it)) — `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:

```bash
# 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):**

```json
{
  "mcpServers": {
    "subwire": {
      "command": "subwire",
      "args": ["--config", "/path/to/config.yaml"]
    }
  }
}
```

**Remote (HTTP server on your LAN, via `mcp-remote`):**

```json
{
  "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`](https://github.com/Corundex/subwire/blob/main/config.example.yaml) for an annotated copy.

```yaml
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:

```yaml
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.

```yaml
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:

```bash
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/`](https://github.com/Corundex/subwire/tree/main/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.

```bash
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`](https://github.com/Corundex/subwire/blob/main/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:

```bash
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/`](https://github.com/Corundex/subwire/tree/main/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](#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](https://github.com/Corundex/subwire/blob/main/LICENSE).
