Skip to main content
Glama

nas-reach-mcp

What is the address of service X, from vantage point Y — and is it up?

A read-only MCP server that answers reachability questions about a Docker host. It reads the live Docker runtime (containers, networks, attachments and published ports) and reports the correct address for the vantage point you name — or, just as importantly, explains precisely why there isn't one.

It is built for a homelab where the same service is reachable at several different addresses depending on where you are standing, and where guessing wrongly is the normal outcome.

Why this exists: a port is absolute, an address is relative

litellm:4000 and nas:4000 can name the same gateway, and neither is the address. Docker's embedded DNS resolves container names on a user-defined network, but the host's own hostname does not resolve inside that network — so:

  • from the host, the address is nas:4000;

  • from a container on the ai_proxy network, it is litellm:4000.

Reachability is therefore a graph query, not a lookup: a container is reachable from a network if and only if it is attached to that network, and multi-homed containers act as bridges between networks. Getting this wrong returns a plausible-looking URL that simply doesn't work — which is exactly the failure this tool exists to prevent.

Related MCP server: hermes-fleet-mcp

The six tools

Tool

Question it answers

whoami

Where am I standing? (this process's own vantage, detected)

list_networks

What networks exist, who is on them, and which containers bridge them?

list_services(from_vantage)

What is reachable from here, and at what address?

resolve_service(name, from_vantage)

Where does this name answer from there — or why not?

check_reachable(name_or_url, from_vantage)

Does it actually answer, right now?

describe_service(name)

What is this thing attached to? (networks, IPs, published ports)

The vantage model

from_vantage is required on resolve_service and check_reachable and is never defaulted — a default is how you get a confidently wrong address. (list_services is the one exception: "everything from where I am" is a complete question on its own.)

Value

Means

host

the Docker host itself, or any network_mode: host context

network:<name>

a container attached to that network

container:<name>

through a specific container (i.e. its networks)

self

this process's own vantage, detected

A bare word (ai_proxy) is read as a network name.

Answers are specific, not empty

resolve_service always answers with a reason. Returning nothing is a failure mode, so a negative result names what it checked and points at where the service is reachable:

  • resolved — a candidate address exists for that vantage

  • host_only — runs network_mode: host; no name on any network, so it is reachable by host port or not at all

  • not_attached — exists and has networks, but not this one (the name will not resolve there; a bridge IP may still answer via host routing)

  • unresolvable_name — no such container (DSM packages and host processes are invisible to the Docker API)

  • unknown_port — attached and nameable, but no port is visible to the API

  • ambiguous — two containers share the name; guessing would be wrong

  • unknown_network / unknown_container — the vantage itself doesn't exist

check_reachable never collapses failures into an opaque 000. curl reports one status code for several different diseases, so they are separated: unresolvable_name (the name never resolved), refused (port actively refused), timeout (filtered), unreachable (no route), or ok. A successful probe also reports the HTTP status, Server header and latency, and — on an MCP path — serverInfo from a single initialize handshake, because an HTTP 200 from the wrong service is worse than a 404.

Nothing is cached: every call re-reads runtime state, and every response is stamped verified_at.

The full tool contract lives in docs/tools.md.

Deployment

The server is designed to be deployed to the Docker host itself, because it needs the host's view of the runtime:

  • Network mode: host — so the host vantage is genuinely the host and host-network containers are visible. The trade-off: this process has no name on any Docker network, so it can only resolve host names and ports.

  • /var/run/docker.sock mounted read-only (note: a read-only mount does not make the socket read-only — it is root-equivalent either way).

  • Port 3010 — mcpo wraps the stdio server as Streamable HTTP, served at the root (/openapi.json, /docs).

  • Registered in MCPHub as slug nas-reach (type openapi, group ops) and surfaced on the Homepage dashboard.

See deploy/README.md for the full runbook (Buildkite → GHCR → Watchtower → Docker host, plus rollback).

docker compose up -d

Configuration

Variable

Default

Purpose

NAS_REACH_HOSTNAME

nas

The host name to use when composing host-vantage addresses

DOCKER_SOCKET

/var/run/docker.sock

Docker API socket path

NAS_REACH_SELF_CONTAINER

(unset)

Override self-container detection when hostname matching fails

The MCPHUB_* variables in the generated .env.example / compose file are genproj scaffolding and are not read by this code.

Known limitation

Because it runs network_mode: host, check_reachable probes from the host vantage. It confirms a host-vantage answer directly; for a network vantage it probes the container's bridge IP (which is what actually answers) while reporting the name-based address the caller would use, and it sets confirms_requested_vantage: false rather than implying success. Truly confirming from a container vantage requires running the probe inside that vantage (e.g. an ephemeral container on the target network).

Relationship to nas-port-mcp

This is a sibling to nas-port-mcp. nas-port-mcp answers which host port maps to which container; nas-reach-mcp answers the harder, vantage-relative question — what address works from where, and does it answer. The two complement each other.

Development

python3 -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"

Run the checks (the exact sequence CI runs):

ruff check src tests
pytest -q

CI runs on Buildkite (see .buildkite/); the pipeline's docker_publish step builds the linux/amd64 image and pushes it to ghcr.io/nickbrett1/nas-reach-mcp.

Doppler

This project uses Doppler for secrets from the shared common project (config dev) — no per-repo Doppler project is created. First use:

doppler setup --project common --config dev

The Doppler CLI is installed in the devcontainer; auth is persisted via the host ~/.doppler bind-mount.

Env-var precedence (read this if doppler run hits the wrong project)

Doppler resolves its target as environment variables > doppler.yaml > ~/.doppler scoped config. If your shell — or the session that launched the devcontainer (e.g. an agent runtime) — exports DOPPLER_PROJECT / DOPPLER_CONFIG / DOPPLER_ENVIRONMENT, those silently override this repo's doppler.yaml. To force the correct context manually:

unset DOPPLER_PROJECT DOPPLER_CONFIG DOPPLER_ENVIRONMENT
doppler setup --no-interactive --project common --config dev

The container's agent

This devcontainer brings up its own a2a-goose agent, registered in the hub as nas-reach-mcp-dev — one agent per repo, so a restart reclaims the same entry instead of adding a second one. Turns are billed through the LiteLLM proxy configured in Doppler (LITELLM_BASE_URL).

scripts/agent-dev.sh start    # write secrets + config, fetch the launcher, run it
scripts/agent-dev.sh status   # running or not, the card URL, the log tail
scripts/agent-dev.sh stop     # SIGTERM, wait for a clean deregister, confirm gone

start runs from the devcontainer's post-start hook, so the agent is normally already up when you arrive. It fails open: with no network on a first start it prints why it did not start and leaves the project usable. Secrets come from Doppler into ~/.config/a2a-goose/env (mode 0600) and never into the image or containerEnv.

Generated by genproj

This project was scaffolded with genproj; the application code above is hand-written.

Related MCP Connectors

Related MCP Servers