oauth-mcp-server
# oauth-mcp-server
A **production-grade** [Model Context Protocol (MCP)](https://modelcontextprotocol.io) resource server with **OAuth 2.1 authorization**. Written in TypeScript with the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk).
It is **provider-agnostic** — swap between Auth0, Keycloak, Google, Entra ID, Okta, or any OAuth 2.1-compliant authorization server by changing one configuration file.
Compatible with both MCP specification versions:
- **[2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28)** (primary: per-request `_meta`, `server/discover`, stateless auth)
- **[2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25)** (backward compat: `initialize` handshake)
---
## Quick Start
> **Requires Node.js >= 18.** All commands below are PowerShell (Windows). For macOS/Linux, replace `$env:VAR = "value"` with `VAR=value`.
```powershell
# 1. Install dependencies
npm install
# 2. Build the TypeScript
npm run build
# 3. Start the server on port 6000 (dev mode — no OAuth required)
$env:PORT = "6000"; $env:MCP_NO_AUTH = "true"; npm run start:http
```
Verify it's running:
```powershell
# Health check
curl http://localhost:6000/health
# Protected Resource Metadata (RFC 9728)
curl http://localhost:6000/.well-known/oauth-protected-resource
```
Expected output:
```json
{"status":"ok","server":"oauth-mcp-server v1.0.0","auth":"disabled (dev mode)","sessions":0}
```
### Running on a Custom Port
The server reads the `PORT` environment variable. The config file uses port `6000` by default. To use a different port, set both:
```powershell
# Pick any free port
$env:PORT = "8080"; npm run start:http
```
If you change the port, also update these fields in your config file to match (`config/default.json`):
```json
{
"baseUrl": "http://localhost:8080",
"authorization": {
"validation": {
"jwks": {
"uri": "http://localhost:8080/.well-known/jwks.json",
"issuer": "http://localhost:8080"
}
},
"resourceMetadata": {
"authorizationServers": ["http://localhost:8080"],
"resource": "http://localhost:8080"
}
}
}
```
The port appears in 5 places in the config — they must all match the port you actually run on.
---
## Setup Guide: Local Development
For local development, start with OAuth **disabled**. This unlocks all capabilities without needing any tokens — perfect for testing the server itself.
```powershell
# Build and start in dev mode
npm run build
$env:PORT = "6000"; $env:MCP_NO_AUTH = "true"; npm run start:http
```
| Variable | What it does |
|---|---|
| `PORT` | Port to listen on (defaults to 6000) |
| `MCP_NO_AUTH` | Set to `"true"` to skip ALL OAuth checks — every request gets full access |
| `MCP_CONFIG_PATH` | Path to a config JSON file (defaults to `config/default.json`) |
| `HOST` | Bind address (defaults to `127.0.0.1`; Cloud Run auto-sets to `0.0.0.0`) |
### Testing with curl (no auth)
```powershell
# Discover server capabilities
$body = '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
curl -X POST http://localhost:6000/mcp -H "Content-Type: application/json" -H "MCP-Protocol-Version: 2026-07-28" -d $body
# List tools (all 18 available since auth is off)
$body = '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
curl -X POST http://localhost:6000/mcp -H "Content-Type: application/json" -H "MCP-Protocol-Version: 2026-07-28" -d $body
# Call the echo tool
$body = '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hello world"}}}'
curl -X POST http://localhost:6000/mcp -H "Content-Type: application/json" -H "MCP-Protocol-Version: 2026-07-28" -d $body
```
### Local dev with live reload
```powershell
# stdio transport (no build step, auto-reloads on code changes)
npm run dev:stdio
# HTTP transport, no OAuth, auto-reloads
$env:MCP_NO_AUTH = "true"; npm run dev:http
```
---
## Setup Guide: Enabling OAuth (Local Testing with a Real Provider)
When you're ready to test the full OAuth flow, point the server at a real authorization server. Here's the step-by-step for **Auth0** (the same pattern works for Keycloak, Okta, etc.).
### Step 1: Create an API in Auth0
1. Go to **Auth0 Dashboard → Applications → APIs → Create API**
2. Set **Name**: `MCP Server`
3. Set **Identifier**: `http://localhost:6000` (this becomes your `audience`)
4. Set **Signing Algorithm**: `RS256`
5. Click **Create**
### Step 2: Add scopes to the API
In your Auth0 API settings, add these scopes:
```
mcp:read
mcp:write
mcp:admin
```
### Step 3: Create a Machine-to-Machine client
1. Go to **Applications → Create Application**
2. Name it `MCP Client`
3. Choose **Machine to Machine**
4. Select the `MCP Server` API
5. Grant it `mcp:read` and `mcp:write` scopes
6. Note the **Client ID** and **Client Secret**
### Step 4: Update the server config
Copy the Auth0 example and fill in your details:
```powershell
Copy-Item config\auth0.example.json config\production.json
```
Edit `config/production.json` — the three fields you must change:
```json
{
"baseUrl": "http://localhost:6000",
"authorization": {
"validation": {
"jwks": {
"uri": "https://YOUR_DOMAIN.us.auth0.com/.well-known/jwks.json",
"issuer": "https://YOUR_DOMAIN.us.auth0.com/",
"audience": "http://localhost:6000"
}
},
"resourceMetadata": {
"authorizationServers": ["https://YOUR_DOMAIN.us.auth0.com"],
"resource": "http://localhost:6000"
}
}
}
```
| Field | Where to find it |
|---|---|
| `YOUR_DOMAIN` | Auth0 Dashboard → your tenant domain (e.g. `acme-corp.us.auth0.com`) |
| `uri` | `https://YOUR_DOMAIN/.well-known/jwks.json` |
| `issuer` | `https://YOUR_DOMAIN/` (trailing slash matters — use exactly what Auth0 shows) |
| `audience` | The API **Identifier** you set in Step 1 |
### Step 5: Start the server with OAuth enabled
```powershell
# Omit MCP_NO_AUTH — OAuth is now active
$env:PORT = "6000"; $env:MCP_CONFIG_PATH = "config/production.json"; npm run start:http
```
### Step 6: Get a token and test
```powershell
# Get a token from Auth0 (Client Credentials flow)
$tokenResponse = curl -X POST https://YOUR_DOMAIN.us.auth0.com/oauth/token `
-H "Content-Type: application/json" `
-d '{"client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET","audience":"http://localhost:6000","grant_type":"client_credentials","scope":"mcp:read"}'
$token = ($tokenResponse | ConvertFrom-Json).access_token
# Now call the server with the token
$body = '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
curl -X POST http://localhost:6000/mcp `
-H "Content-Type: application/json" `
-H "MCP-Protocol-Version: 2026-07-28" `
-H "Authorization: Bearer $token" `
-d $body
```
### What happens with no token (OAuth enabled)
```powershell
# Without a token, the server returns 401
curl -X POST http://localhost:6000/mcp `
-H "Content-Type: application/json" `
-H "MCP-Protocol-Version: 2026-07-28" `
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}'
# Response:
# HTTP 401 Unauthorized
# WWW-Authenticate: Bearer resource_metadata="http://localhost:6000/.well-known/oauth-protected-resource", scope="mcp:read"
```
### Provider-specific notes
| Provider | JWKS URI pattern | Issuer pattern | Notes |
|---|---|---|---|
| **Auth0** | `https://<domain>/.well-known/jwks.json` | `https://<domain>/` | Trailing slash on issuer |
| **Keycloak** | `https://<host>/realms/<realm>/protocol/openid-connect/certs` | `https://<host>/realms/<realm>` | No trailing slash |
| **Okta** | `https://<domain>/oauth2/<server-id>/v1/keys` | `https://<domain>` | Use the authorization server ID |
| **Entra ID** | `https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys` | `https://login.microsoftonline.com/<tenant-id>/v2.0` | Use tenant ID, not domain |
---
## Setup Guide: Production Deployment
### Configuration changes for production
The only differences between local and production config are the **URLs** — everything else stays the same:
```json
{
"baseUrl": "https://mcp.your-company.com",
"authorization": {
"validation": {
"jwks": {
"uri": "https://YOUR_DOMAIN.us.auth0.com/.well-known/jwks.json",
"issuer": "https://YOUR_DOMAIN.us.auth0.com/",
"audience": "https://mcp.your-company.com"
}
},
"resourceMetadata": {
"authorizationServers": ["https://YOUR_DOMAIN.us.auth0.com"],
"resource": "https://mcp.your-company.com"
}
}
}
```
| Config field | Local value | Production value |
|---|---|---|
| `baseUrl` | `http://localhost:6000` | `https://mcp.your-company.com` |
| `jwks.uri` | Your provider's JWKS URL | Same provider URL (unchanged) |
| `jwks.issuer` | Your provider's issuer | Same (unchanged) |
| `jwks.audience` | `http://localhost:6000` | `https://mcp.your-company.com` |
| `resourceMetadata.resource` | `http://localhost:6000` | `https://mcp.your-company.com` |
| `resourceMetadata.authorizationServers` | Your provider | Same (unchanged) |
> **Key insight**: The `authorizationServers` and JWKS/issuer fields point to your **OAuth provider** (which doesn't change between local and prod). Only the `baseUrl`, `audience`, and `resource` fields reflect where **this server** is running.
### Deploying to Google Cloud Run
```powershell
npm run build
```
Cloud Run sets `PORT` (usually 8080) and `K_SERVICE` automatically. The server detects `K_SERVICE` and binds to `0.0.0.0`. Set your config path as an env var in Cloud Run:
```
MCP_CONFIG_PATH=config/production.json
```
A `Dockerfile` is included for container-based deployments. After deployment:
- MCP endpoint: `https://<your-cloud-run-url>/mcp`
- Health: `https://<your-cloud-run-url>/health`
- Metadata: `https://<your-cloud-run-url>/.well-known/oauth-protected-resource`
### Deploying to Vercel
```text
api/
├── health.ts # Health check (no auth)
└── mcp.ts # Stateless Streamable HTTP handler with OAuth
```
Deploy via Vercel Git integration — no build step or framework preset needed. Set these env vars in Vercel:
```
MCP_CONFIG_PATH=config/production.json
MCP_CORS_ORIGIN=https://your-client.example.com
```
After deployment:
- MCP endpoint: `https://<project>.vercel.app/api/mcp`
- Health: `https://<project>.vercel.app/api/health`
- Metadata: `https://<project>.vercel.app/.well-known/oauth-protected-resource`
---
## How OAuth Works on This Server
This server acts as an **OAuth 2.1 Resource Server**. It never issues tokens — it only **validates** tokens issued by an authorization server.
### The Flow
```
MCP Client This Server Auth Server
│ │ │
│ POST /mcp (no token) │ │
│ ──────────────────────────────────►│ │
│ │ │
│ HTTP 401 │ │
│ WWW-Authenticate: Bearer │ │
│ resource_metadata="...", │ │
│ scope="mcp:read" │ │
│ ◄──────────────────────────────────│ │
│ │ │
│ [Client discovers auth server, │ │
│ gets a Bearer token via OAuth] │ │
│ ───────────────────────────────────────────────────────────────►│
│ ◄───────────────────────────────────────────────────────────────│
│ │ │
│ POST /mcp │ │
│ Authorization: Bearer <token> │ │
│ MCP-Protocol-Version: 2026-07-28 │ │
│ ──────────────────────────────────►│ │
│ │ Validate JWT signature │
│ │ Check exp, iss, aud │
│ │ Resolve scopes→permissions│
│ │ Filter capabilities │
│ HTTP 200 + MCP response │ │
│ ◄──────────────────────────────────│ │
```
### Token Validation Modes
| Mode | How it works | When to use |
|---|---|---|
| `jwt` | Fetches JWKS from your provider, verifies signature locally. Fast, no network call per request after JWKS is cached. | Auth0, Okta, Keycloak — most providers |
| `introspection` | Sends the token to the provider's introspection endpoint on every request. Works with opaque tokens. | When tokens are not JWTs, or you need real-time revocation |
| `hybrid` | Tries JWT validation first; falls back to introspection if JWT fails. | When your provider issues both JWT and opaque tokens |
### Scope Model
Different OAuth scopes unlock different subsets of capabilities. The default config defines:
| Scope | What it unlocks |
|---|---|
| _(no token)_ | `echo`, `current_time`, `calculate`, `random_number` — public tools only |
| `mcp:read` | Public tools + search documents, query analytics, list projects/files, read resources |
| `mcp:write` | Everything in read + create/update documents, create/update projects, upload files |
| `mcp:admin` | **Everything** — including `get_server_stats`, `reload_config`, `list_server_capabilities` |
| `mcp:documents:read` | Just `search_documents` + `docs://*` resources |
| `mcp:analytics:read` | Just `query_analytics` + `analytics://*` resources |
| `mcp:files:read` / `mcp:files:write` | File system tools |
| `mcp:projects:read` / `mcp:projects:write` | Project management tools |
Scopes are additive and resolved in the order listed — if a single token has both `mcp:read` and `mcp:files:write`, the user gets the union of both permission sets.
---
## Configuration Reference
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `PORT` | `6000` | HTTP port to listen on |
| `HOST` | `127.0.0.1` | Bind address (`0.0.0.0` when `K_SERVICE` is set, i.e. Cloud Run) |
| `MCP_CONFIG_PATH` | `config/default.json` | Path to the OAuth config JSON file |
| `MCP_NO_AUTH` | _(not set)_ | Set to `"true"` to disable all OAuth checks |
| `MCP_DEV_TOKEN` | _(not set)_ | A static JWT to use for local testing (bypasses real OAuth flow) |
| `MCP_LOG_BODY_LIMIT` | `4000` | Max characters logged per request/response body |
| `MCP_CORS_ORIGIN` | `*` | CORS origin for Vercel/public deployments |
### Config File Structure
The config file (`config/default.json` or your own) controls everything about the OAuth integration:
```json
{
"name": "oauth-mcp-server",
"version": "1.0.0",
"baseUrl": "http://localhost:6000",
"authorization": {
"validation": {
"mode": "jwt",
"jwks": {
"uri": "https://YOUR_DOMAIN/.well-known/jwks.json",
"cacheTtlSeconds": 3600,
"allowedAlgorithms": ["RS256"],
"issuer": "https://YOUR_DOMAIN/",
"audience": "http://localhost:6000"
}
},
"resourceMetadata": {
"authorizationServers": ["https://YOUR_DOMAIN"],
"scopesSupported": ["mcp:read", "mcp:write", "mcp:admin"],
"bearerMethodsSupported": ["header"],
"resourceSupported": true,
"resource": "http://localhost:6000"
},
"scopeMapping": {
"scopes": {
"mcp:read": {
"tools": ["echo", "calculate", "current_time", "..."],
"resources": ["config://app", "docs://*", "..."],
"prompts": ["summarize", "code_review", "..."]
}
},
"defaultPermissions": {
"tools": ["echo", "current_time"],
"resources": ["config://app", "docs://about"],
"prompts": []
}
}
},
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true, "listChanged": true },
"prompts": { "listChanged": true }
},
"instructions": "OAuth-protected MCP resource server. ..."
}
```
Tool/resource/prompt names in `scopeMapping` support glob patterns — `"*"` matches everything, `"docs://*"` matches all document resources.
### Provider Presets
| File | Provider | Validation |
|---|---|---|
| `config/default.json` | Local dev | JWKS (dev keys) |
| `config/auth0.example.json` | Auth0 | JWKS RS256 |
| `config/keycloak.example.json` | Keycloak | JWKS RS256, ES256 |
---
## HTTP API Reference
| Method | Path | Auth Required | Purpose |
|---|---|---|---|
| `POST` | `/mcp` | Yes (Bearer token) | JSON-RPC requests |
| `GET` | `/mcp` | Session ID | Server-Sent Events stream |
| `DELETE` | `/mcp` | Session ID | Terminate a session |
| `GET` | `/health` | No | Health check |
| `GET` | `/.well-known/oauth-protected-resource` | No | RFC 9728 metadata |
| `GET` | `/.well-known/oauth-protected-resource/mcp` | No | Path-specific metadata |
---
## Capabilities Inventory
### Tools (18)
| Category | Tool | Description | Scope |
|---|---|---|---|
| **Utility** | `echo` | Text echo — connectivity check | Public |
| | `calculate` | add, subtract, multiply, divide, power, sqrt | Public |
| | `current_time` | Server time in any IANA timezone (structured output) | Public |
| | `random_number` | Random integer in [min, max] range | Public |
| | `summarize_list` | count, sum, min, max, mean, median, stddev | Public |
| **Data Ops** | `search_documents` | Full-text search across 15+ articles | `mcp:read` |
| | `query_analytics` | DAU, revenue, churn, regional data, feature usage | `mcp:read` |
| | `create_document` | Create a new KB article | `mcp:write` |
| | `update_document` | Update an existing KB article | `mcp:write` |
| | `list_projects` | List/filter 12+ mock projects | `mcp:read` |
| | `create_project` | Create a new project | `mcp:write` |
| | `update_project` | Update project status/progress | `mcp:write` |
| **File System** | `list_files` | Browse mock directory tree | `mcp:files:read` |
| | `read_file` | Read mock file contents | `mcp:files:read` |
| | `upload_file` | Create/overwrite a mock file | `mcp:files:write` |
| **Admin** | `get_server_stats` | Uptime, requests, memory, auth info | `mcp:admin` |
| | `reload_config` | Hot-reload OAuth config from disk | `mcp:admin` |
| | `list_server_capabilities` | List all tools, resources, prompts | `mcp:admin` |
### Resources (10)
| URI | Description | Scope |
|---|---|---|
| `config://app` | Server config and feature flags (JSON) | Public |
| `docs://about` | About this server (Markdown) | Public |
| `docs://articles/{id}` | KB article by ID | `mcp:read` |
| `docs://articles/recent` | 10 most recent articles | `mcp:read` |
| `analytics://dashboard` | Current analytics snapshot | `mcp:read` |
| `analytics://users/{region}` | Regional user/revenue data | `mcp:read` |
| `projects://{id}` | Project details by ID (listed) | `mcp:read` |
| `files://{path}` | Mock file content by path | `mcp:files:read` |
| `user://me` | Current user profile from token | `mcp:read` |
| `system://health` | Server health and auth info | `mcp:admin` |
### Prompts (5)
| Prompt | Arguments | Description | Scope |
|---|---|---|---|
| `summarize` | `text`, `style` | Summarize text (bullet/paragraph/tweet/executive) | `mcp:read` |
| `code_review` | `language`, `code`, `focus` | Structured code review with senior engineer persona | `mcp:read` |
| `brainstorm` | `topic`, `count`, `perspective` | Guided brainstorming session | `mcp:read` |
| `data_explore` | `dataset`, `question` | Data exploration with tool guidance | `mcp:read` |
| `refactor` | `language`, `code`, `goal` | Structured refactoring with architect persona | `mcp:write` |
---
## Using with Claude Desktop (stdio)
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"oauth-mcp-server": {
"command": "node",
"args": ["C:\\Users\\Shylendra\\git\\oauth-mcp-server\\dist\\stdio.js"]
}
}
}
```
The stdio transport does **not** use OAuth per the MCP spec — it runs in full-access mode. For OAuth, use the HTTP transport.
---
## Testing
```powershell
npm run test:smoke # stdio transport — validates all 18 tools, 10 resources, 5 prompts
npm run test:vercel # Vercel serverless handler
```
### MCP Inspector
```powershell
npm run inspect # stdio transport
```
Or for HTTP: start the server, then open the Inspector and connect with **Transport type: Streamable HTTP**, **URL: http://localhost:6000/mcp**.
---
## Project Layout
```text
src/
├── server.ts # McpServer factory (auth-aware)
├── stdio.ts # stdio transport entry point
├── http.ts # Streamable HTTP entry point (OAuth)
├── banner.ts # Startup banner
├── logging.ts # Structured JSON logging
├── auth/
│ ├── index.ts # Public API surface
│ ├── types.ts # ValidatedToken, AuthContext, etc.
│ ├── token-validator.ts # JWT/JWKS + introspection
│ ├── scope-resolver.ts # OAuth scopes → MCP permissions
│ ├── discovery.ts # RFC 9728 metadata + WWW-Authenticate
│ └── errors.ts # 401/403/400 error responses
├── config/
│ ├── types.ts # Config type definitions
│ └── loader.ts # Config loading + Zod validation
├── capabilities/
│ ├── tools/ # 18 tools in 4 categories
│ ├── resources/index.ts # 10 resources
│ └── prompts/index.ts # 5 prompts
└── data/
├── users.ts # 8 mock users
├── documents.ts # 15+ knowledge base articles
├── projects.ts # 12+ mock projects
├── analytics.ts # 30-day metrics, regional data
└── files.ts # Mock directory tree
config/
├── default.json # Dev config (port 6000)
├── auth0.example.json # Auth0 preset
└── keycloak.example.json # Keycloak preset
api/
├── health.ts # Vercel health endpoint
└── mcp.ts # Vercel serverless OAuth handler
scripts/
├── smoke.mjs # stdio smoke test
├── smoke-http.mjs # HTTP smoke test
├── smoke-vercel.ts # Vercel smoke test
└── gen-jwks.mjs # Dev JWKS generator
```
---
## Standards Compliance
- **MCP 2026-07-28** — Full spec: `server/discover`, per-request `_meta`, stateless auth
- **MCP 2025-11-25** — Backward compatible `initialize` handshake
- **OAuth 2.1** (draft-ietf-oauth-v2-1-13) — Resource server role
- **RFC 6750** — Bearer Token Usage
- **RFC 8414** — OAuth 2.0 Authorization Server Metadata
- **RFC 8707** — Resource Indicators for OAuth 2.0
- **RFC 9207** — Authorization Server Issuer Identification
- **RFC 9728** — OAuth 2.0 Protected Resource Metadata
## License
MIT
TDQS
Scored across 18 tools
Each tool performs a distinct action on a distinct resource (e.g., echo vs calculate vs read_file vs create_project). Descriptions are explicit and leave no ambiguity about what each tool does.
The vast majority of tools follow a verb_noun pattern (read_file, create_document, list_projects, upload_file). A few outliers (echo, calculate, current_time, random_number) use bare verbs or noun phrases, causing minor inconsistency.
With 18 tools, the set feels bloated for an OAuth-oriented server. Many tools (echo, calculate, current_time, random_number, summarize_list) are generic utilities unrelated to OAuth, making the count disproportionate to the server's stated purpose.
The server lacks any OAuth-specific tools such as authorize, token, or client management. Even within the mock domains (files, documents, projects), operations like delete or single-item retrieval are missing, leaving significant gaps in the tool surface.