Skip to main content
Glama

mcpgate

CI

MCP servers exist everywhere. The production layer doesn't.

MCP (Model Context Protocol) servers are easy to write and easy to expose — and that's the problem. A raw MCP server has no authentication, no rate limiting, no caching, no circuit breaking, and no observability. Put one behind your product and the first abusive client, the first slow backend, or the first retry storm takes it down.

mcpgate is the production layer: a gateway that sits in front of your MCP servers and adds auth, per-key rate limits, response caching, circuit breakers, structured logs, and Prometheus metrics — without touching the servers themselves.

                    ┌─────────────────────────────────────────────┐
  MCP client ──────▶│  mcpgate                                    │
  (any MCP client)  │                                             │
                    │  ┌─────────┐  ┌──────────┐  ┌───────────┐  │
                    │  │ API key │─▶│ token    │─▶│ cache     │  │
                    │  │ auth    │  │ bucket   │  │ (TTL)     │  │
                    │  └─────────┘  └──────────┘  └─────┬─────┘  │
                    │  ┌───────────┐  ┌───────────┐     │        │
                    │  │ circuit   │─▶│ backend   │◀────┘        │
                    │  │ breaker   │  │ stdio /   │              │
                    │  └───────────┘  │ HTTP      │              │
                    │  /metrics /healthz /readyz └───────────┘   │
                    └─────────────────────────────────────────────┘
                                        │                │
                                   stdio child       Streamable HTTP
                                   MCP server        MCP server

Quickstart

# Build
go build -o mcpgate ./cmd/mcpgate
go build -o echo-backend ./examples/echo-backend

# Run (examples/config.yaml uses the demo key "demo-key-123" — rotate it)
./mcpgate -config examples/config.yaml

List tools through the gateway:

curl -X POST http://localhost:8080/mcp/echo \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: demo-key-123' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Call a tool:

curl -X POST http://localhost:8080/mcp/echo \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: demo-key-123' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}'
# {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"5"}]}}

Or run the full guided demo (auth failure, cache MISS/HIT, rate-limit 429s, health + metrics):

bash demo/demo.sh

Stdio mode

With -stdio, mcpgate itself runs as a stdio MCP server, aggregating every configured backend behind one stdin/stdout stream — for stdio-only clients (Claude Desktop, IDE extensions, MCP inspectors) and server directories:

./mcpgate -config examples/config.yaml -stdio

The gateway answers initialize/ping itself, merges all backends' tool lists into one tools/list (a tool name claimed by more than one backend is listed once — calls can only route to the first owner, in sorted backend-name order), and routes each tools/call to the backend that owns the tool. Per backend, the tool filter, circuit breaker, and response cache apply exactly as on the HTTP path. Two HTTP concerns do not apply: stdio is a local trust boundary, so there is no API-key auth or rate limiting — whoever can spawn the process already controls it. Logs go to stderr; stdout carries protocol traffic only.

Related MCP server: mcp-streamable-http-bridge

How it works

POST /mcp/{backend} runs one JSON-RPC 2.0 request through this pipeline:

  1. Auth — X-API-Key header or Authorization: Bearer <key> for API keys; when oidc is configured, a Bearer JWT signed by the issuer is also accepted (identity oidc:<sub>); when oauth is configured, an opaque Bearer token is checked with the authorization server's introspection endpoint (identity oauth:<sub>). Missing/invalid credentials get 401. Key values and token contents are never logged.

  2. Rate limit — token bucket per API key. Exhausted buckets get 429 with a JSON-RPC error. Per-key overrides in config.

  3. Backend lookup — unknown backends get 404 / JSON-RPC -32601.

  4. Tool filter — a backend's tool_filter hides tools: denied tools are removed from tools/list results, and tools/call for a filtered tool is rejected with 403 / JSON-RPC -32602 before touching the backend, cache, or breaker.

  5. Circuit breaker — an open breaker fails fast with 503 and never touches the backend. After the cooldown, one probe is allowed; success closes the breaker, failure re-opens it.

  6. Cache — tools/list responses are cached per backend. tools/call responses are cached only for tools explicitly listed in cacheable_tools, because tool calls can have side effects. Cache hits return X-Cache: HIT; the cached payload is re-wrapped with the current request's id, because JSON-RPC requires the response id to match. Operators can drop cached entries without restarting the gateway: POST /admin/cache/invalidate clears every backend's cache, POST /admin/cache/invalidate/{backend} clears one (API key required, answered with {"status":"ok","backends":[...],"cleared":N}).

  7. Forward — the request goes to the backend (stdio child process or Streamable HTTP POST). Configured static headers are injected into HTTP backend requests. Transport failures return 502 and count toward the breaker. JSON-RPC error objects from the backend are forwarded verbatim and are not cached. A Streamable HTTP backend that answers with text/event-stream has its stream parsed incrementally: the JSON-RPC response matching the request id is returned as soon as its event arrives, instead of waiting for the stream to close.

Clients that send Accept: text/event-stream get the backend's SSE events relayed live: progress notifications and other server messages arrive as data: events as the backend sends them, and the stream ends after the JSON-RPC response matching the request id. Streaming responses are never cached, and the per-request timeout bounds the whole stream. A backend that cannot stream (stdio) answers with a single SSE event carrying the response. Clients that only accept JSON keep the single-response behavior.

Notifications (messages without an id) are forwarded to the backend fire-and-forget and the client gets 202 Accepted — the client asked for acceptance, not delivery confirmation, so a backend that can't be reached still gets a 202, and the failure is logged and counted instead of surfacing to the client. An open circuit breaker drops notifications without attempting the forward.

Configuration

listen: ":8080"

api_keys:
  - key: "demo-key-123"      # X-API-Key or Bearer token
    name: "demo"              # appears in logs/metrics, never the key itself
    rate_limit:               # optional per-key override
      requests_per_second: 5
      burst: 10

default_rate_limit:
  requests_per_second: 10
  burst: 20

# Optional: accept OIDC JWT bearer tokens as well as API keys.
# oidc:
#   issuer: "https://accounts.example.com"  # discovery via /.well-known/openid-configuration
#   audience: "mcpgate"                      # required aud claim
#   # jwks_url: "https://accounts.example.com/jwks"  # override discovery
#   # clock_skew: 60s                        # exp/nbf tolerance (default 60s)

# Optional: accept opaque OAuth2 bearer tokens via RFC 7662 introspection.
# oauth:
#   introspection_url: "https://auth.example.com/oauth/introspect"
#   client_id: "gateway"                     # gateway credential at the endpoint
#   client_secret: "s3cret"                  # never logged; rotate before real use
#   # cache_ttl: 60s                         # successful-introspection cache (default 60s, never past exp)

backends:
  - name: "echo"
    transport: "stdio"        # or "http"
    command: ["./echo-backend"]  # stdio: executable + args
    env: {}                   # stdio: extra env vars for the child
    # url: "http://localhost:9000/mcp"  # http: Streamable HTTP endpoint
    # auth: {type: "bearer", token: "..."}  # http: gateway -> backend auth
    # headers:                            # http: static headers injected into
    #   X-Gateway: "mcpgate"              #   every request to this backend
    timeout: 10s              # per-request backend timeout (default 10s)
    tool_filter:              # which tools clients may see and call
      # allow: ["echo", "add"]  # non-empty = only these tools are exposed
      deny: ["drop_table"]     # hidden from tools/list, rejected on tools/call
    cache:
      tools_list_ttl: 60s
      tools_call_ttl: 30s     # 0 disables tools/call caching
      cacheable_tools: ["echo", "add"]
    breaker:
      failure_threshold: 5    # consecutive failures to trip open (0 = never)
      cooldown: 30s           # open duration before a probe is allowed

Field

Required

Notes

listen

yes

Address the gateway binds, e.g. :8080

api_keys

yes

At least one; keys must be unique

default_rate_limit

yes

Applies to keys without an override

oidc.issuer

with oidc

OIDC issuer URL; JWKS discovered via /.well-known/openid-configuration

oidc.audience

with oidc

Required aud claim value

oidc.jwks_url

no

Overrides the discovered JWKS endpoint

oidc.clock_skew

no

exp/nbf tolerance; default 60s

oauth.introspection_url

with oauth

RFC 7662 introspection endpoint

oauth.client_id

with oauth

Gateway credential at the introspection endpoint (HTTP Basic)

oauth.client_secret

with oauth

Gateway credential at the introspection endpoint; never logged

oauth.cache_ttl

no

Successful-introspection cache; default 60s, never past the token's exp

backends

yes

At least one; names must be unique

backends[].transport

yes

stdio or http

backends[].command

stdio

Executable + args for the child process

backends[].url

http

Absolute http(s) URL of the Streamable HTTP endpoint

backends[].auth

no

bearer (token) or basic (username/password)

backends[].headers

no

http only: static headers injected into every backend request; backend auth wins on name clashes

backends[].tool_filter.allow

no

Non-empty: only these tools are exposed

backends[].tool_filter.deny

no

Hidden from tools/list, rejected on tools/call (deny wins)

backends[].timeout

no

Default 10s

backends[].cache.tools_list_ttl

no

0 disables

backends[].cache.tools_call_ttl

no

0 disables; only listed tools cached

backends[].breaker.failure_threshold

no

0 disables tripping

backends[].breaker.cooldown

no

Time before a probe is allowed

Observability

  • GET /healthz → {"status":"ok"} (liveness).

  • GET /readyz → {"status":"ready","backends":{"demo":"closed"}} (readiness: per-backend circuit-breaker state; 503 {"status":"degraded",...} when no backend can serve — use this for load-balancer health checks, not /healthz, which stays green while every backend is down).

  • GET /metrics → Prometheus exposition:

Metric

Labels

Meaning

mcpgate_requests_total

backend, method, status

Requests by outcome (ok, rpc_error, backend_error, rejected)

mcpgate_request_duration_seconds

backend

Backend call latency histogram

mcpgate_cache_hits_total / mcpgate_cache_misses_total

backend, method

Cache behavior

mcpgate_backend_errors_total

backend

Transport failures

mcpgate_breaker_state

backend

0 closed, 1 half-open, 2 open

mcpgate_rate_limit_rejections_total

key_name

Rejected requests per API key name

mcpgate_notifications_total

backend, method, status

Notifications by outcome (forwarded, dropped, failed)

Logs are structured JSON (via log/slog) with request_id, api_key (name only), backend, method, cache outcome, and latency. Backend credentials and API key values never appear in logs.

Development

gofmt -l .          # must be clean
go build ./...
go vet ./...
go test ./...       # unit tests: rate limiter, cache, breaker, config,
                    # backend transports (incl. stdio against echo-backend),
                    # gateway routing (auth, rate limit, cache, breaker)

CI (GitHub Actions) runs format, build, vet, and test on every push/PR.

Roadmap — what's NOT in v0.1

Honest gaps, in rough priority order:

  • SSE streaming (live relay): supported — a client that sends Accept: text/event-stream gets the backend's SSE events relayed live (progress notifications arrive before the response, instead of being swallowed), and the stream ends after the matching JSON-RPC response. Streaming responses are never cached; stdio backends answer with a single SSE event. Still missing: relaying events that arrive after the response (the stream closes at the response, matching the single-response path).

  • OAuth / OIDC: OIDC JWT bearer-token client auth is supported (RS/ES/PS signature validation against the issuer's JWKS via discovery, strict iss/aud/exp checks; JWKS refreshes periodically and on key rotation). OAuth2 token introspection (RFC 7662) is supported too: opaque Bearer tokens are checked with the authorization server (HTTP Basic client auth, active/sub/exp enforced, successful results cached no longer than the token's remaining lifetime). Still missing: scopes and per-tool authorization.

  • Request/response transformation: per-backend tool_filter (allow/deny lists: hidden from tools/list, rejected on tools/call) and static headers injection for HTTP backends are supported. Still missing: schema rewriting.

  • Backend health checks / load balancing: one backend per name, no pooling, no active probing — the breaker is the only failure signal. GET /readyz at least surfaces that signal per backend for load balancers and operators.

  • Distributed rate limiting / shared cache: both are in-process only. Multi-replica deployments need Redis-backed implementations.

  • TLS termination: run behind a TLS-terminating reverse proxy.

  • Admin API: POST /admin/cache/invalidate[/{backend}] clears backend response caches at runtime (API-key authenticated); no config reload or other admin operations yet — restart to pick up config changes.

License

MIT. See LICENSE.

Available Tools

2 tools
addC

Add two numbers.

NameRequiredDescriptionDefault
aYes
bYes
echoB

Return the input text unchanged.

NameRequiredDescriptionDefault
textYes

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 2 tool updatesv0.1.0
    • First observedadd
    • First observedecho

TDQS

B3.3/5.0

Scored across 2 tools

Disambiguation5/5

echo and add target completely different operations (returning text vs arithmetic), so there is no risk of misselection. Each tool has a single, clearly distinct purpose.

Naming Consistency5/5

Both names are single lowercase verbs (echo, add), following a clean and predictable pattern. The convention is minimal but perfectly consistent.

Tool Count3/5

Two tools is very thin even for a gateway server; the surface feels like a demo rather than a functional set. It earns a borderline score rather than being egregiously mismatched.

Completeness2/5

There is no discernible domain to cover—echo and add are unrelated utility stubs with no lifecycle or workflow. This leaves significant gaps for almost any real use case.

Maintenance

ActivityMaintained
ResponsivenessNo recent community issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Aggregates multiple upstream MCP servers (stdio, streamable-http, sse, websocket) behind a single streamable-HTTP endpoint with declarative configuration, namespacing, filtering, and auto-reload.
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Bridges stdio Model Context Protocol (MCP) servers to MCP Streamable HTTP behind a single gateway, enabling multi-tenant, multi-user deployment with per-tenant environment variables via HTTP headers.
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Self-hosted MCP gateway that registers upstream MCP servers and fronts them behind one governed Streamable HTTP endpoint: per-tool access control by caller API key, guardrails over tool arguments and results, rate limits, and usage logs. The same Rust gateway also proxies LLM and A2A agent traffic.
    1
    216
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    High-performance Go gateway and reverse proxy for Model Context Protocol servers with rate-limiting, authorization, and pay-per-result metering.
    33 npm
    15
    MIT