refactor, improve docs

This commit is contained in:
Basil Hosmer 2025-10-02 18:21:12 -04:00
commit 112997d352
No known key found for this signature in database
92 changed files with 30079 additions and 1640 deletions

View file

@ -1,4 +0,0 @@
AUTH_MODE=integrated
BASE_URI=http://localhost:3232
PORT=3232
REDIS_URL=redis://localhost:6379

View file

@ -1,6 +0,0 @@
AUTH_MODE=separate
BASE_URI=http://localhost:3232
PORT=3232
REDIS_URL=redis://localhost:6379
AUTH_SERVER_URL=http://localhost:3001
AUTH_SERVER_PORT=3001

435
README.md
View file

@ -1,4 +1,4 @@
# MCP Everything Server
# MCP Feature Reference Server
_Note: these docs were AI generated based on a claude code transcript, and then edited manually for accuracy_
@ -6,7 +6,7 @@ A comprehensive example implementation of a scalable Model Context Protocol (MCP
## Overview
The Everything Server is an open-source reference implementation that showcases:
The Feature Reference Server is an open-source reference implementation that showcases:
- **Complete [MCP Protocol](https://modelcontextprotocol.io/specification) Support**: All MCP features including tools, resources, prompts, sampling, completions, and logging
- **Multiple [Transport Methods](https://modelcontextprotocol.io/docs/concepts/transports)**: Streamable HTTP (SHTTP) and Server-Sent Events (SSE)
- **Dual Authentication Modes**: Integrated and separate authorization server support
@ -14,6 +14,23 @@ The Everything Server is an open-source reference implementation that showcases:
This server serves as both primarily as a learning resource, and an example implementation of a scalable remote MCP server.
## Repository Structure
This repository contains **two complete, standalone implementations** to help you learn different MCP deployment patterns:
```
embedded-oauth/ # Complete example: MCP server with integrated OAuth
└── src/ # All code in one place, ready to run
external-oauth/
├── auth-server/ # Standalone OAuth authorization server
│ └── src/
└── mcp-server/ # MCP server that delegates to external auth
└── src/
```
Both modes are complete, independently buildable implementations with full test coverage. For more information on the difference between embedded and external OAuth, see [Authentication Modes](#authentication-modes).
## Quick Start
Get the server running in 5 minutes:
@ -27,12 +44,13 @@ orbctl start # macOS: Start OrbStack daemon
# 2. Setup
git clone https://github.com/modelcontextprotocol/example-remote-server.git
cd example-remote-server
npm install
cp .env.integrated .env # Configure for integrated mode (see Authentication Modes for details)
npm install # Install dependencies for all workspaces
# 3. Start services
docker compose up -d # Start Redis
npm run dev # Start server
npm run dev:embedded # Start embedded-oauth server
# OR
npm run dev:external # Start both auth-server and mcp-server
# 4. Test with Inspector
npx -y @modelcontextprotocol/inspector
@ -120,16 +138,22 @@ sudo apt-get install redis-server && sudo systemctl start redis
```bash
git clone https://github.com/modelcontextprotocol/example-remote-server.git
cd example-remote-server
npm install
npm install # Installs dependencies for all workspaces (embedded-oauth, auth-server, mcp-server)
```
### Step 3: Configure Environment
```bash
# Use integrated mode (default, simpler setup)
cp .env.integrated .env
### Step 3: Choose Your Mode
The repository includes two complete, standalone examples:
# OR use separate mode (for testing external auth)
cp .env.separate .env
**Option A: Embedded OAuth (Simpler)**
```bash
cd embedded-oauth
# Everything is configured by default in .env
```
**Option B: External OAuth (Production-like)**
```bash
cd external-oauth/auth-server # Auth server configuration in .env
cd external-oauth/mcp-server # MCP server configuration in .env
```
### Step 4: Start Redis
@ -141,133 +165,103 @@ docker compose up -d
docker compose ps
```
### Step 5: Verify Installation
```bash
# Run the development server
npm run dev
### Step 5: Start and Verify
# Server should start on http://localhost:3232
**Embedded OAuth:**
```bash
npm run dev:embedded
# Server starts on http://localhost:3232
```
**External OAuth:**
```bash
npm run dev:external
# Auth server starts on http://localhost:3001
# MCP server starts on http://localhost:3232
```
## Configuration
Environment variables (`.env` file):
Each mode has its own `.env` file pre-configured:
**Embedded OAuth** (`embedded-oauth/.env`):
```bash
# Server Configuration
BASE_URI=http://localhost:3232 # MCP server URL
PORT=3232 # MCP server port
BASE_URI=http://localhost:3232 # Base URI for OAuth redirects
# Redis Configuration
REDIS_URL=redis://localhost:6379 # Redis connection URL
# Authentication Mode (integrated | separate)
AUTH_MODE=integrated # Default: integrated mode
# Separate Mode Configuration (only used when AUTH_MODE=separate)
AUTH_SERVER_URL=http://localhost:3001 # External auth server URL
AUTH_SERVER_PORT=3001 # Auth server port (for standalone server)
REDIS_URL=redis://localhost:6379 # Redis connection
```
**Pre-configured environment files:**
- `.env.integrated` - Configuration for integrated mode
- `.env.separate` - Configuration for separate mode
**External OAuth Auth Server** (`external-oauth/auth-server/.env`):
```bash
# Use integrated mode
cp .env.integrated .env
AUTH_SERVER_URL=http://localhost:3001 # Auth server URL
AUTH_SERVER_PORT=3001 # Auth server port
BASE_URI=http://localhost:3232 # MCP server URL (for redirects)
REDIS_URL=redis://localhost:6379 # Redis connection
```
# Use separate mode
cp .env.separate .env
**External OAuth MCP Server** (`external-oauth/mcp-server/.env`):
```bash
BASE_URI=http://localhost:3232 # MCP server URL
PORT=3232 # MCP server port
AUTH_SERVER_URL=http://localhost:3001 # External auth server URL
REDIS_URL=redis://localhost:6379 # Redis connection
```
## Authentication Modes
The Everything Server supports two authentication modes to demonstrate different MCP deployment patterns:
Per the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization), the authorization server may be hosted with the resource server or as a separate entity. This repository demonstrates both patterns.
### Integrated Mode (Default)
The MCP server acts as its own OAuth 2.0 authorization server. This configuration is simpler to deploy but requires the MCP server to host its own authorization logic. The implementation provided here is for demonstration purposes only.
### Embedded OAuth
**Location**: `embedded-oauth/`
OAuth authorization server hosted with the MCP resource server. Demonstrates self-hosted OAuth with upstream identity provider delegation (enterprise pattern where you control OAuth but delegate user authentication to corporate SSO/LDAP).
```bash
npm run dev:integrated
npm run dev:embedded # Single server on :3232
```
### Separate Mode
The MCP server delegates authentication to a standalone authorization server. This demonstrates how MCP servers can integrate with existing OAuth infrastructure. See [auth-server/README.md](auth-server/README.md) for more details about the standalone auth server.
See [embedded-oauth/README.md](embedded-oauth/README.md) for details.
### External OAuth
**Location**: `external-oauth/`
MCP resource server using a completely separate OAuth authorization server. Demonstrates the OAuth-as-a-Service pattern (modern SaaS pattern using Auth0, Okta, or similar providers).
```bash
# Start both the auth server and MCP server
npm run dev:with-separate-auth
# Or run them separately:
# Terminal 1: Start the authorization server
npm run dev:auth-server
# Terminal 2: Start the MCP server in separate mode
npm run dev:separate
npm run dev:external # Auth server on :3001, MCP server on :3232
```
In production, the separate authorization server would typically be replaced with:
- Corporate SSO (Auth0, Okta)
- Cloud providers (AWS Cognito, Azure AD)
- Social providers (Google, GitHub)
See [external-oauth/README.md](external-oauth/README.md) for architecture and implementation details.
## Development
### Quick Start
If you've completed installation, you're ready to develop:
```bash
# Integrated mode (MCP server handles auth)
npm run dev:integrated
# Separate mode (external auth server)
npm run dev:with-separate-auth
# Start servers
npm run dev:embedded # Embedded OAuth
npm run dev:external # External OAuth (both servers)
npm run dev:auth-server # External OAuth - auth server only
npm run dev:mcp-server # External OAuth - MCP server only
```
### Development Commands
### Build & Production
```bash
# Start development server with hot reload
npm run dev
npm run build # Build all workspaces
npm run build:embedded # Build embedded OAuth only
npm run build:external # Build external OAuth only
# Start in integrated mode (MCP server as OAuth server)
npm run dev:integrated
# Start in separate mode (external auth server)
npm run dev:separate
# Start standalone authorization server
npm run dev:auth-server
# Start both auth server and MCP server in separate mode
npm run dev:with-separate-auth
# Start development server with debugging
npm run dev:break
npm run start:embedded # Run embedded OAuth
npm run start:external # Run external OAuth (both servers)
```
#### Build & Production
### Testing & Quality
```bash
# Build TypeScript to JavaScript (builds both servers)
npm run build
npm test # All unit tests (189 total)
npm run lint # Lint all code
npm run typecheck # Typecheck all code
# Run production server
npm start
# Run production auth server
npm run start:auth-server
```
#### Testing & Quality
```bash
# Run linting
npm run lint
# Run unit tests
npm test
# Run end-to-end tests (automated server management)
npm run test:e2e:integrated # Test integrated mode OAuth + features
npm run test:e2e:separate # Test separate mode OAuth + features
npm run test:e2e # Run all e2e tests
npm run test:e2e:embedded # E2E test for embedded OAuth
npm run test:e2e:external # E2E test for external OAuth
```
### Testing with MCP Inspector
@ -277,12 +271,11 @@ The MCP Inspector is a web-based tool for testing MCP servers.
#### Prerequisites
1. Ensure Docker/OrbStack is running
2. Ensure Redis is running: `docker compose ps`
3. Ensure environment is configured: Check `.env` file exists
#### Test Integrated Mode
#### Test Embedded OAuth
```bash
# 1. Start the server (Redis must already be running)
npm run dev:integrated
npm run dev:embedded
# 2. Launch MCP Inspector in a new terminal
npx -y @modelcontextprotocol/inspector
@ -291,10 +284,10 @@ npx -y @modelcontextprotocol/inspector
# 4. Navigate to Auth tab and complete OAuth flow
```
#### Test Separate Mode
#### Test External OAuth
```bash
# 1. Start both servers (Redis must already be running)
npm run dev:with-separate-auth
npm run dev:external
# 2. Launch MCP Inspector in a new terminal
npx -y @modelcontextprotocol/inspector
@ -303,54 +296,22 @@ npx -y @modelcontextprotocol/inspector
# 4. Auth flow will redirect to :3001 for authentication
```
### Running Tests
```bash
# Run all tests
npm test
# Run specific test suites
npm test -- --testNamePattern="User Session Isolation"
npm test -- --testNamePattern="session ownership"
# Run with coverage
npm test -- --coverage
```
### Test Categories
- **Unit Tests**: Individual component testing
- **Unit Tests**: Component testing (189 tests across all workspaces)
- **Integration Tests**: Transport and Redis integration
- **Auth Tests**: OAuth flow and session ownership
- **Multi-user Tests**: User isolation and access control
- **E2E Tests**: Complete OAuth + MCP feature verification
### Automated End-to-End Testing
### End-to-End Testing
The `scripts/` directory contains automated test scripts that verify the complete OAuth flow and all MCP features:
E2E scripts in `scripts/` verify complete OAuth flows and MCP features:
#### Scripts
- **`test-integrated-e2e.sh`** - Tests integrated mode (MCP server as OAuth server)
- **`test-separate-e2e.sh`** - Tests separate mode (external auth server)
#### What the scripts test:
- Complete OAuth 2.0 + PKCE flow from client registration to token usage
- All MCP features: tools (7), resources (100 with pagination), prompts (3)
- Session management and proper error handling
- README claim verification
#### Usage
```bash
# Recommended: Automated testing (handles server lifecycle)
npm run test:e2e:integrated # Tests integrated mode
npm run test:e2e:separate # Tests separate mode
# Advanced: Manual script execution (requires manual server setup)
./scripts/test-integrated-e2e.sh
./scripts/test-separate-e2e.sh
npm run test:e2e:embedded # Test embedded OAuth
npm run test:e2e:external # Test external OAuth
npm run test:e2e # Run both
```
The npm scripts automatically start required servers, run tests, and clean up. Manual scripts require you to start Redis and servers first.
### Interactive Testing
Use the MCP Inspector for interactive testing and debugging of OAuth flows, tool execution, and resource access.
Scripts automatically manage server lifecycle, test OAuth 2.0 + PKCE flow, and verify all MCP features.
## Troubleshooting
@ -367,8 +328,8 @@ Use the MCP Inspector for interactive testing and debugging of OAuth flows, tool
- Ensure Docker/OrbStack is started first
**"Missing .env file"**
- Run `cp .env.integrated .env` for default setup
- Or `cp .env.separate .env` for separate auth mode
- Each mode directory has a pre-configured `.env` file
- embedded-oauth/.env, external-oauth/auth-server/.env, external-oauth/mcp-server/.env
**"Port already in use"**
- Check for existing processes: `lsof -i :3232` or `lsof -i :3001`
@ -380,15 +341,15 @@ Use the MCP Inspector for interactive testing and debugging of OAuth flows, tool
- Delete node_modules and package-lock.json, then retry
**"Authentication flow fails"**
- Check the server logs for error messages
- Ensure Redis is running and accessible
- Verify .env configuration matches your setup mode
- Check server logs for error messages
- Ensure Redis is running: `docker compose ps`
- Verify .env configuration in the mode directory you're running
## Architecture & Technical Details
### Authentication Architecture
#### Integrated Mode
#### Embedded OAuth
```mermaid
graph TD
Client["MCP Client<br/>(Inspector)"]
@ -397,7 +358,7 @@ graph TD
Client <-->|"OAuth flow & MCP resources"| MCP
```
#### Separate Mode
#### External OAuth
```mermaid
graph TD
Client["MCP Client<br/>(Inspector)"]
@ -410,97 +371,63 @@ graph TD
MCP <-->|"Token validation<br/>(introspect)"| Auth
```
### OAuth 2.0 + PKCE Flow Analysis
### OAuth Flow
The server implements a complete OAuth 2.0 authorization code flow with PKCE. Here's how each step maps to data storage and expiry:
**1. Client Registration** (app setup - happens once)
```
App → Auth Server: "I want to use OAuth, here's my info"
Auth Server → App: "OK, your client_id is XYZ, client_secret is ABC"
```
- **Storage**: Client credentials for future OAuth flows
- **Expiry**: 30 days (long-lived app credentials)
**2. Authorization Request** (starts each OAuth flow)
```
User → App: "I want to connect to MCP server"
App → Auth Server: "User wants access, here's my PKCE challenge"
Auth Server: Stores pending authorization, shows auth page
```
- **Storage**: `PENDING_AUTHORIZATION` - temporary state during flow
- **Expiry**: 10 minutes (short-lived temporary state)
**3. Authorization Code Exchange** (completes OAuth flow)
```
User → Auth Server: "I approve this app"
Auth Server → App: "Here's your authorization code"
App → Auth Server: "Exchange code + PKCE verifier for tokens"
Auth Server → App: "Here are your access/refresh tokens"
```
- **Storage**: `TOKEN_EXCHANGE` - prevents replay attacks
- **Expiry**: 10 minutes (single-use, consumed immediately)
**4. Token Storage** (long-term user session)
```
Auth Server: Issues access_token + refresh_token
Server: Stores user installation with tokens
```
- **Storage**: `UPSTREAM_INSTALLATION` - the actual user session
- **Expiry**: 7 days (balances security vs usability)
**5. Token Refresh** (extends user session)
```
App → Auth Server: "My access token expired, here's my refresh token"
Auth Server → App: "Here's a new access token"
```
- **Storage**: `REFRESH_TOKEN` - mapping for token rotation
- **Expiry**: 7 days (matches installation lifetime)
#### Data Lifecycle Hierarchy
**Timeline (shortest to longest expiry):**
1. **OAuth flow state** (10 minutes) - very temporary
2. **User sessions** (7 days) - medium-term
3. **Client credentials** (30 days) - long-term
This creates a logical hierarchy where each layer outlives the layers it supports.
Both modes implement OAuth 2.1 with PKCE. For detailed flow analysis including data storage, TTLs, and mode-specific differences, see [docs/oauth-flow.md](docs/oauth-flow.md).
### Project Structure
```
├── src/ # MCP server code
│ ├── index.ts # Express app setup and routes
│ ├── config.ts # Configuration management
│ ├── redis.ts # Redis client setup
│ ├── auth/
│ │ ├── provider.ts # OAuth auth provider implementation
│ │ └── external-verifier.ts # External token verification
│ ├── handlers/
│ │ ├── shttp.ts # Streamable HTTP handler
│ │ ├── sse.ts # SSE transport handler
│ │ ├── fakeauth.ts # Fake upstream auth handler
│ │ └── common.ts # Shared middleware
│ ├── services/
│ │ ├── mcp.ts # MCP server implementation
│ │ ├── auth.ts # Auth service wrappers
│ │ └── redisTransport.ts # Redis-backed transport
│ └── utils/
│ └── logger.ts # Structured logging
├── auth-server/ # Standalone authorization server
│ ├── index.ts # Auth server main entry point
│ ├── README.md # Auth server documentation
│ └── tsconfig.json # TypeScript configuration
├── shared/ # Shared between both servers
│ ├── auth-core.ts # Core auth logic
│ ├── redis-auth.ts # Redis auth operations
│ └── types.ts # Shared type definitions
├── embedded-oauth/ # Complete integrated auth example
│ ├── src/
│ │ ├── index.ts # Main entry point (MCP + OAuth)
│ │ ├── auth/
│ │ │ ├── provider.ts # FeatureReferenceAuthProvider
│ │ │ └── auth-core.ts # Token generation, PKCE
│ │ ├── services/
│ │ │ ├── mcp.ts # MCP server implementation
│ │ │ ├── auth.ts # Auth service wrappers
│ │ │ ├── redis-auth.ts # Redis auth operations
│ │ │ └── redisTransport.ts # Redis-backed transport
│ │ ├── handlers/ # SHTTP, SSE, mock-upstream-idp handlers
│ │ ├── utils/logger.ts # Structured logging
│ │ └── ...
│ ├── package.json
│ ├── tsconfig.json
│ └── .env
│
├── external-oauth/
│ ├── auth-server/ # Standalone OAuth server
│ │ ├── src/
│ │ │ ├── index.ts # Auth server entry point
│ │ │ ├── auth/
│ │ │ │ ├── provider.ts # FeatureReferenceAuthProvider
│ │ │ │ └── auth-core.ts
│ │ │ ├── services/ # Auth services, redis-auth
│ │ │ ├── handlers/ # Mock upstream auth
│ │ │ └── ...
│ │ ├── package.json
│ │ └── .env
│ │
│ └── mcp-server/ # MCP server with external auth
│ ├── src/
│ │ ├── index.ts # MCP server entry point
│ │ ├── auth/
│ │ │ ├── external-verifier.ts # Token introspection
│ │ │ └── auth-core.ts
│ │ ├── services/ # MCP server, redisTransport
│ │ ├── handlers/ # SHTTP, SSE handlers
│ │ └── ...
│ ├── package.json
│ └── .env
│
├── scripts/ # End-to-end testing scripts
│ ├── test-integrated-e2e.sh # OAuth + feature verification (integrated)
│ └── test-separate-e2e.sh # OAuth + feature verification (separate)
├── docs/
│ ├── streamable-http-design.md # SHTTP implementation details
│ └── user-id-system.md # Authentication flow documentation
└── dist/ # Compiled JavaScript output
├── package.json # Root workspace configuration
└── docker-compose.yml # Redis service
```
### Scalability Architecture
@ -560,23 +487,7 @@ Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2
## API Reference
### MCP Endpoints
- `GET/POST/DELETE /mcp` - Streamable HTTP transport endpoint
- `POST`: Initialize sessions or send messages
- `GET`: Establish SSE streams
- `DELETE`: Terminate sessions
- `GET /sse` - Legacy SSE transport endpoint
- `POST /message` - Legacy message endpoint for SSE transport
### Authentication Endpoints (Integrated Mode Only)
- `GET /fakeupstreamauth/authorize` - Fake OAuth authorization page
- `GET /fakeupstreamauth/callback` - OAuth redirect handler
- OAuth 2.0 endpoints provided by MCP SDK auth router
### Headers
- `Mcp-Session-Id`: Session identifier for Streamable HTTP
- `Authorization: Bearer <token>`: OAuth access token
- Standard MCP headers as per protocol specification
For a complete listing of all endpoints provided by each server configuration, including OAuth authorization endpoints, MCP resource endpoints, and demo identity provider endpoints, see [docs/endpoints.md](docs/endpoints.md).
## Security
@ -660,9 +571,11 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
Built by the Model Context Protocol team as a reference implementation for the MCP ecosystem.
## Links
## References
### MCP Documentation
- [Model Context Protocol Documentation](https://modelcontextprotocol.io)
- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
- [MCP Specification](https://modelcontextprotocol.io/specification)
- [MCP Concepts](https://modelcontextprotocol.io/docs/concepts)
- [Tools](https://modelcontextprotocol.io/docs/concepts/tools)
@ -671,4 +584,16 @@ Built by the Model Context Protocol team as a reference implementation for the M
- [Sampling](https://modelcontextprotocol.io/docs/concepts/sampling)
- [Transports](https://modelcontextprotocol.io/docs/concepts/transports)
- [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [Example Servers](https://github.com/modelcontextprotocol/servers)
### OAuth 2.0 / 2.1 Resources
- [RFC 6749: OAuth 2.0 Framework](https://datatracker.ietf.org/doc/html/rfc6749)
- [RFC 7662: Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662) (used in separate mode)
- [OAuth 2.1 Draft](https://oauth.net/2.1/) - Modern security best practices
- [OAuth.net: End User Authentication](https://oauth.net/articles/authentication/)
- [The Resource Server - OAuth 2.0 Simplified](https://www.oauth.com/oauth2-servers/the-resource-server/)
### Architecture Patterns
This implementation demonstrates patterns discussed in:
- [Stack Overflow: Separating Resource and Authorization Servers](https://stackoverflow.com/questions/16228193/oauth-2-separating-resource-server-and-authorization-server)
- [OAuth 2.0 Overview - Curity](https://curity.io/resources/learn/oauth-overview/)
- Microsoft identity platform documentation on [OAuth 2.0 flows](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow)

View file

@ -1,94 +0,0 @@
# MCP Standalone Authorization Server
This is a demonstration OAuth 2.0 authorization server for MCP's separate authentication mode.
## Purpose
This server demonstrates how MCP servers can delegate authentication to a separate authorization server. See the main [README Authentication Modes](../README.md#authentication-modes) section for a complete overview of integrated vs separate modes.
In production environments, you would typically use established OAuth providers like:
- Auth0, Okta
- Google OAuth, GitHub OAuth
- Microsoft Azure AD, AWS Cognito
## Architecture
For detailed architecture information and OAuth flow analysis, see:
- [Authentication Modes](../README.md#authentication-modes) - Overview and comparison
- [OAuth 2.0 + PKCE Flow Analysis](../README.md#oauth-20--pkce-flow-analysis) - Step-by-step flow breakdown
- [Authentication Architecture](../README.md#authentication-architecture) - Visual diagrams
This auth server specifically implements the "Auth Server" component in the separate mode architecture diagram.
## Endpoints
- `/.well-known/oauth-authorization-server` - OAuth 2.0 server metadata
- `/authorize` - Authorization endpoint
- `/token` - Token endpoint
- `/register` - Dynamic client registration
- `/introspect` - Token introspection (for MCP server validation)
- `/fakeupstreamauth/authorize` - Fake upstream auth page (demo only)
- `/fakeupstreamauth/callback` - Fake upstream callback (demo only)
- `/health` - Health check endpoint
## Development
This server shares Redis with the MCP server for development convenience.
In production, these would typically be separate.
## Running the Auth Server
### Standalone
```bash
# From the repository root
npm run dev:auth-server
```
### With MCP Server (Separate Mode)
```bash
# Start both servers together
npm run dev:with-separate-auth
```
## Testing
### Health Check
```bash
curl http://localhost:3001/health
```
### OAuth Metadata
```bash
curl http://localhost:3001/.well-known/oauth-authorization-server
```
### With MCP Inspector
See the main [Testing with MCP Inspector](../README.md#testing-with-mcp-inspector) section for complete testing instructions for both modes.
**Quick test for this auth server:**
1. Start this auth server: `npm run dev:auth-server`
2. Start MCP server in separate mode: `AUTH_MODE=separate npm run dev`
3. Follow the separate mode testing steps in the main README
## Configuration
The auth server uses the same configuration system as the main server. See [Configuration](../README.md#configuration) in the main README for complete environment variable documentation.
**Auth server specific variables:**
- `AUTH_SERVER_PORT` - Port to run on (default: 3001)
- `AUTH_SERVER_URL` - Base URL (default: http://localhost:3001)
- `REDIS_URL` - Redis connection (shared with MCP server)
## Production Considerations
**This server is for demonstration only.** In production, use established OAuth providers.
For comprehensive security and deployment guidance, see:
- [Security](../README.md#security) - Security measures and best practices
- [Configuration](../README.md#configuration) - Environment setup
- [Monitoring & Debugging](../README.md#monitoring--debugging) - Operational guidance
**Production replacement options:**
- Corporate SSO (Auth0, Okta)
- Cloud providers (AWS Cognito, Azure AD)
- Social providers (Google OAuth, GitHub OAuth)

154
docs/endpoints.md Normal file
View file

@ -0,0 +1,154 @@
# Endpoint Reference
Complete listing of all endpoints provided by each server configuration.
## Embedded OAuth Server (Port 3232)
Single server hosting OAuth authorization, mock identity provider, and MCP resources.
### OAuth Authorization Endpoints
Provided by `mcpAuthRouter` from MCP SDK:
- `GET /.well-known/oauth-authorization-server` - OAuth metadata discovery
- `POST /register` - Dynamic client registration
- `GET /authorize` - Authorization request (starts OAuth flow)
- `POST /token` - Token exchange (authorization code → tokens) and token refresh
- `POST /revoke` - Token revocation
### Mock Upstream Identity Provider Endpoints
Local simulation of upstream IDP (would be external in production):
- `GET /mock-upstream-idp/authorize` - Mock user authentication page
- `GET /mock-upstream-idp/callback` - IDP callback handler (returns userId)
**Note**: In production, the OAuth server would redirect to external URLs like `https://accounts.google.com` or `https://login.okta.com` instead of these local endpoints.
### MCP Resource Endpoints
#### Streamable HTTP Transport (Recommended)
- `GET /mcp` - Establish SSE stream for session
- `POST /mcp` - Initialize session or send messages
- `DELETE /mcp` - Terminate session
#### SSE Transport (Legacy)
- `GET /sse` - Establish SSE connection
- `POST /message` - Send messages to session
All MCP endpoints require `Authorization: Bearer <token>` header.
### Static Assets
- `GET /` - Splash page (HTML)
- `GET /mcp-logo.png` - MCP logo
- `GET /styles.css` - Stylesheet
---
## External OAuth - Auth Server (Port 3001)
Standalone OAuth authorization server (represents Auth0, Okta, etc. in production).
### OAuth Authorization Endpoints
Provided by `mcpAuthRouter` from MCP SDK:
- `GET /.well-known/oauth-authorization-server` - OAuth metadata discovery
- `POST /register` - Dynamic client registration
- `GET /authorize` - Authorization request
- `POST /token` - Token exchange and refresh
- `POST /revoke` - Token revocation
### Token Introspection
Custom implementation for resource server token validation:
- `POST /introspect` - Token introspection (RFC 7662)
- Called by MCP server to validate tokens
- Returns token status, scopes, expiry, user info
- Protected endpoint (not public)
### Mock Upstream Identity Provider Endpoints
Local simulation of upstream IDP:
- `GET /mock-upstream-idp/authorize` - Mock user authentication page
- `GET /mock-upstream-idp/callback` - IDP callback handler
**Note**: Commercial OAuth providers (Auth0, Okta) have their own user authentication systems. These endpoints simulate that functionality.
### Utility Endpoints
- `GET /health` - Health check (returns server status)
- `GET /mcp-logo.png` - Logo asset for auth pages
---
## External OAuth - MCP Server (Port 3232)
Pure MCP resource server with no OAuth authorization functionality.
### OAuth Metadata (Read-Only)
Provided by `mcpAuthMetadataRouter`:
- `GET /.well-known/oauth-authorization-server` - Returns metadata pointing to external auth server
- Tells clients to use auth server at :3001
- Read-only - no token issuance happens here
### MCP Resource Endpoints
#### Streamable HTTP Transport (Recommended)
- `GET /mcp` - Establish SSE stream for session
- `POST /mcp` - Initialize session or send messages
- `DELETE /mcp` - Terminate session
#### SSE Transport (Legacy)
- `GET /sse` - Establish SSE connection
- `POST /message` - Send messages to session
All MCP endpoints require `Authorization: Bearer <token>` header. Tokens are validated by calling the auth server's `/introspect` endpoint.
### Static Assets
- `GET /` - Splash page
- `GET /mcp-logo.png` - MCP logo
- `GET /styles.css` - Stylesheet
---
## Key Differences
| Endpoint Type | Embedded OAuth | External Auth Server | External MCP Server |
|---------------|----------------|---------------------|---------------------|
| OAuth authorization (`/authorize`, `/token`) | ✅ Full | ✅ Full | ❌ None |
| Token introspection (`/introspect`) | ❌ Not needed | ✅ Yes | ❌ Not needed |
| OAuth metadata discovery | ✅ Yes | ✅ Yes | ✅ Read-only redirect |
| Mock IDP (`/mock-upstream-idp`) | ✅ Yes | ✅ Yes | ❌ No |
| MCP resources (`/mcp`, `/sse`) | ✅ Yes | ❌ No | ✅ Yes |
---
## Headers
### Required Headers
**For MCP endpoints**:
- `Authorization: Bearer <access_token>` - OAuth access token (required)
- `Mcp-Session-Id: <session_id>` - Session identifier (for Streamable HTTP, after initialization)
**For OAuth endpoints**:
- `Content-Type: application/json` (for POST requests)
- `Content-Type: application/x-www-form-urlencoded` (for `/token` endpoint)
### Response Headers
**Streamable HTTP**:
- `Mcp-Session-Id` - Session ID (returned on initialization)
- `Content-Type: text/event-stream` - For SSE responses
**SSE Transport**:
- `Content-Type: text/event-stream`
- `Cache-Control: no-store, max-age=0`
- `Connection: keep-alive`
---
## References
- [MCP Transport Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports)
- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
- [RFC 7662: Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
- [OAuth 2.0 Endpoints](https://www.oauth.com/oauth2-servers/definitions/)

217
docs/oauth-flow.md Normal file
View file

@ -0,0 +1,217 @@
# OAuth 2.0 + PKCE Flow Analysis
This document details the complete OAuth 2.0 authorization code flow with PKCE as implemented in this reference server, including how it differs between integrated and separate modes.
## Flow Overview
The server implements OAuth 2.1 with PKCE (Proof Key for Code Exchange) for secure authorization. Here's how each step maps to data storage, expiry, and mode-specific behavior:
## 1. Client Registration
**Purpose**: Register the application with the OAuth server (happens once during app setup).
**Flow**:
```
App → Auth Server: POST /register {"client_name": "...", "redirect_uris": [...]}
Auth Server → App: {"client_id": "XYZ", "client_secret": "ABC", ...}
```
**Storage**:
- Redis key: `auth:client:{clientId}`
- Expiry: 30 days (long-lived app credentials)
**Mode differences**: None - identical in both modes.
---
## 2. Authorization Request
**Purpose**: User initiates connection to MCP server.
**Flow**:
```
App → Auth Server: GET /authorize?
client_id=XYZ
&redirect_uri=http://localhost:3000/callback
&code_challenge=<SHA256 of verifier>
&code_challenge_method=S256
&state=<CSRF token>
Auth Server: Saves pending authorization, shows authorization page
```
**Storage**:
- Redis key: `auth:pending:{authCode}`
- Expiry: 10 minutes (short-lived temporary state)
- Data: redirect_uri, code_challenge, code_challenge_method, client_id, state
**Mode differences**:
- **Embedded OAuth**: OAuth server runs on MCP server (port 3232)
- **External OAuth**: OAuth server runs independently (port 3001)
---
## 3. User Authentication & Authorization
**Purpose**: Authenticate the user and obtain consent.
**Flow** (identical in both modes):
```
OAuth Server → User: Shows auth page with "Continue to Authentication" button
User → OAuth Server: Clicks button
OAuth Server → Upstream IDP: Redirects to /mock-upstream-idp/authorize
Upstream IDP → User: Shows user selection UI
User → Upstream IDP: Selects/creates user ID
Upstream IDP → OAuth Server: Redirects to /mock-upstream-idp/callback?userId=X
OAuth Server: Validates user, issues authorization code
OAuth Server → App: Redirects to app's redirect_uri with code
```
**Mode differences**:
- **Embedded OAuth**: OAuth Server runs on MCP server (port 3232)
- **External OAuth**: OAuth Server runs independently (port 3001)
The flow itself is identical. Both modes delegate user authentication to an upstream IDP (simulated by `/mock-upstream-idp/*` endpoints, which represent corporate SSO or social login in production).
---
## 4. Authorization Code Exchange
**Purpose**: Exchange authorization code for access and refresh tokens.
**Flow**:
```
App → Auth Server: POST /token
grant_type=authorization_code
&client_id=XYZ
&client_secret=ABC
&code=<authorization code>
&redirect_uri=http://localhost:3000/callback
&code_verifier=<original random string>
Auth Server:
1. Validates code_verifier matches code_challenge (PKCE)
2. Validates redirect_uri matches original
3. Validates client credentials
4. Issues tokens
Auth Server → App:
{
"access_token": "...",
"refresh_token": "...",
"expires_in": 604800,
"token_type": "Bearer"
}
```
**Storage**:
- `auth:exch:{authCode}` - Token exchange record (prevents replay attacks)
- `auth:installation:{accessToken}` - Active MCP installation
- `auth:refresh:{refreshToken}` - Refresh token mapping
- Expiry: 10 minutes for exchange record, 7 days for installation
**Mode differences**: None in the exchange itself. The endpoint location differs:
- **Embedded OAuth**: `http://localhost:3232/token`
- **External OAuth**: `http://localhost:3001/token`
---
## 5. Using Access Tokens
**Purpose**: Access MCP resources with the token.
**Flow**:
```
App → MCP Server: POST /mcp
Authorization: Bearer <access_token>
Mcp-Session-Id: <session_id>
{MCP request}
MCP Server: Validates token → Serves MCP resource
```
**Token Validation - Embedded OAuth**:
```
MCP Server (same process as OAuth server):
- Reads auth:installation:{accessToken} from Redis
- Validates expiry, scopes
- Returns user info directly
```
**Token Validation - External OAuth**:
```
MCP Server:
- Calls POST {AUTH_SERVER_URL}/introspect with token
- Auth server validates and returns token info
- MCP server validates audience matches BASE_URI
- Proceeds with request
```
**Mode differences**: This is the key architectural difference:
- **Embedded OAuth**: In-process token validation (fast, direct Redis access)
- **External OAuth**: Remote token validation via HTTP introspection (adds network hop, follows RFC 7662)
---
## 6. Token Refresh
**Purpose**: Obtain new access token when current one expires.
**Flow**:
```
App → Auth Server: POST /token
grant_type=refresh_token
&refresh_token=<refresh token>
&client_id=XYZ
&client_secret=ABC
Auth Server:
1. Validates refresh_token
2. Issues new access_token (and optionally new refresh_token)
3. Updates installation record
Auth Server → App: {"access_token": "...", ...}
```
**Storage**:
- Reads: `auth:refresh:{refreshToken}` → gets access token
- Reads: `auth:installation:{accessToken}` → gets installation
- Writes: New `auth:installation:{newAccessToken}`
- Writes: New `auth:refresh:{newRefreshToken}`
- Expiry: 7 days
**Mode differences**: None - identical flow, just different endpoint locations.
---
## Data Lifecycle Hierarchy
**Timeline (shortest to longest expiry):**
1. **OAuth flow state** (10 minutes) - `auth:pending`, `auth:exch`
2. **User sessions** (7 days) - `auth:installation`, `auth:refresh`
3. **Client credentials** (30 days) - `auth:client`
This hierarchy ensures each layer outlives the layers it supports.
---
## Security: PKCE (Proof Key for Code Exchange)
PKCE prevents authorization code interception attacks:
1. **Authorization request**: Client sends `code_challenge` (SHA256 hash of random string)
2. **Token exchange**: Client must provide original `code_verifier`
3. **Server validates**: SHA256(code_verifier) must equal stored code_challenge
This ensures only the client that initiated the flow can exchange the code, even if the code is intercepted.
**Mode differences**: None - PKCE works identically in both modes.
---
## References
- [RFC 6749: OAuth 2.0 Framework](https://datatracker.ietf.org/doc/html/rfc6749)
- [RFC 7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
- [RFC 7662: Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)

145
docs/session-ownership.md Normal file
View file

@ -0,0 +1,145 @@
# Session Ownership System
## Overview
The MCP server implements multi-user session isolation to ensure users can only access their own MCP sessions. This is accomplished using user identifiers and Redis-backed ownership tracking.
## How It Works
Each MCP session is owned by a specific user. Session ownership is stored in Redis and validated on each request.
### User ID Assignment
**Production**: OAuth providers assign unique user identifiers via the `sub` (subject) claim - a standard OAuth/OpenID Connect field.
User ID sources:
- **Provider's user database**: Users created in Auth0/Okta (example: `auth0|507f1f77bcf86cd799439011`)
- **Social/federated identities**: Provider links to Google, Facebook (example: `google-oauth2|112233445566778899`)
- **Enterprise connections**: SAML, LDAP, Active Directory (example: `samlp|ad|john.doe@company.com`)
**This implementation**: Mock upstream IDP returns HTML with JavaScript that generates a random UUID in the browser, stores it in `localStorage.mcpUserId`, and sends it to the server as a query parameter. This simulates user authentication for testing - different browsers/tabs get different UUIDs, allowing multi-user testing.
### Token Validation
When the MCP server validates an access token:
**Embedded OAuth**: Reads `McpInstallation` from Redis, extracts `installation.userId`
**External OAuth**: Calls auth server's `/introspect` endpoint, receives `sub` field
Both result in `AuthInfo.extra.userId` being populated.
### Session Creation
```typescript
const userId = req.auth?.extra?.userId;
const sessionId = randomUUID();
await redisClient.set(`session:${sessionId}:owner`, userId);
```
### Session Access Validation
```typescript
const owner = await redisClient.get(`session:${sessionId}:owner`);
if (owner !== userId) {
throw new Error('Session not owned by user');
}
```
## Redis Key Structure
### Session Ownership
```
session:{sessionId}:owner → userId
```
Example: `session:550e8400-...:owner` → `auth0|507f1f77bcf86cd799439011`
### Session Liveness
Sessions are considered "live" when an MCP server process is actively handling them. This is tracked via Redis pub/sub subscription counts:
```
mcp:shttp:toserver:{sessionId} → pub/sub channel
```
Check if live: `PUBSUB NUMSUB mcp:shttp:toserver:{sessionId}` returns > 0
When an MCP server starts handling a session, it subscribes to the channel. When it shuts down (gracefully or via crash), Redis automatically removes the subscription.
## Security
- **Session isolation**: Users can only access sessions they own
- **Ownership persistence**: Survives across requests and server restarts
- **Token-based validation**: User ID extracted from validated OAuth token
- **Access control**: All operations (GET, POST, DELETE) validate ownership
## Implementation
### Core Functions (src/services/redisTransport.ts)
```typescript
export async function setSessionOwner(sessionId: string, userId: string): Promise<void>
export async function isSessionOwnedBy(sessionId: string, userId: string): Promise<boolean>
export async function isLive(sessionId: string): Promise<boolean>
export async function shutdownSession(sessionId: string): Promise<void>
```
### Request Flow
```mermaid
sequenceDiagram
participant Client
participant MCP as MCP Server
participant Redis
Client->>MCP: POST /mcp (initialize)
MCP->>MCP: Extract userId from OAuth token
MCP->>MCP: Generate sessionId
MCP->>Redis: SET session:{sessionId}:owner = userId
MCP->>Client: Return sessionId
Client->>MCP: POST /mcp (with sessionId)
MCP->>MCP: Extract userId from OAuth token
MCP->>Redis: GET session:{sessionId}:owner
Redis-->>MCP: userId
MCP->>MCP: Verify userId matches token
MCP->>Client: Process request
```
## Configuration
### Environment Variables
```bash
REDIS_URL=redis://localhost:6379
BASE_URI=http://localhost:3232
```
### Redis Monitoring
```bash
# List all session owners
redis-cli KEYS "session:*:owner"
# Check specific session ownership
redis-cli GET "session:{sessionId}:owner"
# Check if session is live (actively being handled)
redis-cli PUBSUB NUMSUB "mcp:shttp:toserver:{sessionId}"
# Monitor session operations
redis-cli MONITOR | grep "session:"
```
## Testing
```bash
npm test -- --testNamePattern="User Session Isolation"
npm test -- --testNamePattern="session ownership"
```
## References
- [RFC 7662: Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662) - Standard for `sub` claim
- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)

View file

@ -1,468 +0,0 @@
# Design Document: Transport Implementation for Example Remote Server
## Current Implementation
The example remote server implements both MCP transport methods to support different client needs.
### Shared Infrastructure
All transports share the same foundational components:
1. **Authentication**: Uses `requireBearerAuth` middleware with mode-dependent auth providers
2. **Redis Integration**: Messages published/subscribed through Redis channels using session IDs
3. **Session Management**: Session ownership tracked via Redis for multi-user isolation
4. **MCP Server**: Common `createMcpServer()` provides tools, resources, and prompts
### SSE Transport Architecture
Legacy transport for backwards compatibility:
1. **SSE Endpoint**: `/sse` - Creates SSE connection using `SSEServerTransport`
2. **Message Endpoint**: `/message` - Receives POST requests and forwards them via Redis
3. **Session ID**: Generated by `SSEServerTransport`, used for Redis channel keys
4. **Message Flow**: POST to `/message` → Redis pub/sub → SSE stream to client
**Key Files:**
- `/src/index.ts:236` - SSE endpoint registration
- `/src/handlers/sse.ts` - SSE connection handler with Redis integration
- `/src/handlers/sse.ts:75-95` - Message POST handler
### Streamable HTTP Transport Architecture
Modern unified transport:
1. **Unified Endpoint**: `/mcp` - Handles GET, POST, DELETE with `StreamableHTTPServerTransport`
2. **Stateful Sessions**: Requires initialization with session ID tracking
3. **Response Format**: Returns results via Server-Sent Events streams
4. **Session Management**: Custom session ownership integration via Redis
**Key Files:**
- `/src/index.ts:240-242` - Streamable HTTP endpoint registration
- `/src/handlers/shttp.ts` - Complete Streamable HTTP handler
- `/src/services/redisTransport.ts` - Redis-backed transport integration
### Streamable HTTP Transport Specification (2025-03-26)
The new Streamable HTTP transport replaces the old HTTP+SSE approach with a single endpoint that supports:
1. **Single Endpoint**: One URL that handles GET, POST, and DELETE methods
2. **POST Requests**: Send JSON-RPC messages, can return either JSON responses or SSE streams
3. **GET Requests**: Open SSE streams for server-to-client messages
4. **Session Management**: Optional session IDs in `Mcp-Session-Id` headers
5. **Resumability**: Optional event storage with `Last-Event-ID` support
6. **Auth Integration**: Same authentication patterns as SSE
**Key Specification Requirements:**
- Accept header must include both `application/json` and `text/event-stream`
- Session ID management via `Mcp-Session-Id` headers
- 202 Accepted for notifications/responses only
- SSE streams or JSON responses for requests
- Security: Origin validation, localhost binding, proper auth
### TypeScript SDK Implementation
The SDK provides `StreamableHTTPServerTransport` with:
1. **Two Modes**:
- **Stateful**: Session ID generator provided, maintains sessions in memory
- **Stateless**: Session ID generator undefined, no session state
2. **Key Features**:
- Built-in session validation
- Event store support for resumability
- Automatic response correlation
- Auth info threading via `req.auth`
3. **Integration Patterns**:
- **Stateful**: Store transports by session ID, reuse across requests
- **Stateless**: New transport per request, immediate cleanup
- **Auth**: Same bearer auth middleware as SSE
## Implementation Plan
### 1. New Streamable HTTP Endpoint
Add `/mcp` endpoint that handles GET, POST, DELETE methods:
```typescript
// In src/index.ts
app.get("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP);
app.post("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP);
app.delete("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP);
```
### 2. Handler Implementation
Create new handler in `/src/handlers/mcp.ts`:
```typescript
export async function handleStreamableHTTP(req: Request, res: Response) {
// Use same Redis-based architecture as SSE transport
// but with StreamableHTTPServerTransport instead of SSEServerTransport
}
```
### 3. Transport Integration Strategy (Horizontally Scalable)
**Redis-based Session Management (Required for Horizontal Scaling)**
- Store session state in Redis, not in-memory
- Any server instance can handle any request for any session
- Session lifecycle independent of SSE connection lifecycle
- Message buffering in Redis when SSE connection is down
- Session TTL of 5 minutes to prevent Redis bloat
### 4. Redis Integration
Maintain current Redis architecture:
- Use session ID as Redis channel key
- Same message publishing/subscribing pattern
- Same MCP server creation logic
- Transport acts as bridge to Redis like current SSE implementation
### 5. Auth Integration
Use identical auth setup as SSE:
- Same `bearerAuth` middleware
- Same `authContext` middleware
- Same `EverythingAuthProvider`
- Auth info flows through `req.auth` to transport
### 6. Backwards Compatibility
Keep existing `/sse` and `/message` endpoints:
- Maintain current SSE transport for existing clients
- Add new `/mcp` endpoint alongside
- Both transports share same Redis infrastructure
- Same auth provider serves both
## Key Differences from Current SSE Implementation
1. **Single Endpoint**: `/mcp` handles all HTTP methods vs separate `/sse` + `/message`
2. **Transport Class**: `StreamableHTTPServerTransport` vs `SSEServerTransport`
3. **Session Headers**: `Mcp-Session-Id` headers vs URL session ID
4. **Request Handling**: Transport handles HTTP details vs manual SSE headers
5. **Response Correlation**: Built into transport vs manual request tracking
## Benefits of This Approach
1. **Spec Compliance**: Follows 2025-03-26 MCP specification exactly
2. **Minimal Changes**: Reuses existing Redis infrastructure and auth
3. **Feature Parity**: Same functionality as current SSE transport
4. **Future Proof**: Can add resumability with event store later
5. **Clean Integration**: Same auth patterns and middleware stack
## Implementation Steps
1. **Add Dependencies**: `StreamableHTTPServerTransport` from SDK
2. **Create Redis Session Management**: Implement `SessionManager` and `MessageDelivery` classes
3. **Create Handler**: New streamable HTTP handler function with Redis integration
4. **Add Routes**: New `/mcp` endpoint with all HTTP methods
5. **Session Management**: Redis-based session storage with TTL
6. **Message Buffering**: Redis-based message buffering for disconnected clients
7. **Testing**: Verify auth, Redis integration, horizontal scaling, and MCP protocol compliance
8. **Documentation**: Update README with new endpoint usage
### Additional Implementation Considerations
- **Redis Connection Management**: Ensure Redis connections are properly pooled and cleaned up
- **Error Handling**: Robust error handling for Redis operations and session timeouts
- **Monitoring**: Add logging for session creation, cleanup, and message buffering metrics
- **Performance**: Consider Redis memory usage and implement appropriate limits on message buffer size
- **Security**: Ensure session IDs are cryptographically secure and validate all session operations
## Technical Details
### Session Management Architecture (Redis-based)
**Redis Data Structures for Horizontal Scaling:**
```typescript
// Redis keys for session management
const SESSION_METADATA_KEY = (sessionId: string) => `session:${sessionId}:metadata`;
const SESSION_MESSAGES_KEY = (sessionId: string) => `session:${sessionId}:messages`;
const SESSION_CONNECTION_KEY = (sessionId: string) => `session:${sessionId}:connection`;
// Session metadata structure
interface SessionMetadata {
sessionId: string;
clientId: string;
createdAt: number;
lastActivity: number;
}
// Session lifecycle management
class SessionManager {
private static SESSION_TTL = 5 * 60; // 5 minutes in seconds
static async createSession(sessionId: string, clientId: string): Promise<void> {
const metadata: SessionMetadata = {
sessionId,
clientId,
createdAt: Date.now(),
lastActivity: Date.now()
};
// Store session metadata with TTL
await redisClient.set(
SESSION_METADATA_KEY(sessionId),
JSON.stringify(metadata),
{ EX: this.SESSION_TTL }
);
// Initialize empty message buffer
await redisClient.del(SESSION_MESSAGES_KEY(sessionId));
// Mark connection as disconnected initially
await redisClient.set(SESSION_CONNECTION_KEY(sessionId), 'disconnected', { EX: this.SESSION_TTL });
}
static async refreshSession(sessionId: string): Promise<boolean> {
const metadata = await this.getSessionMetadata(sessionId);
if (!metadata) return false;
// Update last activity and refresh TTL
metadata.lastActivity = Date.now();
await redisClient.set(
SESSION_METADATA_KEY(sessionId),
JSON.stringify(metadata),
{ EX: this.SESSION_TTL }
);
// Refresh other keys too
await redisClient.expire(SESSION_MESSAGES_KEY(sessionId), this.SESSION_TTL);
await redisClient.expire(SESSION_CONNECTION_KEY(sessionId), this.SESSION_TTL);
return true;
}
static async deleteSession(sessionId: string): Promise<void> {
await redisClient.del(SESSION_METADATA_KEY(sessionId));
await redisClient.del(SESSION_MESSAGES_KEY(sessionId));
await redisClient.del(SESSION_CONNECTION_KEY(sessionId));
}
static async getSessionMetadata(sessionId: string): Promise<SessionMetadata | null> {
const data = await redisClient.get(SESSION_METADATA_KEY(sessionId));
return data ? JSON.parse(data) : null;
}
// Mark SSE connection as connected/disconnected
static async setConnectionState(sessionId: string, connected: boolean): Promise<void> {
await redisClient.set(
SESSION_CONNECTION_KEY(sessionId),
connected ? 'connected' : 'disconnected',
{ EX: this.SESSION_TTL }
);
}
static async isConnected(sessionId: string): Promise<boolean> {
const state = await redisClient.get(SESSION_CONNECTION_KEY(sessionId));
return state === 'connected';
}
}
```
### Redis Integration Pattern with Message Buffering
The implementation extends the current Redis pattern to support message buffering:
```typescript
// Message delivery with buffering support
class MessageDelivery {
static async deliverMessage(sessionId: string, message: JSONRPCMessage): Promise<void> {
const isConnected = await SessionManager.isConnected(sessionId);
if (isConnected) {
// Direct delivery via existing Redis pub/sub
const redisChannel = `mcp:${sessionId}`;
await redisClient.publish(redisChannel, JSON.stringify(message));
} else {
// Buffer the message for later delivery
await redisClient.lpush(
SESSION_MESSAGES_KEY(sessionId),
JSON.stringify(message)
);
// Set TTL on the messages list
await redisClient.expire(SESSION_MESSAGES_KEY(sessionId), SessionManager.SESSION_TTL);
}
}
static async deliverBufferedMessages(sessionId: string, transport: StreamableHTTPServerTransport): Promise<void> {
// Get all buffered messages
const bufferedMessages = await redisClient.lrange(SESSION_MESSAGES_KEY(sessionId), 0, -1);
// Deliver buffered messages in order (reverse because lpush)
for (let i = bufferedMessages.length - 1; i >= 0; i--) {
const message = JSON.parse(bufferedMessages[i]);
await transport.send(message);
}
// Clear the buffer after delivery
await redisClient.del(SESSION_MESSAGES_KEY(sessionId));
}
}
// Enhanced Redis subscription for SSE connections
const setupRedisSubscription = async (sessionId: string, transport: StreamableHTTPServerTransport) => {
const redisChannel = `mcp:${sessionId}`;
const redisCleanup = await redisClient.createSubscription(
redisChannel,
async (message) => {
const jsonMessage = JSON.parse(message);
try {
await transport.send(jsonMessage);
} catch (error) {
console.error(`Failed to send message on transport for session ${sessionId}:`, error);
// Mark connection as disconnected so future messages get buffered
await SessionManager.setConnectionState(sessionId, false);
}
},
async (error) => {
console.error('Redis subscription error:', error);
await SessionManager.setConnectionState(sessionId, false);
}
);
return redisCleanup;
};
```
### Handler Implementation Flow
The new streamable HTTP handler integrates with the Redis-based session management:
```typescript
export async function handleStreamableHTTP(req: Request, res: Response) {
const method = req.method;
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (method === 'POST') {
// Handle POST requests (initialization or message sending)
if (isInitializeRequest(req.body) && !sessionId) {
// New session initialization
const newSessionId = randomUUID();
const authInfo = req.auth;
// Create session in Redis
await SessionManager.createSession(newSessionId, authInfo?.clientId || 'unknown');
// Create transport with Redis-based session management
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => newSessionId,
// Custom implementation - don't store transport in memory
});
const { server: mcpServer, cleanup: mcpCleanup } = createMcpServer();
// Set up Redis subscription for this session but don't store transport globally
// Instead, rely on Redis for all message routing
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
} else if (sessionId) {
// Existing session - validate and handle request
const sessionValid = await SessionManager.refreshSession(sessionId);
if (!sessionValid) {
res.writeHead(404).end(JSON.stringify({
jsonrpc: "2.0",
error: { code: -32001, message: "Session not found" },
id: null
}));
return;
}
// Create ephemeral transport for this request
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId, // Use existing session ID
});
const { server: mcpServer, cleanup: mcpCleanup } = createMcpServer();
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
// Clean up after request completes
res.on('close', mcpCleanup);
}
} else if (method === 'GET') {
// Handle SSE stream requests
if (!sessionId) {
res.writeHead(400).end('Session ID required');
return;
}
const sessionValid = await SessionManager.refreshSession(sessionId);
if (!sessionValid) {
res.writeHead(404).end('Session not found');
return;
}
// Create transport for SSE stream
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
});
// Mark connection as active
await SessionManager.setConnectionState(sessionId, true);
// Deliver any buffered messages first
await MessageDelivery.deliverBufferedMessages(sessionId, transport);
// Set up Redis subscription for live messages
const redisCleanup = await setupRedisSubscription(sessionId, transport);
// Handle connection cleanup
res.on('close', async () => {
await SessionManager.setConnectionState(sessionId, false);
redisCleanup();
});
await transport.handleRequest(req, res);
} else if (method === 'DELETE') {
// Handle session deletion
if (!sessionId) {
res.writeHead(400).end('Session ID required');
return;
}
// Delete session from Redis
await SessionManager.deleteSession(sessionId);
res.writeHead(200).end();
}
}
```
### Auth Information Flow
Auth information flows through the middleware stack:
```typescript
// Auth middleware adds req.auth
const authInfo: AuthInfo = req.auth;
// Transport receives auth info
await transport.handleRequest(req, res);
// Auth info is available in MCP server handlers
server.tool('example', 'description', schema, async (params, { authInfo }) => {
// authInfo contains token, clientId, scopes, etc.
});
```
## Conclusion
This design provides horizontally scalable streamable HTTP support by using Redis for all session state management and message buffering. Key advantages:
1. **Horizontal Scalability**: Any server instance can handle any request for any session
2. **Resilient Connection Handling**: SSE disconnects don't end sessions; messages are buffered
3. **Automatic Cleanup**: 5-minute session TTL prevents Redis bloat
4. **Backwards Compatibility**: Existing `/sse` and `/message` endpoints remain unchanged
5. **Spec Compliance**: Follows 2025-03-26 MCP specification exactly
The implementation is more complex than a single-instance approach but essential for production deployment in a horizontally scaled environment. The Redis-based architecture ensures sessions persist across server instances and SSE connection interruptions.

View file

@ -1,404 +0,0 @@
# User ID System Documentation
## Overview
The MCP server implements a comprehensive user identification and session ownership system that ensures secure multi-user access to MCP resources. This system integrates localStorage-based user management, OAuth authentication flows, and Redis-backed session isolation.
## Architecture Components
### 1. User ID Management (localStorage)
### 2. OAuth Authorization Flow
### 3. Redis Session Ownership
### 4. Session Access Validation
---
## 1. User ID Management (localStorage)
The fake upstream authentication system uses browser localStorage to manage user identities for testing and development purposes.
### localStorage Schema
```typescript
// Stored in browser localStorage
{
"mcpUserId": "550e8400-e29b-41d4-a716-446655440000" // UUID v4
}
```
### User ID Generation Flow
```mermaid
sequenceDiagram
participant Browser
participant LocalStorage
participant AuthPage as Fake Auth Page
Browser->>AuthPage: Load /fakeupstreamauth/authorize
AuthPage->>LocalStorage: Check mcpUserId
alt User ID exists
LocalStorage-->>AuthPage: Return existing UUID
AuthPage->>Browser: Display existing ID
else User ID missing
AuthPage->>AuthPage: Generate new UUID v4
AuthPage->>LocalStorage: Store new mcpUserId
AuthPage->>Browser: Display new ID
end
Note over AuthPage: User can edit or regenerate ID
AuthPage->>LocalStorage: Update mcpUserId (if changed)
```
### User ID Operations
| Operation | Description | Implementation |
|-----------|-------------|----------------|
| **Generate** | Create new UUID v4 | `generateUUID()` function |
| **Retrieve** | Get existing or create new | `getUserId()` function |
| **Update** | Edit existing ID | `editUserId()` function |
| **Persist** | Store in localStorage | `localStorage.setItem('mcpUserId', userId)` |
---
## 2. OAuth Authorization Flow
The OAuth flow integrates user IDs from localStorage into the MCP authorization process.
### Complete OAuth Flow with User ID
```mermaid
sequenceDiagram
participant Client
participant MCPServer as MCP Server
participant AuthPage as Auth Page
participant FakeAuth as Fake Upstream Auth
participant LocalStorage
participant Redis
Client->>MCPServer: Request authorization
MCPServer->>AuthPage: Redirect to auth page
AuthPage->>Client: Show MCP authorization page
Client->>FakeAuth: Click "Continue to Authentication"
FakeAuth->>LocalStorage: Get/Create userId
LocalStorage-->>FakeAuth: Return userId
FakeAuth->>Client: Show userId management UI
Client->>FakeAuth: Complete authentication
FakeAuth->>MCPServer: Redirect with code + userId
MCPServer->>Redis: Store userId in McpInstallation
MCPServer->>Client: Return access token
Note over Redis: McpInstallation.userId = userId
```
### OAuth Data Flow
```mermaid
graph TD
A[Browser localStorage] -->|userId| B[Fake Auth Page]
B -->|userId in query params| C[Authorization Callback]
C -->|userId| D[McpInstallation Object]
D -->|access_token| E[Redis Storage]
E -->|AuthInfo.extra.userId| F[Session Ownership]
```
### Authorization Code Exchange
The userId is embedded in the authorization flow:
```javascript
// In fake auth page
function authorize() {
const userId = getUserId(); // From localStorage
const url = new URL(redirectUri);
url.searchParams.set('userId', userId);
url.searchParams.set('code', 'fakecode');
window.location.href = url.toString();
}
```
---
## 3. Redis Session Ownership
Redis stores session ownership information using a structured key system.
### Redis Key Structure
#### MCP Session Keys (MCP Server)
```
session:{sessionId}:owner → userId # Session ownership
mcp:shttp:toserver:{sessionId} → [pub/sub channel] # Client→Server messages (also indicates liveness)
mcp:shttp:toclient:{sessionId}:{requestId} → [pub/sub channel] # Server→Client responses
mcp:control:{sessionId} → [pub/sub channel] # Control messages
```
#### Auth Keys (Auth Server)
```
auth:client:{clientId} → client registration # OAuth client registrations
auth:pending:{authCode} → pending authorization # Pending auth (10 min TTL)
auth:installation:{accessToken} → MCP installation # Active sessions (7 days TTL)
auth:exch:{authCode} → token exchange # Token exchange (10 min TTL)
auth:refresh:{refreshToken} → access token # Refresh tokens (7 days TTL)
```
Note: The `auth:` prefix ensures complete isolation from MCP session keys, allowing both integrated and separate modes to work consistently.
### Redis Operations
| Operation | Key Pattern | Value | Purpose |
|-----------|-------------|--------|---------|
| **Set Owner** | `session:{sessionId}:owner` | `userId` | Store session owner |
| **Get Owner** | `session:{sessionId}:owner` | `userId` | Retrieve session owner |
| **Check Live** | `mcp:shttp:toserver:{sessionId}` | `numsub > 0` | Check if session active via pub/sub subscribers |
### Session Liveness Mechanism
Session liveness is determined by **pub/sub subscription count** rather than explicit keys:
```mermaid
graph TD
A[MCP Server Starts] --> B[Subscribe to mcp:shttp:toserver:sessionId]
B --> C[numsub = 1 → Session is LIVE]
C --> D[Session Processing]
D --> E[MCP Server Shutdown]
E --> F[Unsubscribe from channel]
F --> G[numsub = 0 → Session is DEAD]
H[isLive() function] --> I[Check numsub count]
I --> J{numsub > 0?}
J -->|Yes| K[Session is Live]
J -->|No| L[Session is Dead]
```
**Why this works:**
- When an MCP server starts, it subscribes to `mcp:shttp:toserver:{sessionId}`
- When it shuts down (gracefully or crashes), Redis automatically removes the subscription
- `numsub` reflects the actual state without requiring explicit cleanup
### Session Ownership Functions
```typescript
// Core ownership functions
export async function setSessionOwner(sessionId: string, userId: string): Promise<void>
export async function getSessionOwner(sessionId: string): Promise<string | null>
export async function validateSessionOwnership(sessionId: string, userId: string): Promise<boolean>
export async function isSessionOwnedBy(sessionId: string, userId: string): Promise<boolean>
export async function isLive(sessionId: string): Promise<boolean> // Uses numsub count
```
---
## 4. Session Access Validation
Session access is validated at multiple points in the request lifecycle.
### Session Validation Flow
```mermaid
sequenceDiagram
participant Client
participant Handler as shttp Handler
participant Auth as Auth Middleware
participant Redis
Client->>Handler: MCP Request with session-id
Handler->>Auth: Extract userId from token
Auth-->>Handler: Return userId
alt New Session (Initialize)
Handler->>Handler: Generate new sessionId
Handler->>Redis: setSessionOwner(sessionId, userId)
Handler->>Handler: Start MCP server (subscribes to channel)
Note over Handler: Session becomes "live" via pub/sub subscription
Handler->>Client: Return with new session
else Existing Session
Handler->>Redis: isSessionOwnedBy(sessionId, userId)
Redis-->>Handler: Return ownership status
alt Session Owned by User
Handler->>Client: Process request
else Session Not Owned
Handler->>Client: 400 Bad Request
end
end
```
### DELETE Request Validation
```mermaid
sequenceDiagram
participant Client
participant Handler as shttp Handler
participant Redis
Client->>Handler: DELETE /mcp (session-id: xyz)
Handler->>Handler: Extract userId from auth
Handler->>Redis: isSessionOwnedBy(sessionId, userId)
alt Session Owned by User
Redis-->>Handler: true
Handler->>Redis: shutdownSession(sessionId)
Handler->>Client: 200 OK (Session terminated)
else Session Not Owned
Redis-->>Handler: false
Handler->>Client: 404 Not Found (Session not found or access denied)
end
```
### Request Authorization Matrix
| Request Type | Session ID | User ID | Authorization Check |
|-------------|-----------|---------|-------------------|
| **Initialize** | None | Required | Create new session |
| **Existing Session** | Required | Required | `isSessionOwnedBy()` |
| **DELETE Session** | Required | Required | `isSessionOwnedBy()` |
---
## 5. Security Model
### Multi-User Isolation
```mermaid
graph TB
subgraph "User A"
A1[localStorage: userA-id]
A2[Session: session-A]
A3[Redis: session:A:owner → userA]
end
subgraph "User B"
B1[localStorage: userB-id]
B2[Session: session-B]
B3[Redis: session:B:owner → userB]
end
subgraph "Redis Isolation"
R1[session:A:owner → userA-id]
R2[session:B:owner → userB-id]
R3[Ownership Validation]
end
A3 --> R1
B3 --> R2
R1 --> R3
R2 --> R3
```
### Security Guarantees
1. **Session Isolation**: Users can only access sessions they own
2. **Identity Verification**: User ID is validated from authenticated token
3. **Ownership Persistence**: Session ownership is stored in Redis
4. **Access Control**: All session operations validate ownership
5. **Secure Cleanup**: DELETE operations verify ownership before termination
### Attack Prevention
| Attack Vector | Prevention | Implementation |
|---------------|------------|----------------|
| **Session Hijacking** | Ownership validation | `isSessionOwnedBy()` check |
| **Cross-User Access** | User ID verification | Extract userId from AuthInfo |
| **Session Spoofing** | Token validation | Bearer token middleware |
| **Unauthorized DELETE** | Ownership check | Validate before shutdown |
---
## 6. Implementation Details
### Error Handling
```typescript
// Session access errors
if (!userId) {
return 401; // Unauthorized: User ID required
}
if (!await isSessionOwnedBy(sessionId, userId)) {
return 400; // Bad Request: Session access denied
}
```
### Testing Strategy
The system includes comprehensive tests for:
- **User session isolation**: Users cannot access other users' sessions
- **DELETE request validation**: Only owners can delete sessions
- **Redis cleanup**: Proper cleanup of ownership data
- **Auth flow integration**: User ID propagation through OAuth
### Performance Considerations
1. **Redis Efficiency**: O(1) lookups for session ownership
2. **Session Reuse**: Existing sessions are reused when ownership matches
3. **Cleanup**: Automatic cleanup prevents resource leaks
4. **Caching**: Session ownership is cached in Redis
---
## 7. Configuration
### Environment Variables
```bash
# Redis configuration for session storage
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=optional
# Base URI for OAuth redirects
BASE_URI=http://localhost:3000
```
### Development Testing
```bash
# Run multi-user tests
npm test -- --testNamePattern="User Session Isolation"
# Test session ownership
npm test -- --testNamePattern="session ownership"
# Full integration test
npm test
```
---
## 8. Monitoring and Debugging
### Redis Key Monitoring
```bash
# Monitor session ownership keys
redis-cli KEYS "session:*:owner"
# Watch session ownership operations
redis-cli MONITOR | grep "session:"
# Check active (live) sessions via pub/sub
redis-cli PUBSUB CHANNELS "mcp:shttp:toserver:*"
redis-cli PUBSUB NUMSUB "mcp:shttp:toserver:*"
```
### Debugging Commands
```bash
# Check session ownership
redis-cli GET "session:550e8400-e29b-41d4-a716-446655440000:owner"
# List all session owners
redis-cli KEYS "session:*:owner"
# Check if specific session is live
redis-cli PUBSUB NUMSUB "mcp:shttp:toserver:550e8400-e29b-41d4-a716-446655440000"
# Monitor pub/sub activity
redis-cli MONITOR
```
This system provides robust multi-user session management with strong security guarantees and comprehensive testing coverage.

135
embedded-oauth/README.md Normal file
View file

@ -0,0 +1,135 @@
# Embedded OAuth - MCP Feature Reference Server
This is a **complete, standalone implementation** of an MCP server with self-hosted OAuth authentication. Everything you need is in this directory.
## Overview
Per the [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization), "the authorization server may be hosted with the resource server." This mode demonstrates a self-hosted OAuth 2.1 authorization server running in the same process as the MCP server.
**Pattern demonstrated**: OAuth server that delegates user authentication to an upstream identity provider (corporate SSO, LDAP, Active Directory).
**Real-world use case**: Enterprise deployments that need control over OAuth token issuance while leveraging existing corporate identity infrastructure.
For the OAuth-as-a-Service pattern (using Auth0/Okta directly), see [external-oauth](../external-oauth/README.md).
## Quick Start
```bash
# 1. Make sure Redis is running (from repo root)
docker compose up -d
# 2. Install dependencies (if not already done)
npm install
# 3. Start the server
npm run dev
# 4. Test with MCP Inspector
npx -y @modelcontextprotocol/inspector
# Connect to: http://localhost:3232/mcp
```
## Available Commands
```bash
npm run dev # Start with hot reload
npm run dev:break # Start with debugger
npm run build # Build to dist/
npm start # Run built version
npm test # Run 85 unit tests
npm run lint # Lint code
npm run typecheck # Check types
```
## Configuration
Environment variables are in `.env`:
```bash
BASE_URI=http://localhost:3232
PORT=3232
REDIS_URL=redis://localhost:6379
```
## What This Demonstrates
**OAuth + MCP integration**:
- Self-hosted OAuth 2.1 server with PKCE
- Upstream identity provider delegation (simulated via `/mock-upstream-idp/*`)
- Complete authorization flow in one codebase
**MCP features**:
- Tools (7), resources (100), prompts (3), sampling, logging, completions
- Streamable HTTP and SSE transports
- Redis-backed horizontal scaling
- Session isolation and user ownership
## Understanding the Upstream Delegation Pattern
This implementation demonstrates a common enterprise OAuth pattern:
```
MCP Client This Server Simulated Upstream
│ │ │
│──1. /authorize───────────────>│ │
│<───(show auth page)───────────│ │
│ │ │
│──2. Click "Continue"──────────>│ │
│<──redirect /mock-upstream-idp─│ │
│ │ │
│──3. /mock-upstream-idp/authorize──────────────────────────────>│
│<──(show user selection)───────────────────────────────────────│
│──4. Select user───────────────────────────────────────────────>│
│<──redirect with userId────────────────────────────────────────│
│ │ │
│──5. /mock-upstream-idp/callback──>│ │
│ │──(validate userId) │
│ │──(issue MCP tokens) │
│<──redirect with auth code─────│ │
│ │ │
│──6. /token (exchange code)────>│ │
│<──MCP access token────────────│ │
```
**In production**, `/mock-upstream-idp/*` would be replaced by corporate SSO (Okta, Azure AD, etc.).
## Code Organization
```
src/
├── index.ts # Main entry point (MCP + OAuth + routing)
├── auth/
│ ├── provider.ts # FeatureReferenceAuthProvider (OAuth server logic)
│ └── auth-core.ts # Token generation, PKCE utilities
├── services/
│ ├── mcp.ts # MCP server with all features
│ ├── auth.ts # Auth service wrappers
│ ├── redis-auth.ts # Redis auth operations
│ └── redisTransport.ts # Redis-backed transport
├── handlers/
│ ├── shttp.ts # Streamable HTTP handler
│ ├── sse.ts # SSE handler
│ ├── mock-upstream-idp.ts # Mock upstream IDP simulation
│ └── common.ts # Shared middleware
└── utils/
└── logger.ts # Structured logging
```
## Next Steps
- Modify `src/services/mcp.ts` to add custom tools and resources
- See [external-oauth](../external-oauth/README.md) for external OAuth pattern
- See [docs/oauth-flow.md](../docs/oauth-flow.md) for detailed OAuth flow analysis
## Testing
```bash
npm test # 85 unit tests
npm run test:e2e:integrated # E2E test (from repo root)
```
## References
- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
- [OAuth 2.1 Draft](https://oauth.net/2.1/)
- [RFC 6749: OAuth 2.0 Framework](https://datatracker.ietf.org/doc/html/rfc6749)
- [docs/oauth-flow.md](../docs/oauth-flow.md) - Detailed flow analysis with mode differences

View file

@ -0,0 +1,19 @@
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
linterOptions: {
reportUnusedDisableDirectives: false,
},
rules: {
"@typescript-eslint/no-unused-vars": ["error",
{ "argsIgnorePattern": "^_" }
]
}
}
);

View file

@ -0,0 +1,19 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
},
],
},
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/scratch/'],
injectGlobals: true,
};

7141
embedded-oauth/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,43 @@
{
"name": "mcp-server-feature-reference-embedded-oauth",
"version": "0.1.0",
"description": "MCP Feature Reference Server - Embedded OAuth",
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "tsx watch --inspect src/index.ts",
"dev:break": "tsx --inspect-brk watch src/index.ts",
"build": "tsc && npm run copy-static",
"copy-static": "mkdir -p dist/static && cp -r src/static/* dist/static/",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
"@redis/client": "^1.6.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^8.0.1",
"raw-body": "^3.0.0"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@types/content-type": "^1.1.8",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.0"
},
"overrides": {
"@types/express": "^5.0.0",
"@types/express-serve-static-core": "^5.0.2"
}
}

View file

@ -0,0 +1,87 @@
import crypto from "crypto";
import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
/**
* Generates a PKCE code challenge from a verifier string.
* Uses S256 method as specified in RFC 7636.
* @param verifier The code verifier string
* @returns Base64url-encoded SHA256 hash of the verifier
*/
export function generatePKCEChallenge(verifier: string): string {
const buffer = Buffer.from(verifier);
const hash = crypto.createHash("sha256").update(buffer);
return hash.digest("base64url");
}
/**
* Generates a cryptographically secure random token.
* @returns 64-character hexadecimal string
*/
export function generateToken(): string {
return crypto.randomBytes(32).toString("hex");
}
/**
* Computes SHA256 hash of input data.
* @param data The string to hash
* @returns Hexadecimal representation of the hash
*/
export function sha256(data: string): string {
return crypto.createHash("sha256").update(data).digest("hex");
}
/**
* Encrypts a string using AES-256-CBC encryption.
* @param text The plaintext to encrypt
* @param key The encryption key (64 hex characters)
* @returns Encrypted string in format "iv:ciphertext"
*/
export function encryptString({ text, key }: { text: string; key: string }): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv);
let encrypted = cipher.update(text, "utf-8", "hex");
encrypted += cipher.final("hex");
return `${iv.toString("hex")}:${encrypted}`;
}
/**
* Decrypts a string encrypted with encryptString.
* @param encryptedText The encrypted string in format "iv:ciphertext"
* @param key The encryption key (64 hex characters)
* @returns Decrypted plaintext
*/
export function decryptString({
encryptedText,
key,
}: {
encryptedText: string;
key: string;
}): string {
const [ivHex, encrypted] = encryptedText.split(":");
const iv = Buffer.from(ivHex, "hex");
const decipher = crypto.createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv);
let decrypted = decipher.update(encrypted, "hex", "utf-8");
decrypted += decipher.final("utf-8");
return decrypted;
}
/**
* Access token expiry time in seconds (1 hour)
*/
export const ACCESS_TOKEN_EXPIRY_SEC = 60 * 60;
/**
* Generates a complete set of MCP OAuth tokens.
* @returns OAuth tokens with access token, refresh token, and expiry
*/
export function generateMcpTokens(): OAuthTokens {
const mcpAccessToken = generateToken();
const mcpRefreshToken = generateToken();
return {
access_token: mcpAccessToken,
refresh_token: mcpRefreshToken,
expires_in: ACCESS_TOKEN_EXPIRY_SEC,
token_type: "Bearer",
};
}

View file

@ -0,0 +1,412 @@
import { jest, describe, beforeEach, it, expect } from '@jest/globals';
import { Response } from "express";
import { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js";
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
import { MockRedisClient, setRedisClient } from "../redis.js";
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
import { FeatureReferenceAuthProvider, FeatureReferenceOAuthClientsStore } from "./provider.js";
import * as authService from "../services/auth.js";
// Helper function to create sample client
function createTestClient(): OAuthClientInformationFull {
return {
client_id: "test-client-id",
client_name: "Test Client",
client_uri: "https://example.com",
redirect_uris: ["https://example.com/callback"]
};
}
// Helper function to create a mock Response object
function createMockResponse() {
const res = {
redirect: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis()
};
return res as unknown as jest.Mocked<Response>;
}
function getMockAuthValues() {
const client = createTestClient();
// Use properly generated tokens for encryption
const accessToken = authService.generateToken();
const newTokens: OAuthTokens = {
access_token: authService.generateToken(),
refresh_token: authService.generateToken(),
token_type: "bearer",
expires_in: 3600,
};
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
refresh_token: authService.generateToken(),
expires_in: 3600,
},
clientId: client.client_id,
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
return {
client,
accessToken,
newTokens,
mcpInstallation,
}
}
describe("FeatureReferenceOAuthClientsStore", () => {
let clientsStore: FeatureReferenceOAuthClientsStore;
beforeEach(() => {
const mockRedis = new MockRedisClient();
setRedisClient(mockRedis);
jest.resetAllMocks();
clientsStore = new FeatureReferenceOAuthClientsStore();
});
describe("getClient", () => {
it("returns undefined for non-existent client", async () => {
const result = await clientsStore.getClient("non-existent");
expect(result).toBeUndefined();
});
it("returns client information for existing client", async () => {
const client = createTestClient();
// First save the client
await clientsStore.registerClient(client);
// Then retrieve it
const result = await clientsStore.getClient(client.client_id);
expect(result).toEqual(client);
});
});
describe("registerClient", () => {
it("saves and returns client information", async () => {
const client = createTestClient();
const result = await clientsStore.registerClient(client);
expect(result).toEqual(client);
// Verify it was saved
const retrieved = await clientsStore.getClient(client.client_id);
expect(retrieved).toEqual(client);
});
});
});
describe("FeatureReferenceAuthProvider", () => {
let provider: FeatureReferenceAuthProvider;
let mockRedis: MockRedisClient;
beforeEach(() => {
jest.resetAllMocks();
mockRedis = new MockRedisClient();
setRedisClient(mockRedis);
provider = new FeatureReferenceAuthProvider();
});
describe("authorize", () => {
it("saves pending authorization and sends HTML response", async () => {
const client = createTestClient();
// Use a type assertion to make TypeScript ignore the mismatch
const params = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
} as unknown as AuthorizationParams;
const res = createMockResponse();
await provider.authorize(client, params, res);
// Verify HTML sent with redirect
expect(res.send).toHaveBeenCalled();
const sentHtml = (res.send as jest.Mock).mock.calls[0][0];
expect(sentHtml).toContain('MCP Server Authorization');
expect(sentHtml).toContain('Authorization Required');
expect(sentHtml).toContain('mock-upstream-idp/authorize?redirect_uri=/mock-upstream-idp/callback&state=');
});
});
describe("challengeForAuthorizationCode", () => {
it("returns code challenge for valid authorization code", async () => {
const client = createTestClient();
const pendingAuth: PendingAuthorization = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
clientId: client.client_id,
};
// First save the pending authorization
const authCode = authService.generateToken();
await authService.savePendingAuthorization(authCode, pendingAuth);
const result = await provider.challengeForAuthorizationCode(client, authCode);
expect(result).toBe("test-challenge");
});
it("throws error for non-existent authorization code", async () => {
const client = createTestClient();
await expect(provider.challengeForAuthorizationCode(client, "test-code"))
.rejects
.toThrow("Authorization code not found");
});
it("throws error when client ID doesn't match", async () => {
const client = createTestClient();
const pendingAuth: PendingAuthorization = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
clientId: "different-client-id",
};
// Save pending auth with different client ID
const authCode = authService.generateToken();
await authService.savePendingAuthorization(authCode, pendingAuth);
await expect(provider.challengeForAuthorizationCode(client, authCode))
.rejects
.toThrow("Authorization code does not match client");
});
});
describe("exchangeAuthorizationCode", () => {
it("returns tokens when exchange is successful", async () => {
const client = createTestClient();
const { mcpInstallation } = getMockAuthValues();
// Setup: save token exchange and installation
const authCode = authService.generateToken();
const tokenExchange: TokenExchange = {
mcpAccessToken: mcpInstallation.mcpTokens.access_token,
alreadyUsed: false,
};
await authService.saveTokenExchange(authCode, tokenExchange);
await authService.saveMcpInstallation(mcpInstallation.mcpTokens.access_token, mcpInstallation);
const result = await provider.exchangeAuthorizationCode(client, authCode);
expect(result).toEqual({
access_token: mcpInstallation.mcpTokens.access_token,
expires_in: mcpInstallation.mcpTokens.expires_in,
refresh_token: mcpInstallation.mcpTokens.refresh_token,
token_type: "Bearer",
});
});
it("throws error for invalid authorization code", async () => {
const client = createTestClient();
await expect(provider.exchangeAuthorizationCode(client, "test-code"))
.rejects
.toThrow("Invalid authorization code");
});
});
describe("exchangeRefreshToken", () => {
it("returns new tokens when refresh token is valid", async () => {
const {
client,
accessToken,
mcpInstallation,
} = getMockAuthValues();
// Setup: save refresh token and installation
const refreshToken = authService.generateToken();
await authService.saveRefreshToken(refreshToken, accessToken);
await authService.saveMcpInstallation(accessToken, mcpInstallation);
const result = await provider.exchangeRefreshToken(client, refreshToken);
// Should return new tokens
expect(result).toHaveProperty('access_token');
expect(result).toHaveProperty('refresh_token');
expect(result).toHaveProperty('expires_in', 3600);
expect(result).toHaveProperty('token_type', 'Bearer');
});
it("throws error for invalid refresh token", async () => {
const client = createTestClient();
await expect(provider.exchangeRefreshToken(client, "test-refresh-token"))
.rejects
.toThrow("Invalid refresh token");
});
it("throws error for refresh token with no installation", async () => {
const client = createTestClient();
const refreshToken = authService.generateToken();
const accessToken = authService.generateToken();
// Only save refresh token, not the installation
await authService.saveRefreshToken(refreshToken, accessToken);
await expect(provider.exchangeRefreshToken(client, refreshToken))
.rejects
.toThrow("Invalid refresh token");
});
it("throws error when client ID doesn't match", async () => {
const client = createTestClient();
const accessToken = authService.generateToken();
const refreshToken = authService.generateToken();
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "different-client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
await authService.saveRefreshToken(refreshToken, accessToken);
await authService.saveMcpInstallation(accessToken, mcpInstallation);
await expect(provider.exchangeRefreshToken(client, refreshToken))
.rejects
.toThrow("Invalid client");
});
it("works correctly even when the refresh token has expired from Redis", async () => {
const client = createTestClient();
// Simulate the refresh token not being found in Redis (expired)
await expect(provider.exchangeRefreshToken(client, "expired-refresh-token"))
.rejects
.toThrow("Invalid refresh token");
});
});
describe("verifyAccessToken", () => {
it("returns auth info for valid token", async () => {
const accessToken = authService.generateToken();
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
await authService.saveMcpInstallation(accessToken, mcpInstallation);
const result = await provider.verifyAccessToken(accessToken);
expect(result).toEqual({
token: accessToken,
clientId: mcpInstallation.clientId,
scopes: ['mcp'],
expiresAt: mcpInstallation.mcpTokens.expires_in! + mcpInstallation.issuedAt,
extra: {
userId: "test-user-id"
}
});
});
it("throws error for invalid token", async () => {
await expect(provider.verifyAccessToken("invalid-token"))
.rejects
.toThrow("Invalid access token");
});
it("throws InvalidTokenError for expired token", async () => {
const accessToken = authService.generateToken();
const oneDayInSeconds = 24 * 60 * 60;
const twoDaysAgoInSeconds = Math.floor(Date.now() / 1000) - (2 * oneDayInSeconds);
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: oneDayInSeconds,
},
clientId: "client-id",
issuedAt: twoDaysAgoInSeconds, // 2 days ago, with 1-day expiry
userId: "test-user-id",
};
await authService.saveMcpInstallation(accessToken, mcpInstallation);
await expect(provider.verifyAccessToken(accessToken))
.rejects
.toThrow(InvalidTokenError);
});
});
describe("revokeToken", () => {
it("revokes the installation when given an access token", async () => {
const client = createTestClient();
const accessToken = authService.generateToken();
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: client.client_id,
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
// Save the installation
await authService.saveMcpInstallation(accessToken, mcpInstallation);
// Verify it exists
const saved = await authService.readMcpInstallation(accessToken);
expect(saved).toBeTruthy();
// Revoke it
const request: OAuthTokenRevocationRequest = {
token: accessToken,
token_type_hint: "access_token"
};
await provider.revokeToken(client, request);
// Verify it was revoked
const revoked = await authService.readMcpInstallation(accessToken);
expect(revoked).toBeUndefined();
});
});
});

View file

@ -0,0 +1,378 @@
import { Response } from 'express';
import { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import {
exchangeToken,
generateToken,
getClientRegistration,
readPendingAuthorization,
readMcpInstallation,
revokeMcpInstallation,
saveClientRegistration,
savePendingAuthorization,
readRefreshToken,
generateMcpTokens,
saveMcpInstallation,
saveRefreshToken,
} from '../services/auth.js';
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { logger } from '../utils/logger.js';
/**
* Implementation of the OAuthRegisteredClientsStore interface using the existing client registration system
*/
export class FeatureReferenceOAuthClientsStore implements OAuthRegisteredClientsStore {
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
const registration = await getClientRegistration(clientId);
if (!registration) {
return undefined;
}
return registration;
}
async registerClient(client: OAuthClientInformationFull): Promise<OAuthClientInformationFull> {
await saveClientRegistration(client.client_id, client);
return client;
}
}
/**
* Implementation of the OAuthServerProvider interface for upstream authentication
*/
export class FeatureReferenceAuthProvider implements OAuthServerProvider {
private _clientsStore: FeatureReferenceOAuthClientsStore;
constructor() {
this._clientsStore = new FeatureReferenceOAuthClientsStore();
}
get clientsStore(): OAuthRegisteredClientsStore {
return this._clientsStore;
}
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
// Client is validated by the MCP sdk.
// Generate authorization code
const authorizationCode = generateToken();
// Save the pending authorization with code challenge and state
await savePendingAuthorization(authorizationCode, {
redirectUri: params.redirectUri,
codeChallenge: params.codeChallenge,
codeChallengeMethod: 'S256', // Currently only support S256
clientId: client.client_id,
state: params.state,
});
logger.debug('Saved pending authorization', {
authorizationCode: authorizationCode.substring(0, 8) + '...',
clientId: client.client_id,
state: params.state?.substring(0, 8) + '...'
});
// TODO: should we use a different key, other than the authorization code, to store the pending authorization?
// You can redirect to another page, or you can send an html response directly
// res.redirect(new URL(`mock-upstream-idp/authorize?metadata=${authorizationCode}`, BASE_URI).href);
// Set permissive CSP for styling
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'"
].join('; '));
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Server Authorization</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #000000;
color: #ffffff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-container {
background: #ffffff;
color: #000000;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(255, 255, 255, 0.1);
padding: 40px;
max-width: 500px;
width: 100%;
text-align: center;
border: 1px solid #e2e8f0;
}
.logo-container {
margin-bottom: 32px;
}
.logo {
width: 80px;
height: 80px;
margin: 0 auto 16px;
filter: invert(1);
}
.mcp-title {
font-size: 24px;
font-weight: 700;
color: #000000;
margin-bottom: 8px;
letter-spacing: 2px;
}
h1 {
color: #000000;
font-size: 32px;
font-weight: 800;
margin-bottom: 12px;
line-height: 1.2;
}
.subtitle {
color: #4a5568;
font-size: 18px;
margin-bottom: 32px;
line-height: 1.5;
}
.client-info {
background: #f8f9fa;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
border: 2px solid #e2e8f0;
}
.client-info h3 {
color: #2d3748;
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.client-id {
background: white;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
color: #4a5568;
word-break: break-all;
}
.auth-flow-info {
background: #f8f9fa;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
border-left: 4px solid #000000;
}
.auth-flow-info h3 {
color: #2d3748;
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
}
.auth-flow-info p {
color: #4a5568;
font-size: 14px;
line-height: 1.5;
}
.btn-primary {
background: #000000;
color: #ffffff;
font-size: 18px;
font-weight: 700;
padding: 18px 36px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
text-decoration: none;
display: inline-block;
text-align: center;
letter-spacing: 1px;
}
.btn-primary:hover {
background: #333333;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.branding {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
color: #718096;
font-size: 12px;
}
</style>
</head>
<body>
<div class="auth-container">
<div class="logo-container">
<img src="/mcp-logo.png" alt="MCP Logo" class="logo">
<div class="mcp-title">MCP</div>
</div>
<h1>Authorization Required</h1>
<p class="subtitle">This client wants to connect to your MCP server</p>
<div class="client-info">
<h3>Client Application</h3>
<div class="client-id">${client.client_id}</div>
</div>
<div class="auth-flow-info">
<h3>What happens next?</h3>
<p>You'll be redirected to authenticate with the upstream provider. Once verified, you'll be granted access to this MCP server's resources.</p>
</div>
<a href="/mock-upstream-idp/authorize?redirect_uri=/mock-upstream-idp/callback&state=${authorizationCode}" class="btn-primary">
Continue to Authentication
</a>
<div class="branding">
Model Context Protocol (MCP) Server
</div>
</div>
</body>
</html>
`);
}
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
const pendingAuth = await readPendingAuthorization(authorizationCode);
if (!pendingAuth) {
throw new Error('Authorization code not found');
}
if (pendingAuth.clientId !== client.client_id) {
throw new Error('Authorization code does not match client');
}
return pendingAuth.codeChallenge;
}
async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
const tokenData = await exchangeToken(authorizationCode);
if (!tokenData) {
throw new Error('Invalid authorization code');
}
// Get the MCP installation to retrieve the full token data including refresh token
const mcpInstallation = await readMcpInstallation(tokenData.mcpAccessToken);
if (!mcpInstallation) {
throw new Error('Failed to retrieve MCP installation');
}
// Return the full token data including refresh token
return {
access_token: mcpInstallation.mcpTokens.access_token,
refresh_token: mcpInstallation.mcpTokens.refresh_token,
expires_in: mcpInstallation.mcpTokens.expires_in,
token_type: 'Bearer',
};
}
async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, _scopes?: string[]): Promise<OAuthTokens> {
const accessToken = await readRefreshToken(refreshToken);
if (!accessToken) {
throw new Error('Invalid refresh token');
}
const mcpInstallation = await readMcpInstallation(accessToken);
if (!mcpInstallation) {
throw new Error('Invalid refresh token');
}
// Check the client_id
if (mcpInstallation.clientId !== client.client_id) {
throw new Error('Invalid client');
}
const newTokens = generateMcpTokens();
if (newTokens.refresh_token) {
await saveRefreshToken(newTokens.refresh_token, newTokens.access_token);
}
// Update the installation with the new tokens
await saveMcpInstallation(newTokens.access_token, {
...mcpInstallation,
mcpTokens: newTokens,
issuedAt: Date.now() / 1000,
userId: mcpInstallation.userId, // Preserve the user ID
});
return newTokens;
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
const installation = await readMcpInstallation(token);
if (!installation) {
throw new InvalidTokenError("Invalid access token");
}
const expiresAt = (
installation.mcpTokens.expires_in
? installation.mcpTokens.expires_in + installation.issuedAt
: undefined
);
// This can be removed once in the SDK
// Check if the token is expired
if (!!expiresAt && expiresAt < Date.now() / 1000) {
throw new InvalidTokenError("Token has expired");
}
return {
token,
clientId: installation.clientId,
scopes: ['mcp'],
expiresAt,
extra: {
userId: installation.userId
}
};
}
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
await revokeMcpInstallation(request.token);
}
}

View file

@ -0,0 +1,24 @@
import "dotenv/config";
/**
* Port for the MCP server to listen on
*/
export const PORT = Number(process.env.PORT) || 3232;
/**
* Base URI for the MCP server. Used for OAuth callbacks and metadata.
* Should match the port if specified separately.
*/
export const BASE_URI = process.env.BASE_URI || `http://localhost:${PORT}`;
// Validate PORT and BASE_URI consistency
const baseUrl = new URL(BASE_URI);
if (baseUrl.port && parseInt(baseUrl.port) !== PORT) {
console.warn(`Warning: BASE_URI port (${baseUrl.port}) doesn't match PORT (${PORT})`);
}
/**
* Redis connection URL
*/
export const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';

View file

@ -0,0 +1,36 @@
import { AsyncLocalStorage } from "async_hooks";
import { MockUpstreamInstallation } from "./types.js";
interface RequestContext {
mcpAccessToken: string;
mockUpstreamInstallation: MockUpstreamInstallation;
}
export const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
export function getMockUpstreamInstallation(): MockUpstreamInstallation {
const context = asyncLocalStorage.getStore();
if (!context) {
throw new Error(
"No request context found - are you calling this from within a request handler?",
);
}
return context.mockUpstreamInstallation;
}
export function getMcpAccessToken(): string {
const context = asyncLocalStorage.getStore();
if (!context) {
throw new Error(
"No request context found - are you calling this from within a request handler?",
);
}
return context.mcpAccessToken;
}
export function withContext<T>(
context: RequestContext,
fn: () => T | Promise<T>,
): T | Promise<T> {
return asyncLocalStorage.run(context, fn);
}

View file

@ -0,0 +1,63 @@
import { NextFunction, Request, Response } from "express";
import { withContext } from "../context.js";
import { readMcpInstallation } from "../services/auth.js";
import { logger } from "../utils/logger.js";
import { JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse } from "@modelcontextprotocol/sdk/types.js";
export function logMcpMessage(
message: JSONRPCError | JSONRPCNotification | JSONRPCRequest | JSONRPCResponse,
sessionId: string,
) {
// check if message has a method field
if ("method" in message) {
if (message.method === "tools/call") {
logger.info('Processing MCP method', {
sessionId,
method: message.method,
toolName: message.params?.name
});
} else {
logger.info('Processing MCP method', {
sessionId,
method: message.method
});
}
} else if ("error" in message) {
logger.warning('Received error message', {
sessionId,
errorMessage: message.error.message,
errorCode: message.error.code
});
}
}
export async function authContext(
req: Request,
res: Response,
next: NextFunction,
) {
const authInfo = req.auth
if (!authInfo) {
res.set("WWW-Authenticate", 'Bearer error="invalid_token"');
res.status(401).json({ error: "Invalid access token" });
return;
}
const token = authInfo.token;
// Load UpstreamInstallation based on the access token
const mcpInstallation = await readMcpInstallation(token);
if (!mcpInstallation) {
res.set("WWW-Authenticate", 'Bearer error="invalid_token"');
res.status(401).json({ error: "Invalid access token" });
return;
}
// Wrap the rest of the request handling in the context
withContext({ mcpAccessToken: token, mockUpstreamInstallation: mcpInstallation.mockUpstreamInstallation }, () =>
next(),
);
}

Some files were not shown because too many files have changed in this diff Show more