Skip to main content
Glama
mstfbysl

Remote MCP OAuth Template

by mstfbysl
README.md
# Remote MCP Server with OAuth 2.1 β€” Node.js Template

[![Node.js >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![MCP: Streamable HTTP](https://img.shields.io/badge/MCP-Streamable%20HTTP-8A2BE2)](https://modelcontextprotocol.io)
[![OAuth 2.1 + PKCE](https://img.shields.io/badge/OAuth%202.1-PKCE%20%2B%20DCR-orange)](#oauth-specifics)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](https://github.com/mstfbysl/remote-mcp-oauth-template/pulls)

A production-shaped template for a **remote [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server** that authenticates real users with **OAuth 2.1** β€” authorization code grant + **PKCE** + **dynamic client registration** β€” and proxies tool calls to your existing backend API over **Streamable HTTP**.

Connect it to **Claude** as a custom connector (or to any MCP client that supports OAuth, like **MCP Inspector**), sign in through a browser consent screen, and every tool call runs as *that* user β€” with your backend's normal per-user permissions.

**Why this exists:** remote MCP servers are easy; remote MCP servers with a *working* OAuth flow are not. Most examples either skip auth entirely or bake an API key into the client config. This template shows the full flow end-to-end, runnable locally in two commands, with no database and no external identity provider.

## Features

- πŸ” **Built-in OAuth 2.1 authorization server** β€” authorization code grant, S256 PKCE, public clients, dynamic client registration (RFC 7591), discovery metadata (RFC 8414 + RFC 9728)
- πŸͺ„ **JWT passthrough** β€” `access_token === your backend's JWT`, so there is no token store, no refresh rotation, no session database
- 🌊 **Streamable HTTP MCP endpoint** at `/mcp` β€” stateless, fresh server per request, proper `401` + `WWW-Authenticate` challenge
- πŸ‘€ **Per-user permissions for free** β€” every tool call carries the user's own JWT to your API
- ⚑ **Local demo in 2 commands** β€” zero-dependency mock backend with a working login + consent page
- βœ… **Full-flow smoke test** β€” registration β†’ consent β†’ PKCE exchange β†’ replay protection β†’ a real `tools/call`
- 🐳 **Deployment configs** β€” multi-stage Dockerfile, docker-compose, Coolify walkthrough

## Table of contents

- [The trick: access_token === your backend's JWT](#the-trick-access_token--your-backends-jwt)
- [How it fits together](#how-it-fits-together)
- [Try it locally (2 minutes)](#try-it-locally-2-minutes)
- [Adapting it to your backend](#adapting-it-to-your-backend)
- [Deployment](#deployment)
- [Environment variables](#environment-variables)
- [Adding it to Claude](#adding-it-to-claude-custom-connector)
- [Demo tools](#demo-tools)
- [OAuth specifics](#oauth-specifics)
- [Project layout](#project-layout)

## The trick: `access_token === your backend's JWT`

Your backend already knows how to log users in and issue JWTs. So instead of building a token store, refresh logic, and a session database, the MCP server:

1. delegates login + consent to your existing web app,
2. receives the user's JWT from it,
3. hands that JWT back to the MCP client **as the OAuth access token**,
4. forwards it verbatim to your API on every tool call.

The MCP server holds **no long-lived state**. When the JWT expires, the client just re-runs the OAuth flow. Auth codes live 60 seconds; in-flight sessions live 5 minutes; both are in-memory Maps.

## How it fits together

```
MCP client  ──HTTPS──►  MCP Server  ──redirect──►  Your web app (login + consent)
(Claude)                     β–²                            β”‚
                             β”‚    POST { mcp_session, jwt }β”‚
                             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             ──HTTPS──►  Your backend API
```

The OAuth flow (one-time per user, then the client caches the token):

1. Client tries `POST /mcp` with no token β†’ MCP returns `401` with a `WWW-Authenticate` header pointing to `/.well-known/oauth-protected-resource`.
2. Client reads the discovery metadata and dynamic-registers itself at `/oauth/register` (RFC 7591).
3. Client opens the user's browser at `/oauth/authorize?...code_challenge=...`.
4. MCP validates the params, parks them as a short-lived `mcp_session`, and `302`-redirects the browser to `AUTH_APP_URL/oauth/authorize?mcp_session=...`.
5. Your web app handles login (or reuses an existing session) and shows an explicit consent screen. On Approve, it `POST`s `{ mcp_session, jwt }` to the MCP server's `/oauth/complete`.
6. MCP re-verifies the JWT against your backend, mints a single-use auth code, and returns the URL the browser should navigate to (the client's `redirect_uri` with `?code=...`).
7. Client exchanges the code at `/oauth/token` with the PKCE verifier and gets back the JWT as the OAuth `access_token`.
8. Every subsequent MCP call carries that JWT; the server forwards it to your API.

Three bridge endpoints connect the MCP server to your web app:

| Endpoint | Method | Purpose |
|---|---|---|
| `/oauth/preflight?mcp_session=...` | GET | Read-only: returns `{ client_name, client_origin, scopes }` so the consent screen can show what's being authorized. Doesn't consume the session. |
| `/oauth/complete` | POST | Consumes the `mcp_session`, verifies the JWT, returns `{ redirect_url }` carrying the auth code. |
| `/oauth/deny` | POST | Consumes the `mcp_session`, returns `{ redirect_url }` with `error=access_denied`. |

CORS on these is locked to the auth app origin (`ALLOWED_ORIGINS`).

## Try it locally (2 minutes)

Requirements: Node.js 18+.

```bash
npm install
cp .env.example .env      # defaults work out of the box

# Terminal 1 β€” mock backend (demo API + login/consent page on :3000)
npm run mock-backend

# Terminal 2 β€” MCP server (:4000)
npm start
```

Then point [MCP Inspector](https://github.com/modelcontextprotocol/inspector) at it:

```bash
npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP, URL: http://localhost:4000/mcp
# Click Connect β†’ your browser opens the consent page
# Log in with demo@example.com / demo β†’ Approve
```

You're through the full OAuth flow. Call `whoami`, `list_notes`, or `create_note` from the Inspector β€” each request carries the JWT the mock backend issued.

The mock backend (`mock-backend/server.js`, zero dependencies) plays both roles your real stack plays in production: the API that issues/verifies JWTs and serves data, and the web app that hosts the login + consent page.

### Smoke test

```bash
npm test
```

Boots a stub backend + the MCP server on random ports and walks the entire flow: registration, authorize redirect, preflight, complete, PKCE token exchange, replay protection, deny path, bad-JWT rejection, and a real `tools/call` through the Streamable HTTP endpoint.

## Adapting it to your backend

Three steps:

1. **Point it at your API.** Set `BACKEND_API_URL` and `JWT_VERIFY_PATH` (the endpoint that returns the current user for a `Bearer` token, e.g. `/api/auth/me`). That endpoint is all the MCP server needs from your backend.

2. **Add one route to your web app.** Implement `/oauth/authorize?mcp_session=...` in your frontend: call the MCP server's `/oauth/preflight` to render the consent screen, let the user log in with your normal auth, then `POST` the JWT to `/oauth/complete` and redirect the browser to the returned `redirect_url`. The mock backend's consent page (~100 lines of HTML in `mock-backend/server.js`) is a working reference. Set `AUTH_APP_URL` to your app's public URL.

3. **Write your tools.** Replace the demo tools in `src/tools.js`. Each tool is ~5 lines β€” the shared `apiCall(extra, method, path, body?)` helper handles auth, fetch, and response shaping:

```js
mcp.registerTool(
  'list_invoices',
  {
    title: 'List invoices',
    description: 'List the authenticated user\'s invoices (GET /api/invoices).',
    inputSchema: { status: z.enum(['paid', 'open']).optional() },
  },
  ({ status }, extra) =>
    apiCall(extra, 'GET', `/api/invoices${status ? `?status=${status}` : ''}`)
);
```

The JWT arrives in `extra.authInfo.token`, so per-user permissions apply on your backend exactly as they do for your web app.

## Deployment

The MCP server is a plain Node.js Express app. The only hard requirement: it MUST be reachable over **HTTPS** at `MCP_PUBLIC_URL` β€” Claude will not complete OAuth against plain HTTP.

### Option A β€” PM2

```bash
npm ci --omit=dev
cp .env.example .env
# edit .env: MCP_PUBLIC_URL=https://mcp.your-domain.com, BACKEND_API_URL, AUTH_APP_URL
pm2 start src/index.js --name mcp-server
pm2 save
```

Reverse proxy `mcp.your-domain.com` β†’ `127.0.0.1:4000` with nginx / Caddy / Cloudflare Tunnel so HTTPS is terminated for you.

### Option B β€” Docker / docker-compose

A multi-stage Alpine `Dockerfile` and a `docker-compose.yml` ship with the project. The compose file runs both the MCP server and the mock backend so the demo works in containers too.

```bash
docker compose up --build
# or just the MCP server:
docker build -t mcp-server .
docker run --rm -p 4000:4000 \
  -e MCP_PUBLIC_URL=https://mcp.your-domain.com \
  -e BACKEND_API_URL=https://api.your-domain.com \
  -e AUTH_APP_URL=https://app.your-domain.com \
  mcp-server
```

### Option C β€” Coolify (or similar PaaS)

1. **+ New β†’ Resource β†’ Docker Compose**, connect this repository.
2. Attach a domain to the `mcp` service (e.g. `mcp.your-domain.com`). The platform terminates HTTPS for you.
3. Set environment variables in the UI (never commit a `.env`):

   ```
   MCP_PUBLIC_URL=https://mcp.your-domain.com
   BACKEND_API_URL=https://api.your-domain.com
   AUTH_APP_URL=https://app.your-domain.com
   ALLOWED_ORIGINS=https://claude.ai,https://*.anthropic.com,https://*.claude.ai,https://app.your-domain.com
   ```

4. Deploy, then sanity-check:

   ```bash
   curl -fsS https://mcp.your-domain.com/healthz
   curl -fsS https://mcp.your-domain.com/.well-known/oauth-authorization-server | jq
   ```

The `issuer` in that metadata response MUST equal `MCP_PUBLIC_URL` exactly, otherwise the client's OAuth flow will refuse to proceed.

### Environment variables

| Var | Required | Purpose |
|---|---|---|
| `PORT` | no (default `4000`) | Local listen port |
| `MCP_PUBLIC_URL` | **yes in prod** | Externally reachable HTTPS URL β€” used in OAuth discovery metadata |
| `BACKEND_API_URL` | yes | Base URL of your backend API (no trailing slash) |
| `JWT_VERIFY_PATH` | no (default `/api/auth/me`) | Backend endpoint that returns the current user for a Bearer token |
| `AUTH_APP_URL` | yes | Public URL of the web app hosting login + consent |
| `ALLOWED_ORIGINS` | no | Comma-separated browser origins for CORS; must include the auth app origin |

See [`.env.example`](.env.example) for the annotated template. Note there are **no credentials** here β€” each user signs in interactively through the browser.

## Adding it to Claude (custom connector)

1. Make sure the MCP server is reachable at `https://mcp.your-domain.com`.
2. In Claude β†’ Settings β†’ Connectors β†’ **Add custom connector**.
3. URL: `https://mcp.your-domain.com/mcp`
4. Claude discovers the OAuth metadata, opens a browser tab at your consent page, and the user logs in with their normal credentials.
5. Done β€” the tools appear in Claude, scoped to that user's data.

Each user who adds the connector goes through their own login and gets their own token. No credentials ever live in the MCP client config, and the MCP server never sees a password.

## Demo tools

| Tool | Endpoint | Notes |
|---|---|---|
| `whoami` | `GET /api/auth/me` | Profile of the authenticated user. |
| `list_notes` | `GET /api/notes` | The user's notes. |
| `create_note` | `POST /api/notes` | `title` required, `content` optional. |

## OAuth specifics

- Only `authorization_code` grant.
- Only `S256` PKCE.
- Public clients only (no `client_secret`).
- Dynamic client registration (RFC 7591); storage is in-memory.
- Discovery via RFC 8414 (`/.well-known/oauth-authorization-server`) and RFC 9728 (`/.well-known/oauth-protected-resource`).
- Auth codes: 32 random bytes, single-use, 60s TTL, in-memory.
- Access token = your backend's JWT verbatim. `expires_in` mirrors the JWT's `exp` claim when present.
- No refresh tokens β€” the client re-runs the flow when the token expires.

### Out of scope (deliberately)

- Refresh tokens β€” re-running the flow is a browser round-trip once every token lifetime.
- Persistent client / auth-code storage β€” in-memory is fine on a single instance; put the Maps in Redis if you run replicas behind a load balancer.
- Scope enforcement β€” the `mcp` scope is advertised but tools don't check granular scopes; your backend's own permissions are the real boundary.

## Project layout

```
.
β”œβ”€β”€ package.json
β”œβ”€β”€ .env.example
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ LICENSE
β”œβ”€β”€ README.md
β”œβ”€β”€ mock-backend/
β”‚   └── server.js       # Zero-dep demo API + login/consent page (replace with your stack)
β”œβ”€β”€ scripts/
β”‚   └── smoketest.mjs   # Full-flow smoke test (npm test)
└── src/
    β”œβ”€β”€ index.js        # Entry point; boots Express and wires up tools
    β”œβ”€β”€ server.js       # Express app: OAuth endpoints + Streamable HTTP MCP
    β”œβ”€β”€ oauth.js        # OAuth core: clients, sessions, codes, PKCE, Bearer middleware
    β”œβ”€β”€ auth.js         # Backend API client: apiFetch(jwt, ...) + JWT verification
    └── tools.js        # MCP tool registrations (replace the demo tools)
```

## License

[MIT](LICENSE)