Skip to main content
Glama
thiskevinwang

MCP + OAuth on Cloudflare Workers

README.md
# MCP + OAuth on Cloudflare Workers

This workspace deliberately has two independent Workers:

- `idp-service` is an OAuth 2.1 authorization server backed only by its `OAUTH_KV` namespace. It issues opaque access tokens, supports S256 PKCE, DCR, CIMD, refresh tokens, and automatic local authorization as `local-developer`.
- `mcp-service` is a stateless Streamable HTTP MCP endpoint. It knows the IdP issuer, RFC 8414 metadata, RFC 7662, and its dedicated introspection credentials. It has no binding to the IdP Worker and no shared storage.

`mcp-service` discovers the IdP's `introspection_endpoint`, caches that endpoint, and sends bearer tokens only through authenticated `client_secret_basic` introspection. It accepts only active tokens with the exact `https://mcp.localhost/mcp` audience, the `mcp:tools` scope, and a subject. Invalid or unauthorized tokens return `401`; an unavailable IdP, rejected introspection credentials, or malformed introspection response returns `503`.

## Local HTTPS

Install [Caddy](https://caddyserver.com/docs/install), then install dependencies:

```sh
brew install caddy
bun install
```

Create independent local secret files before starting either Worker. Use the same value in both files; these files are ignored by Git.

```sh
cp packages/idp-service/.dev.vars.example packages/idp-service/.dev.vars
cp packages/mcp-service/.dev.vars.example packages/mcp-service/.dev.vars
```

Start the two Workers and Caddy in separate terminals:

```sh
bun run dev:idp
bun run dev:mcp
sudo caddy run --config Caddyfile --adapter caddyfile
```

Caddy is the public HTTPS entry point: `https://idp.localhost` proxies to the IdP on port 8787, and `https://mcp.localhost/mcp` proxies to MCP on port 8788. `*.localhost` resolves to loopback without an `/etc/hosts` entry. Caddy uses its local CA; if needed, run `sudo caddy trust`.

## Cloudflare configuration

Create a dedicated KV namespace for the IdP and replace both placeholder IDs in `packages/idp-service/wrangler.jsonc`:

```sh
wrangler kv namespace create OAUTH_KV
wrangler kv namespace create OAUTH_KV --preview
```

Use a dedicated client ID (`mcp-service-introspection`) and store the matching secret independently in each Worker. Do not put it in `vars` or commit it.

```sh
cd packages/idp-service
wrangler secret put MCP_INTROSPECTION_CLIENT_SECRET

cd ../mcp-service
wrangler secret put MCP_INTROSPECTION_CLIENT_SECRET
```

The IdP's public metadata at `/.well-known/oauth-authorization-server` advertises `/introspect` and only `client_secret_basic` as its supported introspection authentication method. `/introspect` always sends `Cache-Control: no-store` and never logs token values.

## Testing with MCP Inspector

Keep both Workers and Caddy running as above, then start Inspector with the local CA available:

```sh
NODE_USE_SYSTEM_CA=1 bunx @modelcontextprotocol/inspector
```

Use `https://mcp.localhost/mcp` as the server URL and scope `mcp:tools`.

### Regular OAuth client

- Client ID: `mcp-inspector-local`
- Client secret: `mcp-inspector-local-secret`
- Redirect URL: `http://localhost:6274/oauth/callback` or `http://localhost:6274/oauth/callback/debug`

The client is seeded in `OAUTH_KV` on demand. Local authorization immediately completes as `local-developer`; no interaction page is involved.

### Dynamic Client Registration

DCR is available from the metadata document's `registration_endpoint` (`/register`). Register a client, then use its returned credentials with the same callback URLs and `mcp:tools` scope.

### Client ID Metadata Documents

For CIMD, use this local fixture client ID and leave the client secret empty:

`https://idp.localhost/client-metadata/mcp-inspector.json`

Caddy serves `fixtures/mcp-inspector-cimd.json` at that path before proxying to the IdP Worker. The JSON document's `client_id` matches the served URL exactly. Built-in CIMD fetches require public HTTPS in deployed environments, so automated localhost tests mock that fetch; use a public deployment to test the production fetch boundary.

Workers intentionally refuse CIMD fetches to private `.localhost` targets. For this exact local fixture URL only, `idp-service` seeds an equivalent public client in KV so the browser flow works locally; all other HTTPS client IDs continue through the provider's built-in CIMD fetch and validation path.

## Validation

```sh
bun run test
bun run typecheck
bun run lint
bun run format:check
caddy validate --config Caddyfile --adapter caddyfile
```