Skip to main content
Glama
cociweb

MCP Gateway

by cociweb
README.md
# MCP Gateway

A [FastMCP](https://gofastmcp.com) 2.x-based gateway that aggregates multiple
upstream [Model Context Protocol](https://modelcontextprotocol.io) servers
(stdio, streamable-http, sse, websocket) behind a single streamable-HTTP
endpoint. Upstream servers are configured declaratively in
`config/mcpServers.json`, with per-server namespacing, tool/resource/prompt
filtering, read-only enforcement, environment-variable interpolation, and
automatic reload on config changes.

## Architecture

```
gateway/
├── main.py             # entrypoint: builds & serves the gateway, wires reload
├── config.py            # GatewayConfig, loaded from environment variables
├── loader.py             # mcpServers.json parsing, env interpolation, validation
├── proxy.py              # builds the aggregated FastMCP server (mount per upstream)
├── namespace.py          # per-server middleware: toolFilter/resourceFilter/promptFilter, readOnly
├── reconnect.py           # retry-with-backoff helper used for upstream connectivity probes
├── reload.py              # watchdog-based watcher for mcpServers.json
├── health.py              # /health, /healthz, /metrics routes
├── logging.py             # logging setup
└── transports/
    ├── stdio.py            # spawns local processes (npx, uvx, ...)
    ├── streamable_http.py  # streamable-HTTP upstream client transport
    ├── sse.py              # SSE upstream client transport
    └── websocket.py        # WebSocket upstream client transport (custom, mcp SDK based)
```

Each enabled upstream server becomes a FastMCP proxy sub-server
(`FastMCPProxy` with an explicit `client_factory` that builds a fresh
transport per call, for concurrent-request isolation), gets a
`ServerFilterMiddleware` attached
(enforcing `readOnly` / `toolFilter` / `resourceFilter` / `promptFilter`), and
is mounted onto the main gateway server with `namespace=<namespace>` when
`prefixTools` is enabled. Tool/resource/prompt names are then automatically
namespaced by FastMCP as `{namespace}_{name}`.

## Configuration

### `config/mcpServers.json`

```json
{
  "mcpServers": {
    "filesystem": {
      "type": "stdio",
      "enabled": true,
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "${FS_ROOT:-/data}"],
      "env": {},
      "namespace": "fs",
      "readOnly": false,
      "prefixTools": true,
      "toolFilter": ["*"],
      "resourceFilter": ["*"],
      "promptFilter": ["*"],
      "timeout": 30
    }
  }
}
```

Field reference per server entry:

| Field | Type | Description |
|---|---|---|
| `type` | `stdio` \| `streamable-http` \| `sse` \| `websocket` | Upstream transport kind |
| `enabled` | bool | If `false`, the server is skipped entirely |
| `url` | string | Required for `streamable-http` / `sse` / `websocket` |
| `command`, `args`, `env` | - | Required for `stdio` (e.g. `npx`, `uvx`) |
| `headers` | object | Extra HTTP headers for `streamable-http` / `sse` |
| `namespace` | string | Prefix used for this server's tools/resources/prompts; defaults to the server's key |
| `readOnly` | bool | If `true`, **no tools** from this server are registered |
| `prefixTools` | bool | If `true`, tools become `{namespace}_{tool}` |
| `toolFilter` / `resourceFilter` / `promptFilter` | list of glob patterns | `fnmatch`-style; `["*"]` = allow all |
| `timeout` | number | Per-call timeout (seconds) for the upstream connection probe |

Any string value in the file supports `${VAR}` / `${VAR:-default}`
interpolation, resolved against the process environment (`.env` +
`docker-compose.yml` `environment:`). An unresolved variable without a
default raises a configuration error for *enabled* servers; disabled server
entries with unresolved variables are tolerated (logged as a warning).

### Environment variables (`.env` / `docker-compose.yml`)

| Variable | Default | Description |
|---|---|---|
| `GATEWAY_LISTEN` | `0.0.0.0` | Bind address (`0.0.0.0`, `127.0.0.1`, `localhost` supported) |
| `GATEWAY_PORT` | `5555` | Bind port |
| `GATEWAY_PATH` | `/mcp` | HTTP path for the MCP streamable-HTTP endpoint |
| `GATEWAY_RELOAD` | `true` | Watch `mcpServers.json` and restart the process on change |
| `GATEWAY_HEALTH_ENDPOINT` | `true` | Enable `/health` and `/healthz` |
| `GATEWAY_METRICS` | `true` | Enable `/metrics` (Prometheus text format) |
| `GATEWAY_LOG_LEVEL` | `info` | `debug` \| `info` \| `warning` \| `error` \| `critical` |
| `GATEWAY_PREFIX_TOOLS` | `true` | Default for `prefixTools` when a server entry omits it |
| `MCP_SERVERS_CONFIG` | `/app/config/mcpServers.json` | Path to the servers config file |

## Running locally

```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
cp .env .env.local   # adjust as needed
export $(grep -v '^#' .env.local | xargs)
python -m gateway.main
```

The gateway will be reachable at `http://localhost:5555/mcp`, with
`http://localhost:5555/health` and `http://localhost:5555/metrics` alongside it.

### stdio upstream servers

`stdio` servers (`npx`, `uvx`, ...) are spawned as subprocesses *inside* the
gateway process/container — no separate container or network hop is needed.
Make sure `node`/`npx` and/or `uv`/`uvx` are available in the runtime
environment (the provided `Dockerfile` installs both).

## Docker Compose

```bash
docker compose up -d --build
```

This starts only the gateway service, on the `mcp` Docker network, mounting
`./config` read-only and loading `./.env`. To use the published image instead
of building locally:

```bash
docker pull ghcr.io/cociweb/mcp-gateway:latest
docker compose up -d
```

## Reload behavior

When `GATEWAY_RELOAD=true`, a `watchdog` observer watches the directory
containing `mcpServers.json`. On any change to that file, the gateway
gracefully restarts itself in-place (`os.execv`) to rebuild the full mounted
server tree (reconnect upstreams, reapply filters/prefixes). Streamable-HTTP
clients are expected to reconnect automatically on disconnect.

## Health & metrics

- `GET /health` — JSON status, per-server connectivity and config summary.
- `GET /healthz` — plain-text `ok` liveness probe.
- `GET /metrics` — Prometheus-style text exposition (uptime, enabled/connected
  server counts, per-server connectivity gauge).

## Development / testing

```bash
pip install -e ".[dev]"
pytest -q
```

`tests/test_proxy_integration.py` spins up a real `npx`-based MCP filesystem
server and is skipped automatically if `npx` is not available.

## CI / GHCR

`.github/workflows/ghcr.yml` builds a multi-platform (`linux/amd64`,
`linux/arm64`) image and pushes it to `ghcr.io/cociweb/mcp-gateway` on pushes
to `main` and on version tags.