refactor, improve docs
This commit is contained in:
parent
dc6c2a00e9
commit
112997d352
92 changed files with 30079 additions and 1640 deletions
|
|
@ -1,4 +0,0 @@
|
|||
AUTH_MODE=integrated
|
||||
BASE_URI=http://localhost:3232
|
||||
PORT=3232
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
|
@ -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
435
README.md
|
|
@ -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)
|
||||
|
|
@ -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
154
docs/endpoints.md
Normal 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
217
docs/oauth-flow.md
Normal 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
145
docs/session-ownership.md
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
135
embedded-oauth/README.md
Normal 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
|
||||
19
embedded-oauth/eslint.config.mjs
Normal file
19
embedded-oauth/eslint.config.mjs
Normal 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": "^_" }
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
19
embedded-oauth/jest.config.js
Normal file
19
embedded-oauth/jest.config.js
Normal 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
7141
embedded-oauth/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
embedded-oauth/package.json
Normal file
43
embedded-oauth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
87
embedded-oauth/src/auth/auth-core.ts
Normal file
87
embedded-oauth/src/auth/auth-core.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
412
embedded-oauth/src/auth/provider.test.ts
Normal file
412
embedded-oauth/src/auth/provider.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
378
embedded-oauth/src/auth/provider.ts
Normal file
378
embedded-oauth/src/auth/provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
embedded-oauth/src/config.ts
Normal file
24
embedded-oauth/src/config.ts
Normal 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';
|
||||
|
||||
36
embedded-oauth/src/context.ts
Normal file
36
embedded-oauth/src/context.ts
Normal 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);
|
||||
}
|
||||
63
embedded-oauth/src/handlers/common.ts
Normal file
63
embedded-oauth/src/handlers/common.ts
Normal 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
Loading…
Reference in a new issue