Mcp server nginx gatekeeper
Skill jmazzahacks/byteforge-claude-skills/skills/mcp-server-nginx-gatekeeper
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.From its SKILL.md
npx -y skills add jmazzahacks/byteforge-claude-skills --skill mcp-server-nginx-gatekeeperAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
25.6 KB, as published. Nobody here has run it
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 newlocation /mcp-<NAME>block, ensures the shared snippet is present, and ensures the.well-knownbypass is present. - Greenfield — no umbrella vhost exists yet. The skill emits the full
mcp.<DOMAIN>server block (TLS, resolver, gatekeeper auth subrequest include,.well-knownbypass, 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 includes 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
conf.d/snippets/mcp-location.conf— the shared snippet (auth_request, proxy headers, streaming/buffering settings) that every MCP locationincludes- A per-MCP
location /mcp-<NAME>block — sets the upstream var and port, runs the three^/mcp-<NAME>(/.*)?→/mcprewrites, andincludes the snippet - (Greenfield only) the full umbrella vhost — HTTP→HTTPS, TLS 443, resolver, gatekeeper
/authinclude,.well-knownbypass, first MCP location - (Brownfield, optional) a refactor of existing MCP location blocks — collapses duplicated 30-line blocks down to 8 lines each that
includethe new snippet - Smoke-test commands — three curls that prove auth, bypass, and streaming all work
- Failure Decoder — symptom → cause → fix table
Step 1: Gather Information
Ask the user these before generating anything. Substitute consistently throughout.
-
"What is the umbrella MCP domain?" (e.g.,
mcp.example.com)- Stored as
<DOMAIN>
- Stored as
-
"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>
- Stored as
-
"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
- Stored as
-
"What port does the MCP container listen on inside the docker network?" (default:
8000)- Stored as
<UPSTREAM_PORT>
- Stored as
-
"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
-
"Is the gatekeeper auth snippet at
conf.d/snippets/auth-gatekeeper.confalready present?"- This skill assumes that snippet defines
location = /auth(internal subrequest target). If absent, point the user at thegatekeeper-nginx-setupskill — that skill is the source of the snippet. Do not redefine/authhere.
- This skill assumes that snippet defines
-
(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.
-
Resolver directive — the snippet uses
proxy_pass http://$mcp_upstream:$mcp_port;to defer DNS to request time, which requires aresolvervisible to the server block. Check:grep -rn "^\s*resolver " /etc/nginx/- For container nginx on a docker user network:
resolver 127.0.0.11 valid=10s ipv6=off;. Theipv6=offflag 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, andauth_requestreturns 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.
- For container nginx on a docker user network:
-
auth-gatekeeper.confsnippet — mustincludecleanly in the server block and definelocation = /auth(internal). Verify with:grep -n "location = /auth" /etc/nginx/conf.d/snippets/auth-gatekeeper.conf -
MCP server transport — the MCP server MUST be running with
streamable-httptransport, notsse. 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/mcpendpoint 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):
# 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-validating the token.
proxy_set_header X-Client-ID $auth_client_id;
proxy_set_header X-Client-Name $auth_client_name;
proxy_set_header X-Route-ID $auth_route_id;
# Streamable-HTTP requires these. Without `proxy_buffering off`, nginx
# accumulates the chunked response and the client sees 60 seconds of silence
# followed by a wall of events. The 3600s timeouts keep idle-but-healthy
# streaming connections from being killed by nginx's 60s defaults.
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
This file is written once per host. Subsequent MCP additions just include it.
Step 4: Add the Per-MCP Location Block
For brownfield (umbrella vhost exists): append inside the server { server_name <DOMAIN>; ... } block, after the .well-known bypass.
For greenfield: this block is part of Step 5's full vhost.
# <NAME> MCP server
location /mcp-<NAME> {
set $mcp_upstream <UPSTREAM_CONTAINER>;
set $mcp_port <UPSTREAM_PORT>;
rewrite ^/mcp-<NAME>$ /mcp break;
rewrite ^/mcp-<NAME>/$ /mcp break;
rewrite ^/mcp-<NAME>/(.+) /$1 break;
include /etc/nginx/conf.d/snippets/mcp-location.conf;
}
Three rewrites, not one, on purpose — see Trap 2.
If this is the first MCP being added to a brand-new umbrella vhost (or a brownfield vhost that somehow lacks the bypass), also ensure the .well-known bypass is present, somewhere before the per-MCP location block:
# OAuth discovery bypass. MCP clients (Claude Code, others) probe
# `<DOMAIN>/.well-known/oauth-authorization-server` to decide whether to
# negotiate OAuth or fall back to the static bearer token in their config.
# Returning 404 unauthenticated forces the bearer-token path, which is what
# we want — Gatekeeper does the auth, the MCP server stays dumb. See Trap 3.
location ~ ^/mcp-[^/]+/\.well-known/ {
return 404;
}
This regex location only needs to exist once per umbrella vhost — it matches every /mcp-*/.well-known/... path. The brownfield path verifies it's already there with grep -n "well-known" <vhost-file>; if missing, add it once before any location /mcp-*.
Step 5 (Greenfield only): Emit the Full Umbrella vhost
If no mcp.<DOMAIN> vhost exists yet, emit this entire server block. Adjust SSL cert paths to the host's nginx convention.
# HTTP -> HTTPS redirect
server {
listen 80;
server_name <DOMAIN>;
return 301 https://$host$request_uri;
}
# HTTPS umbrella vhost for all MCP servers
server {
listen 443 ssl;
server_name <DOMAIN>;
# Docker DNS resolver — prevents stale IP caching when MCP containers
# restart. The variable-form `proxy_pass http://$mcp_upstream:$mcp_port`
# in snippets/mcp-location.conf REQUIRES this. Without it, nginx fails
# with "no resolver defined" at request time → 502. See Trap 5.
#
# ipv6=off: if the auth subrequest or any proxy target is on a dual-stack
# public hostname (Cloudflare-fronted, etc.), nginx may pick the AAAA
# record and connect() over IPv6 — but a default docker bridge is IPv4-
# only, so the subrequest fails with ENETUNREACH → 403 to client.
resolver 127.0.0.11 valid=10s ipv6=off;
ssl_certificate /etc/nginx/ssl/<DOMAIN>.crt;
ssl_certificate_key /etc/nginx/ssl/<DOMAIN>.key;
ssl_protocols TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
# Optional ByteForge stack snippets — include if the host already uses them.
# include /etc/nginx/conf.d/snippets/cloudflare-real-ip.conf;
# include /etc/nginx/conf.d/snippets/scanner-blocklist.conf;
# Defines `location = /auth` (internal) → gatekeeper /authz subrequest.
# Source: the `gatekeeper-nginx-setup` skill. Do NOT redefine /auth here.
include /etc/nginx/conf.d/snippets/auth-gatekeeper.conf;
# OAuth discovery bypass — see Trap 3 for why this returns 404, not 401.
location ~ ^/mcp-[^/]+/\.well-known/ {
return 404;
}
# First MCP server — copy this block (adjusting <NAME>, upstream, port)
# for each additional MCP.
location /mcp-<NAME> {
set $mcp_upstream <UPSTREAM_CONTAINER>;
set $mcp_port <UPSTREAM_PORT>;
rewrite ^/mcp-<NAME>$ /mcp break;
rewrite ^/mcp-<NAME>/$ /mcp break;
rewrite ^/mcp-<NAME>/(.+) /$1 break;
include /etc/nginx/conf.d/snippets/mcp-location.conf;
}
}
Step 6 (Brownfield, optional): Refactor existing inline MCP blocks
Apply this only if the user has 1+ existing MCP location blocks that inline the proxy/auth/streaming headers (the duplication this skill exists to remove). Skip if existing blocks already include the snippet.
For each existing inline block:
Before (~30 lines):
location /mcp-foo {
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;
set $upstream_foo_mcp foo-container;
rewrite ^/mcp-foo$ /mcp break;
rewrite ^/mcp-foo/$ /mcp break;
rewrite ^/mcp-foo/(.+) /$1 break;
proxy_pass http://$upstream_foo_mcp:8000;
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;
proxy_set_header Authorization "";
proxy_set_header X-Client-ID $auth_client_id;
proxy_set_header X-Client-Name $auth_client_name;
proxy_set_header X-Route-ID $auth_route_id;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
After (~8 lines):
location /mcp-foo {
set $mcp_upstream foo-container;
set $mcp_port 8000;
rewrite ^/mcp-foo$ /mcp break;
rewrite ^/mcp-foo/$ /mcp break;
rewrite ^/mcp-foo/(.+) /$1 break;
include /etc/nginx/conf.d/snippets/mcp-location.conf;
}
Important: the snippet uses generic var names $mcp_upstream / $mcp_port. If existing blocks used per-MCP names like $upstream_foo_mcp, rename to $mcp_upstream during refactor. nginx variables are per-request, evaluated inside the matched location — reusing $mcp_upstream across sibling location blocks is fine; each request only enters one of them.
Confirm with the user before refactoring existing blocks. The before/after is mechanical, but it's a working production config — show the diff and reload with nginx -t before nginx -s reload.
Step 7: Reload and Verify
# Inside the nginx container (or on the host, depending on layout)
nginx -t # syntax check
nginx -s reload # apply
Smoke tests (from outside the host):
# 1. Auth required — without a bearer token, gatekeeper /auth returns 401.
curl -i https://<DOMAIN>/mcp-<NAME>
# 2. OAuth discovery bypass — should return 404 (not 401), causing Claude Code
# to fall back to the static bearer in .mcp.json instead of negotiating OAuth.
curl -i https://<DOMAIN>/mcp-<NAME>/.well-known/oauth-authorization-server
# 3. Authenticated MCP request — should return a JSON-RPC initialize response.
curl -i -H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \
https://<DOMAIN>/mcp-<NAME>
Then add to .mcp.json on the client:
{
"mcpServers": {
"<NAME>": {
"type": "http",
"url": "https://<DOMAIN>/mcp-<NAME>",
"headers": { "Authorization": "Bearer <TOKEN>" }
}
}
}
Note: "type": "http", not "sse". The transport on the wire is Streamable-HTTP, even when the underlying SDK class is named ambiguously.
Traps
These are the load-bearing decisions in the emitted config. They look stylistic; they aren't.
Trap 1 — Streamable-HTTP, not SSE
The MCP SDK's SSE transport mounts two endpoints: /sse (the EventSource) and /messages/ (the POST sink). The SDK hands the client a relative URL for /messages/ on connect. Behind a path prefix like /mcp-<NAME>, the client resolves that relative URL to /mcp-<NAME>/messages/, but nothing upstream is mounted there (the SDK mounted /messages/). Result: every client POST 404s.
Streamable-HTTP exposes a single /mcp endpoint with no client-side URL discovery, so path-prefix routing just works.
Fix: configure the MCP server with MCP_TRANSPORT=streamable-http and tell the client "type": "http". Do not try to fix this with nginx rewrites — the relative URL is built in the client.
Trap 2 — The three rewrites
location /mcp-<NAME> matches /mcp-<NAME>, /mcp-<NAME>/, and /mcp-<NAME>/anything. Without rewrites, the proxy_pass forwards the path unmodified — but the upstream only mounts /mcp. You need to map all three forms.
Order matters: nginx evaluates rewrites top to bottom, and break stops further rewrite processing in the location. The literal-match rewrites for ^/mcp-<NAME>$ and ^/mcp-<NAME>/$ come first; the greedy ^/mcp-<NAME>/(.+) comes last and strips the prefix off any subpath. Subpaths the upstream doesn't recognize will 404 from the SDK, which is the right behavior — /mcp-<NAME>/foo was never going to mean anything.
Trap 3 — .well-known bypass returns 404, not 401
If you protect /mcp-<NAME>/.well-known/oauth-authorization-server with auth_request /auth, gatekeeper returns 401 for the unauthenticated probe. Claude Code interprets 401 on a .well-known/oauth-* URL as "OAuth is available — let's negotiate" and starts an OAuth flow that will never complete, because there's no OAuth provider behind this endpoint — just gatekeeper bearer auth.
Returning 404 (no auth_request on this regex) tells the client "no OAuth here", and it falls back to the static bearer in .mcp.json.
This is why the regex location ~ ^/mcp-[^/]+/\.well-known/ lives outside any auth_request directive, and is matched before the per-MCP prefix locations (regex locations beat prefix locations in nginx).
Trap 4 — Strip the inbound Authorization header
After auth_request /auth succeeds, the original request still carries the client's Authorization: Bearer <token> header. Most MCP servers don't expect to see it (they trust the gatekeeper-injected X-Client-* headers) and some choke on it outright. proxy_set_header Authorization ""; clears it before proxying.
If the upstream MCP server does need the original bearer (rare — usually only if it's doing its own auth on top), remove this line for that MCP only and document the deviation in the per-MCP location block. Do not delete it from the snippet — that would silently change behavior for every MCP that depends on the strip.
Trap 5 — Resolver required for variable proxy_pass
The snippet uses proxy_pass http://$mcp_upstream:$mcp_port;. The variable form forces nginx to resolve the upstream at request time, not at config-load time. That's exactly what we want — MCP containers restart and get new IPs on the docker network, and a static proxy_pass http://foo-container:8000 would cache the first IP forever.
But variable form requires a resolver directive. Without it, nginx fails with "no resolver defined" at request time → 502 to the client, "host not found" in error.log. The greenfield vhost includes resolver 127.0.0.11 valid=10s ipv6=off (docker's embedded DNS). The brownfield path verifies one exists.
Add ipv6=off to the resolver line if any upstream is on a dual-stack hostname (e.g., Cloudflare-fronted public services like a remote gatekeeper). Without it, nginx may pick the AAAA result, try connect() over IPv6, and fail with ENETUNREACH because the default docker bridge is IPv4-only. The failure mode is especially nasty: every protected route returns 403, including routes that were working seconds earlier, because the resolver picks IPv6 non-deterministically. The diagnosis is in error.log (Network is unreachable ... subrequest: "/auth"), not in the access log or gatekeeper logs.
Trap 6 — Buffering off, timeouts long
proxy_buffering off and proxy_request_buffering off are non-negotiable for Streamable-HTTP. With buffering on, nginx accumulates response chunks until either the buffer fills or the upstream closes the stream — the client sees a long silence followed by a wall of events. The whole point of streaming is incremental delivery.
proxy_read_timeout 3600s and proxy_send_timeout 3600s survive long idle periods on a stream. nginx's defaults (60s) will kill a perfectly healthy streaming connection that happens to go quiet for a minute.
Trap 7 — Snippet lives under conf.d/snippets/, not conf.d/
nginx auto-loads conf.d/*.conf at the http{} level. The MCP snippet is location{}-level config — auth_request, proxy_pass, proxy_set_header are not valid at http scope. If you put mcp-location.conf directly in conf.d/, nginx will fail to start with "X" directive is not allowed here.
The snippets/ subdirectory keeps the snippet outside auto-loading, and each location block explicitly includes it. This is the same pattern used by auth-gatekeeper.conf, cloudflare-real-ip.conf, and scanner-blocklist.conf in the ByteForge stack.
Failure Decoder
| Symptom | Likely Cause | Fix |
|---|---|---|
nginx: [emerg] "auth_request" directive is not allowed here on reload | Snippet placed in conf.d/ root (auto-loaded into http{} scope) | Move to conf.d/snippets/; ensure no *.conf glob picks it up. See Trap 7 |
502 Bad Gateway immediately on first request; error log says no resolver defined to resolve | Missing resolver directive | Add resolver 127.0.0.11 valid=10s; to the server block. See Trap 5 |
502 Bad Gateway shortly after container restart, was working before | Cached pre-restart IP — valid=10s should expire within seconds; or upstream is genuinely down | docker ps to confirm container is up; docker logs <UPSTREAM_CONTAINER> for crashes |
403 Forbidden from nginx on every /mcp-* route, including ones that worked minutes ago. error.log shows connect() to [<v6>]:... failed (101: Network is unreachable) ... subrequest: "/auth" | Resolver missing ipv6=off; auth/proxy upstream is dual-stack but docker bridge is IPv4-only | Add ipv6=off to the resolver directive. See Trap 5 |
401 Unauthorized even with what should be a valid bearer token | Token doesn't match a gatekeeper-registered route, or auth-gatekeeper.conf not included in this vhost | Confirm include …/auth-gatekeeper.conf is inside the server block; check gatekeeper's route + token config |
404 Not Found from upstream on POST after SSE connect | Using SSE transport behind a path prefix — /messages/ resolved relative on the client | Switch server + client to streamable-http. See Trap 1 |
Claude Code stuck in OAuth-discovery loop, never uses the bearer in .mcp.json | .well-known returns 401 (protected) instead of 404 | Verify the ~ ^/mcp-[^/]+/\.well-known/ location is present and returns 404 unconditionally. See Trap 3 |
| Stream connects, then 60 seconds of silence, then everything arrives at once | proxy_buffering not off, or proxy_read_timeout at default | Confirm snippet is included in the location block. See Trap 6 |
| MCP server returns "missing required header" or rejects the request | Either Authorization not stripped (upstream confused by stale bearer) OR X-Client-* headers not arriving | Confirm proxy_set_header Authorization ""; and the three X-Client-* lines are present in the snippet. See Trap 4 |
| Two MCP locations route to the same upstream | Copy-paste bug — both blocks set $mcp_upstream to the same value | Each location's set is location-scoped; check the literal value in each block |
nginx -t says unknown "mcp_upstream" variable | A proxy_pass http://$mcp_upstream:$mcp_port; somewhere outside a location block (e.g., directly in server{}) — set only works inside location{} | Move the upstream/port set lines and the include into a location {} block |
Marketplace Registration
If you're adding this skill to a marketplace repo (e.g., byteforge-claude-skills), append the skill path to .claude-plugin/marketplace.json:
"skills": [
...,
"./skills/mcp-server-nginx-gatekeeper"
]
And update the plugin's umbrella description to mention "MCP server nginx vhost with Gatekeeper auth" alongside the other skills.