Skip to main content
Glama
Shylendra

oauth-mcp-server

by Shylendra
README.md
# 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

B3.2/5.0

Scored across 18 tools

Disambiguation5/5

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.

Naming Consistency4/5

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.

Tool Count2/5

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.

Completeness1/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues