@isanjosgon/mcp-gateway
v0.1.3
Published
MCP Streamable HTTP gateway with auth, policy, rate limiting and routing.
Maintainers
Readme
mcp-gateway
A production-minded MCP Streamable HTTP gateway for Node.js: auth, policy, rate limiting, audit logs, and HTTP→HTTP proxying to one or more upstream MCP servers.
Use it to keep your upstream MCP servers private (only reachable from the gateway) while exposing a single controlled endpoint to clients.
Why
Running MCP servers in production usually needs more than “it works”:
- One public endpoint instead of exposing many MCP servers
- Centralized auth and least-privilege access control
- Rate limiting to protect upstreams and manage cost
- Auditable logs for traceability and compliance
- Clean routing across multiple MCP upstreams
Features
- MCP Streamable HTTP endpoint (
POST/GET/DELETE) - Routes requests to multiple upstream MCP servers by:
- MCP method (e.g.
tools/call,tools/list,resources/read,prompts/get) - tool name (
params.name) - resource URI (
params.uri) - prompt name (
params.name)
- MCP method (e.g.
- API key auth with
tenant+clientidentity viaBearer,Api-Key, or API key headers - Policy engine (deny-by-default, allow/deny using glob patterns)
- Rate limiting per tenant/client (with per-method overrides, in-memory or Redis-backed)
- Optional per-upstream API key authentication
- Audit logs (structured, consistent logging)
- SSE passthrough (
text/event-stream) when upstream returns it - HTTP health endpoints (
GET /healthzandGET /health) - Docker Compose-friendly setup (upstreams can remain unexposed to the host)
Requirements
- Node.js >= 20 (Node 20+ recommended)
Install
Global
npm i -g @isanjosgon/mcp-gatewayProject-local
npm i @isanjosgon/mcp-gatewayQuick start
1) Create a config file
Create config.yml:
server:
host: 0.0.0.0
port: 8080
path: /mcp
allowedOrigins:
- "http://localhost:3000"
auth:
mode: apiKey
apiKeys:
- id: "local-dev-key"
# Use keyHash in production. Plain key is convenient for local development.
key: "dev_key_1"
# keyHash: "sha256:REPLACE_WITH_SHA256_HEX_OF_THE_API_KEY"
tenant: "client"
client: "local-dev"
rateLimit:
# Set REDIS_URL=redis://... to share limits across gateway instances.
# Without REDIS_URL, the gateway uses in-memory rate limit buckets.
# Override keyPrefix with RATE_LIMIT_KEY_PREFIX to separate environments.
keyPrefix: "mcp-gateway"
defaultRpm: 600
byMethod:
"tools/call": 120
policy:
default: deny
rules:
- subject:
tenant: "client"
client: "local-dev"
allow:
methods:
- "initialize"
- "tools/list"
- "tools/call"
- "resources/list"
- "resources/read"
- "prompts/list"
- "prompts/get"
tools: ["*"]
resources: ["*"]
prompts: ["*"]
upstreams:
- name: mcp-local
type: http
url: "http://mcp-dummy:9000/mcp"
timeoutMs: 30000
# Optional upstream authentication. This is generated by the gateway and is
# separate from the client credentials used to authenticate at the gateway.
# auth:
# type: apiKey
# apiKey:
# header: "Authorization"
# value: "Api-Key ${MCP_LOCAL_API_KEY}"
upstreamHeaders:
# Only these request headers are forwarded to upstream MCP servers.
# Gateway credentials such as Authorization, X-API-Key, and Api-Key are never forwarded.
forward:
- "accept"
- "content-type"
- "mcp-session-id"
- "mcp-protocol-version"
- "last-event-id"
routing:
- match: { method: "*" }
upstream: "mcp-local"
audit:
enabled: true
# Environment is resolved from MCP_GATEWAY_ENV, then NODE_ENV, then "development".
# Use ["*"] to log audit events in all environments.
environments: ["production", "staging"]
logging:
level: "info"
redactKeys:
- "authorization"
- "x-api-key"
- "api-key"
- "token"
- "access_token"
- "password"
- "secret"API keys can be sent using any of these request headers:
Authorization: Bearer dev_key_1
Authorization: Api-Key dev_key_1
X-API-Key: dev_key_1
Api-Key: dev_key_1For production configs, prefer keyHash over storing plaintext keys. The
expected format is sha256:<hex>. id is optional but recommended because it
appears in audit logs as apiKeyId without exposing the secret.
2) Run
Global install:
mcp-gateway run -c config.ymlLocal install:
npx @isanjosgon/mcp-gateway run -c config.ymlGateway will listen on:
http://localhost:8080/mcp
The process handles SIGTERM and SIGINT with graceful shutdown: Fastify stops
accepting traffic, open resources such as Redis rate-limit connections are
closed, and the process exits after shutdown completes.
Health endpoints are available without gateway API-key auth:
http://localhost:8080/healthzhttp://localhost:8080/health
Gateway-level failures on POST /mcp, such as auth, policy, origin, and
rate-limit rejections, return JSON-RPC error objects while preserving the HTTP
status code.
Audit logs are enabled by default. The active environment is resolved from
MCP_GATEWAY_ENV, then NODE_ENV, then development. Use
audit.environments to choose where audit events are emitted, for example
["production", "staging"], or ["*"] for all environments.
Rate limiting uses in-memory buckets by default. Set REDIS_URL, for example
REDIS_URL=redis://localhost:6379, to use Redis-backed buckets shared across
gateway instances. If REDIS_URL is set and Redis cannot be reached, startup
fails instead of silently falling back to memory.
Redis keys use this shape:
<keyPrefix>:rate:<tenant>:<client>:<method>Use a distinct rateLimit.keyPrefix per product, deployment, or environment,
for example mcp-gateway:prod. RATE_LIMIT_KEY_PREFIX overrides the config
value at runtime.
Runtime environment variables:
| Variable | Purpose |
| --- | --- |
| REDIS_URL | Enables Redis-backed rate limiting, for example redis://localhost:6379. |
| RATE_LIMIT_KEY_PREFIX | Overrides rateLimit.keyPrefix to separate products or environments sharing Redis. |
| MCP_GATEWAY_ENV | Primary environment name used by audit filtering. |
| NODE_ENV | Fallback environment name when MCP_GATEWAY_ENV is not set. |
Gateway credentials are used only at the gateway boundary. Authorization,
X-API-Key, and Api-Key request headers are not forwarded to upstream MCP
servers. Use upstreamHeaders.forward to allow only the operational request
headers that an upstream should receive.
If an upstream MCP server requires its own credential, configure it on that
upstream. ${ENV_VAR} placeholders are resolved inside upstream auth values at
startup; missing or empty variables fail config validation.
upstreams:
- name: example-upstream
type: http
url: "https://mcp-upstream.example.com/api/v2/mcp"
timeoutMs: 30000
auth:
type: apiKey
apiKey:
header: "Authorization"
value: "Api-Key ${EXAMPLE_UPSTREAM_API_KEY}"Try it (curl)
initialize
curl -i "http://localhost:8080/mcp" -H "Origin: http://localhost:3000" -H "Authorization: Bearer dev_key_1" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0",
"id":1,
"method":"initialize",
"params":{
"protocolVersion":"2025-03-26",
"capabilities":{},
"clientInfo":{"name":"demo","version":"0.0.1"}
}
}'If the upstream sets a session, you’ll receive a response header like:
Mcp-Session-Id: ...
tools/list
curl -s "http://localhost:8080/mcp" -H "Origin: http://localhost:3000" -H "Authorization: Bearer dev_key_1" -H "Mcp-Session-Id: YOUR_SESSION_ID" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'tools/call
curl -s "http://localhost:8080/mcp" -H "Origin: http://localhost:3000" -H "Authorization: Bearer dev_key_1" -H "Mcp-Session-Id: YOUR_SESSION_ID" -H "Accept: application/json" -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0",
"id":3,
"method":"tools/call",
"params":{
"name":"echo",
"arguments":{"message":"hola"}
}
}'Routing rules
Routing is first-match wins. A rule can match:
match.method: glob pattern for the MCP method (e.g.tools/call,tools/list,*)match.tool: (only fortools/call) glob pattern forparams.namematch.resource: (only forresources/read) glob pattern forparams.urimatch.prompt: (only forprompts/get) glob pattern forparams.nameupstream: name of the destination upstream
Config validation fails if a routing rule references an upstream name that is
not declared in upstreams.
Example with 3 upstreams:
upstreams:
- name: mcp-math
type: http
url: "http://10.0.0.11:9000/mcp"
timeoutMs: 30000
- name: mcp-kb
type: http
url: "http://10.0.0.12:9000/mcp"
timeoutMs: 30000
- name: mcp-reports
type: http
url: "http://10.0.0.13:9000/mcp"
timeoutMs: 45000
routing:
- match: { method: "tools/call", tool: "math.*" }
upstream: "mcp-math"
- match: { method: "tools/call", tool: "kb.*" }
upstream: "mcp-kb"
- match: { method: "tools/call", tool: "reports.*" }
upstream: "mcp-reports"
- match: { method: "*" }
upstream: "mcp-reports"CLI commands
mcp-gateway run -c config.yml
mcp-gateway validate -c config.yml
mcp-gateway routes -c config.yml
mcp-gateway healthInside Docker Compose:
docker compose exec mcp-gateway node src/cli.js health
docker compose exec mcp-gateway node src/cli.js routes -c /config/config.yml
docker compose exec mcp-gateway node src/cli.js validate -c /config/config.ymlDocker Compose: keep upstream private
A common pattern is to not publish upstream ports to the host. Only the gateway is exposed:
- Redis service: no
ports:(only internal networking) - Upstream service: no
ports:(only internal networking) - Gateway service:
ports: ["8080:8080"]
This keeps Redis and the MCP upstream reachable only from the gateway on the
Docker network. The included docker-compose.yml sets
REDIS_URL=redis://redis:6379 so rate limits are shared across gateway
instances that use the same Redis and key prefix.
License
MIT
