Claude
Skills
Sign in
Back

mcp-server-nginx-gatekeeper

Included with Lifetime
$97 forever

Use this skill when the user asks to "add a new MCP server behind nginx", "wire an MCP into the mcp.<domain> vhost", "expose an MCP container with gatekeeper auth", or otherwise needs to put a Streamable-HTTP MCP server (FastMCP or low-level SDK) behind nginx with ByteForge Gatekeeper auth (`auth_request /auth`). Emits a shared `conf.d/snippets/mcp-location.conf` (proxy/auth/streaming headers all in one place) and either a single new `location /mcp-<name>` block (brownfield) or the full umbrella vhost from scratch (greenfield). Includes the `.well-known` OAuth-discovery bypass that forces MCP clients like Claude Code to fall back to bearer-token auth, the `/mcp-<name>` → `/mcp` rewrite trio that makes path-prefix routing survive Streamable-HTTP's absolute upstream endpoint, and the buffering/timeout settings that keep streaming connections alive.

Backend & APIs

What this skill does


# MCP server nginx vhost with Gatekeeper auth

This skill wires a Streamable-HTTP MCP server (FastMCP or low-level SDK) into an nginx vhost that delegates authorization to ByteForge Gatekeeper via `auth_request /auth`.

Two paths:

- **Brownfield** — the umbrella `mcp.<DOMAIN>` vhost already exists and has at least one MCP server under it (the typical "I want to add another MCP" case). The skill appends a new `location /mcp-<NAME>` block, ensures the shared snippet is present, and ensures the `.well-known` bypass is present.
- **Greenfield** — no umbrella vhost exists yet. The skill emits the full `mcp.<DOMAIN>` server block (TLS, resolver, gatekeeper auth subrequest include, `.well-known` bypass, first MCP location), all wired through the same shared snippet.

In both paths, the per-MCP location block is a thin 8-line wrapper that sets `$mcp_upstream` and `$mcp_port`, runs the three rewrites, and `include`s the shared snippet — the heavy lifting (auth_request, proxy headers, buffering, timeouts) lives in `conf.d/snippets/mcp-location.conf` so adding the fourth MCP doesn't mean copy-pasting 30 lines for the fourth time.

Each load-bearing decision is documented in **Traps** below — read that section before editing anything the skill emits.

## When to Use This Skill

- Adding a new MCP server to an existing `mcp.<DOMAIN>` umbrella vhost
- Bringing up the umbrella vhost on a fresh host where MCP servers will live behind gatekeeper auth
- Refactoring an existing `mcp.<DOMAIN>` vhost whose MCP location blocks each duplicate the same proxy/auth/streaming headers
- Debugging why a Claude Code MCP client keeps trying OAuth discovery instead of using the bearer token in `.mcp.json`
- Debugging why `/mcp-<name>/messages/` 404s (you're using SSE behind a path prefix — switch to streamable-http)

## What This Skill Creates

1. **`conf.d/snippets/mcp-location.conf`** — the shared snippet (auth_request, proxy headers, streaming/buffering settings) that every MCP location `include`s
2. **A per-MCP `location /mcp-<NAME>` block** — sets the upstream var and port, runs the three `^/mcp-<NAME>(/.*)?` → `/mcp` rewrites, and `include`s the snippet
3. **(Greenfield only) the full umbrella vhost** — HTTP→HTTPS, TLS 443, resolver, gatekeeper `/auth` include, `.well-known` bypass, first MCP location
4. **(Brownfield, optional) a refactor of existing MCP location blocks** — collapses duplicated 30-line blocks down to 8 lines each that `include` the new snippet
5. **Smoke-test commands** — three curls that prove auth, bypass, and streaming all work
6. **Failure Decoder** — symptom → cause → fix table

## Step 1: Gather Information

Ask the user these before generating anything. Substitute consistently throughout.

1. **"What is the umbrella MCP domain?"** (e.g., `mcp.example.com`)
   - Stored as `<DOMAIN>`

2. **"What short name identifies this MCP server in the URL path?"** (e.g., `loki`, `materia`, `weather`)
   - Stored as `<NAME>` — must be lowercase, no slashes, no path separators
   - Becomes the URL prefix `/mcp-<NAME>`

3. **"What is the Docker service name of the MCP container?"** (e.g., `loki-reader-mcp`)
   - Stored as `<UPSTREAM_CONTAINER>` — must be reachable by hostname on the docker network nginx is attached to

4. **"What port does the MCP container listen on inside the docker network?"** (default: `8000`)
   - Stored as `<UPSTREAM_PORT>`

5. **"Does the umbrella `<DOMAIN>` vhost already exist in nginx?"**
   - If **yes** → brownfield path (Step 2 verifies, Step 4 appends)
   - If **no** → greenfield path (Step 5 emits the full vhost)
   - If unsure, run `grep -rn "server_name <DOMAIN>" /etc/nginx/` (or your nginx config path) to check

6. **"Is the gatekeeper auth snippet at `conf.d/snippets/auth-gatekeeper.conf` already present?"**
   - This skill assumes that snippet defines `location = /auth` (internal subrequest target). If absent, point the user at the `gatekeeper-nginx-setup` skill — that skill is the source of the snippet. Do **not** redefine `/auth` here.

7. **(Brownfield only) "How many MCP location blocks already exist in this vhost, and do they duplicate the same proxy/auth/streaming headers?"**
   - If **0 existing** → just append the new block + the snippet
   - If **1+ existing, inlined** → offer the optional refactor in Step 6
   - Run `grep -n "location /mcp-" <vhost-file>` to enumerate

## Step 2: Verify Prerequisites

Confirm the following are in place. Each maps to a trap in **Traps** below.

1. **Resolver directive** — the snippet uses `proxy_pass http://$mcp_upstream:$mcp_port;` to defer DNS to request time, which requires a `resolver` visible to the server block. Check:
   ```bash
   grep -rn "^\s*resolver " /etc/nginx/
   ```
   - For container nginx on a docker user network: `resolver 127.0.0.11 valid=10s ipv6=off;`. The `ipv6=off` flag is required if any upstream resolved through this resolver is on a dual-stack hostname (Cloudflare-fronted, public-DNS, etc.) — without it, nginx may pick an AAAA record that the IPv4-only docker bridge can't route, and `auth_request` returns 403 across every protected route. Safe to include unconditionally.
   - If missing → proxy_pass fails silently with "no resolver defined" → 502 at request time. Add it once in the server block (greenfield does this) or in nginx.conf's http block.

2. **`auth-gatekeeper.conf` snippet** — must `include` cleanly in the server block and define `location = /auth` (internal). Verify with:
   ```bash
   grep -n "location = /auth" /etc/nginx/conf.d/snippets/auth-gatekeeper.conf
   ```

3. **MCP server transport** — the MCP server MUST be running with `streamable-http` transport, not `sse`. SSE's `/messages/` is a relative URL the SDK hands the client, and a relative URL behind a path prefix (`/mcp-<NAME>`) resolves to `/mcp-<NAME>/messages/` on the client but `/messages/` upstream — guaranteed 404. Streamable-HTTP exposes a single `/mcp` endpoint with no relative-URL gotcha. Confirm with the user before proceeding.

## Step 3: Emit the Shared Snippet

Write `<NGINX_CONFD>/snippets/mcp-location.conf` (where `<NGINX_CONFD>` is the user's `conf.d` location, typically `/etc/nginx/conf.d` or a docker volume mounted there):

```nginx
# Shared MCP location config — included by each `location /mcp-<NAME>` block.
#
# Caller must set the following BEFORE `include`-ing this file:
#   set $mcp_upstream <docker-service-name>;
#   set $mcp_port    <port>;
# and the three rewrites (see per-MCP block).
#
# Why a snippet: every MCP location needs the same ~25 lines of auth/proxy/
# streaming config. A second copy is a typo away from drift; a fourth copy is
# guaranteed to drift. Keep divergent parts (upstream, rewrites) per-location;
# keep invariants here.
#
# Why NOT in conf.d/ root: nginx auto-loads `conf.d/*.conf` at http{} level,
# but this file is location{}-level config (auth_request, proxy_pass, etc.)
# and MUST be explicitly `include`d inside a location{} block. Keeping it
# under conf.d/snippets/ avoids the auto-load — see Trap 7.

auth_request /auth;

auth_request_set $auth_client_id   $upstream_http_x_auth_client_id;
auth_request_set $auth_client_name $upstream_http_x_auth_client_name;
auth_request_set $auth_route_id    $upstream_http_x_auth_route_id;

proxy_pass http://$mcp_upstream:$mcp_port;

proxy_http_version 1.1;
proxy_set_header Host              $host;
proxy_set_header X-Real-IP         $remote_addr;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# Strip the inbound Authorization header before proxying. Gatekeeper already
# authenticated this request (via the /auth subrequest); some MCP servers
# choke on a residual bearer they don't know what to do with. The downstream-
# facing X-Client-* headers below carry the identity instead. See Trap 4.
proxy_set_header Authorization "";

# Authenticated client info from the /auth subrequest, forwarded so the MCP
# upstream can attribute requests without re-validat

Related in Backend & APIs