I Couldn’t Find an OAuth 2.1 Proxy for MCP Servers, So I Built One

When I started deploying custom MCP servers to connect to Claude.ai, I hit a wall fast.

Claude.ai’s custom connector flow requires your MCP server to implement OAuth 2.1 Protected Resource Metadata — specifically RFC 9728 — before it will even attempt to authenticate. No RFC 9728 /.well-known/oauth-protected-resource endpoint? Silent failure. No error. The connector just doesn’t work.

I went looking for an existing solution. Something that could sit in front of any MCP server, handle the spec compliance, validate JWTs, and get out of the way. Nothing existed. So I built it: mcp-gate.

What the Problem Actually Is

When Claude.ai connects to a custom MCP server, the flow looks roughly like this:

  1. Claude.ai fetches /.well-known/oauth-protected-resource from your server
  2. That endpoint must return RFC 9728 metadata pointing to your authorization server
  3. Claude.ai negotiates an OAuth 2.1 token with that authorization server
  4. Subsequent requests carry a Bearer JWT, which your server must validate via JWKS

Most MCP server implementations — whether you’re writing one in Go, Python, TypeScript, or using an off-the-shelf server — don’t implement any of this. They’re focused on the MCP protocol itself, not on auth infrastructure. Bolting RFC 9728 + JWKS validation directly into every server you deploy is the wrong abstraction anyway.

What you actually want is a sidecar proxy that handles auth uniformly, regardless of what’s running behind it.

What mcp-gate Does

mcp-gate is a stateless Go binary that sits between Claude.ai and your MCP server:

mcp-gate architecture

It does three things:

  1. Serves /.well-known/oauth-protected-resource — RFC 9728 compliant metadata, pointing Claude.ai to your authorization server
  2. Validates Bearer JWTs on every request — fetches and caches JWKS from your auth server, validates signature (RS256), expiry, issuer, audience, and scope claims
  3. Reverse proxies to your upstream MCP server — if the token is valid, the request goes through; otherwise 401

That’s it. It has no opinions about what your MCP server does. It works with any OIDC-compliant authorization server: Authentik, Keycloak, Okta, Auth0, or anything else that issues JWTs with a JWKS endpoint.

Quick Start

export LISTEN_ADDR=0.0.0.0:8080
export UPSTREAM_URL=http://your-mcp-server:8000
export RESOURCE_URI=https://mcp.yourdomain.com
export AUTHORIZATION_SERVER=https://auth.yourdomain.com/application/o/mcp/
export JWKS_URI=https://auth.yourdomain.com/application/o/mcp/jwks/
export EXPECTED_ISSUER=https://auth.yourdomain.com/application/o/mcp/
export EXPECTED_AUDIENCE=your-client-id

go run ./cmd/mcp-gate

Or with Docker:

docker pull cpremus/mcp-gate:latest

A docker-compose.example.yml is included in the repo showing how to wire it up alongside a typical MCP server container.

A Note on Logging

Every request is logged as structured JSON — method, path, status, duration_ms, client_ip, user_agent — designed for ingestion by Loki/Alloy or any structured log aggregator. If you’re running a self-hosted observability stack, it drops right in.

Why a Separate Binary

A few design decisions worth explaining:

Stateless by design. mcp-gate holds no session state. JWKS keys are cached in-memory with TTL-based refresh. This makes horizontal scaling trivial and keeps the failure surface small.

No config files. Everything is environment variables. Works cleanly in Docker, Kubernetes, or bare metal.

Deliberately narrow scope. mcp-gate does auth and proxying. It doesn’t do request transformation or protocol-level concerns. Those belong elsewhere in your stack.

Status

The core is stable and running in production on my own infrastructure. Current release handles everything needed for Claude.ai custom connector integration.

Links

If you’re building or deploying MCP servers and haven’t hit this yet, you will. Hopefully this saves you the afternoon I lost to it.

Leave a Reply