Merge pull request #9 from modelcontextprotocol/basil/separate_auth

Add Support for Separate Authorization Server Mode
This commit is contained in:
bhosmer-ant 2025-09-26 15:10:49 -04:00 • committed by GitHub
commit 22b6268380
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2618 additions and 579 deletions

4
.env.integrated Normal file
View file

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

6
.env.separate Normal file
View file

@ -0,0 +1,6 @@
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

3
.gitignore vendored
View file

@ -129,3 +129,6 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Claude config directory
.claude/

607
README.md
View file

@ -9,11 +9,56 @@ A comprehensive example implementation of a scalable Model Context Protocol (MCP
The Everything 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)
- **Comprehensive Auth**: OAuth 2.0 with fake upstream provider for testing
- **Dual Authentication Modes**: Integrated and separate authorization server support
- **Horizontal Scalability**: Redis-backed session management for multi-instance deployments
This server serves as both primarily as a learning resource, and an example implementation of a scalable remote MCP server.
## Quick Start
Get the server running in 5 minutes:
```bash
# 1. Prerequisites
brew install orbstack # macOS: Install OrbStack (skip if already installed)
orbctl start # macOS: Start OrbStack daemon
# OR install Docker Desktop and start it (Windows/Linux/macOS alternative)
# 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)
# 3. Start services
docker compose up -d # Start Redis
npm run dev # Start server
# 4. Test with Inspector
npx -y @modelcontextprotocol/inspector
# Connect to http://localhost:3232/mcp
```
For detailed instructions, see [Installation](#installation).
## Table of Contents
- [Quick Start](#quick-start)
- [Features](#features)
- [Installation](#installation)
- [Configuration](#configuration)
- [Authentication Modes](#authentication-modes)
- [Development](#development)
- [Testing with MCP Inspector](#testing-with-mcp-inspector)
- [Automated End-to-End Testing](#automated-end-to-end-testing)
- [Interactive Testing](#interactive-testing)
- [Troubleshooting](#troubleshooting)
- [Architecture & Technical Details](#architecture--technical-details)
- [API Reference](#api-reference)
- [Security](#security)
- [Monitoring & Debugging](#monitoring--debugging)
- [Contributing](#contributing)
## Features
### MCP Protocol Features
@ -33,9 +78,10 @@ This server serves as both primarily as a learning resource, and an example impl
- **Horizontal Scaling**: Any instance can handle any request
### Authentication & Security
- **Dual Mode Support**: Run with integrated or separate authorization server
- **[OAuth 2.0](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization)**: Complete authorization flow with PKCE support
- **Fake Auth Provider**: Built-in testing provider with localStorage user management
- **Session Ownership**: User isolation and access control
- **External Auth Ready**: Demonstrates integration with external OAuth providers
- **Session Ownership**: User isolation and access control
- **Security Headers**: CSP, HSTS, X-Frame-Options, and more
- **Bearer Token Auth**: Middleware for protected endpoints
@ -43,158 +89,219 @@ This server serves as both primarily as a learning resource, and an example impl
### Prerequisites
- Node.js >= 16
- Redis server
- npm or yarn
- Docker runtime (for Redis)
### Setup
### Step 1: Install Docker Runtime
Choose one option:
**macOS (Recommended: OrbStack)**
```bash
brew install orbstack
# Start OrbStack daemon (required before using Docker commands)
orbctl start
# Or download from https://orbstack.dev/download
```
**Windows/Linux: Docker Desktop**
- Download from https://www.docker.com/products/docker-desktop
- Start Docker Desktop after installation
**Alternative: Local Redis Installation**
```bash
# macOS
brew install redis && brew services start redis
# Ubuntu/Debian
sudo apt-get install redis-server && sudo systemctl start redis
```
### Step 2: Clone and Install Dependencies
```bash
# Clone the repository
git clone https://github.com/modelcontextprotocol/example-remote-server.git
cd example-remote-server
# Install dependencies
npm install
# Configure environment (optional)
cp .env.example .env
# Edit .env with your settings
```
### Configuration
### Step 3: Configure Environment
```bash
# Use integrated mode (default, simpler setup)
cp .env.integrated .env
# OR use separate mode (for testing external auth)
cp .env.separate .env
```
### Step 4: Start Redis
```bash
# Ensure Docker/OrbStack is running first!
docker compose up -d
# Verify Redis is running
docker compose ps
```
### Step 5: Verify Installation
```bash
# Run the development server
npm run dev
# Server should start on http://localhost:3232
```
## Configuration
Environment variables (`.env` file):
```
PORT=3232 # Server port
```bash
# Server Configuration
PORT=3232 # MCP server port
BASE_URI=http://localhost:3232 # Base URI for OAuth redirects
REDIS_HOST=localhost # Redis server host
REDIS_PORT=6379 # Redis server port
REDIS_PASSWORD= # Redis password (if required)
# 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)
```
**Pre-configured environment files:**
- `.env.integrated` - Configuration for integrated mode
- `.env.separate` - Configuration for separate mode
```bash
# Use integrated mode
cp .env.integrated .env
# Use separate mode
cp .env.separate .env
```
## Authentication Modes
The Everything Server supports two authentication modes to demonstrate different MCP deployment patterns:
### Integrated Mode (Default)
The MCP server acts as its own OAuth 2.0 authorization server. This is simpler to deploy and suitable for standalone MCP servers.
```bash
npm run dev:integrated
```
### 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.
```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
```
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)
## Development
### Commands
### 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
```
### Development Commands
```bash
# Start development server with hot reload
npm run dev
# 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
```
# Build TypeScript to JavaScript
#### Build & Production
```bash
# Build TypeScript to JavaScript (builds both servers)
npm run build
# Run production server
npm start
# Run production auth server
npm run start:auth-server
```
#### Testing & Quality
```bash
# Run linting
npm run lint
# Run tests
# 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
```
### Project Structure
```
├── src/
│ ├── index.ts # Express app setup and routes
│ ├── config.ts # Configuration management
│ ├── redis.ts # Redis client setup
│ ├── auth/
│ │ └── provider.ts # OAuth auth provider implementation
│ ├── 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 # Session ownership management
│ │ └── redisTransport.ts # Redis-backed transport
│ └── utils/
│ └── logger.ts # Structured logging
├── docs/
│ ├── streamable-http-design.md # SHTTP implementation details
│ └── user-id-system.md # Authentication flow documentation
├── scratch/ # Development scripts and tests
└── dist/ # Compiled JavaScript output
### Testing with MCP Inspector
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
```bash
# 1. Start the server (Redis must already be running)
npm run dev:integrated
# 2. Launch MCP Inspector in a new terminal
npx -y @modelcontextprotocol/inspector
# 3. Connect to: http://localhost:3232/mcp
# 4. Navigate to Auth tab and complete OAuth flow
```
## API Endpoints
#### Test Separate Mode
```bash
# 1. Start both servers (Redis must already be running)
npm run dev:with-separate-auth
### 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
# 2. Launch MCP Inspector in a new terminal
npx -y @modelcontextprotocol/inspector
### Authentication Endpoints
- `GET /fakeupstreamauth/authorize` - Fake OAuth authorization page
- `GET /fakeupstreamauth/redirect` - 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
## Authentication Flow
The server implements a complete [OAuth 2.0](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) flow with a fake upstream provider for testing:
1. **Client Registration**: Clients register to obtain client_id and client_secret
2. **Authorization**: Users authenticate through `/fakeupstreamauth/authorize`
3. **User Management**: localStorage-based user ID system for testing
4. **Token Exchange**: Authorization codes exchanged for access tokens
5. **Session Creation**: Authenticated tokens create owned sessions
6. **Access Control**: Sessions are isolated by user ownership
See [docs/user-id-system.md](docs/user-id-system.md) for detailed authentication documentation.
## Transport Methods
### Streamable HTTP (Recommended)
Modern [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) supporting bidirectional communication over HTTP:
- Single endpoint for all operations
- Session management via headers
- Efficient message buffering
- Automatic reconnection support
See [docs/streamable-http-design.md](docs/streamable-http-design.md) for implementation details.
### Server-Sent Events (Legacy)
Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#server-sent-events) using SSE:
- Separate endpoints for SSE streams and messages
- Session management via URL parameters
- Redis-backed message routing
- Real-time event delivery
## Scalability Architecture
The server is designed for horizontal scaling using Redis as the backbone:
### Session State Management
- **Redis Storage**: All session state stored in Redis
- **5-minute TTL**: Automatic session cleanup
- **Session Ownership**: User isolation via Redis keys
- **Stateless Servers**: Any instance can handle any request
### Message Routing
- **Pub/Sub Channels**: Redis channels for message distribution
- **Message Buffering**: Reliable delivery for disconnected clients
- **Connection State**: Tracked via pub/sub subscription counts
- **Automatic Cleanup**: No explicit cleanup required
### Redis Key Structure
# 3. Connect to: http://localhost:3232/mcp
# 4. Auth flow will redirect to :3001 for authentication
```
session:{sessionId}:owner # Session ownership
mcp:shttp:toserver:{sessionId} # Client→Server messages
mcp:shttp:toclient:{sessionId}:{requestId} # Server→Client responses
mcp:control:{sessionId} # Control messages
```
## Testing
### Running Tests
```bash
@ -215,12 +322,261 @@ npm test -- --coverage
- **Auth Tests**: OAuth flow and session ownership
- **Multi-user Tests**: User isolation and access control
### Manual Testing
The `scratch/` directory contains utility scripts for testing:
- `oauth.sh` - Test OAuth flows
- `simple-test-client.js` - Basic client implementation
- `test-shttp-client.ts` - Streamable HTTP testing
- `debug-mcp-flow.sh` - Debug MCP message flows
### Automated End-to-End Testing
The `scripts/` directory contains automated test scripts that verify the complete OAuth flow and all 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
```
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.
## Troubleshooting
### Common Issues
**"Cannot connect to Docker daemon"**
- Ensure Docker Desktop or OrbStack daemon is running
- macOS with OrbStack: `orbctl start` (verify with `orbctl status`)
- Windows/Linux/macOS with Docker Desktop: Start Docker Desktop application
**"Redis connection refused"**
- Check Redis is running: `docker compose ps`
- If not running: `docker compose up -d`
- 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
**"Port already in use"**
- Check for existing processes: `lsof -i :3232` or `lsof -i :3001`
- Kill existing processes or change PORT in .env
**"npm install fails"**
- Ensure Node.js >= 16 is installed: `node --version`
- Clear npm cache: `npm cache clean --force`
- 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
## Architecture & Technical Details
### Authentication Architecture
#### Integrated Mode
```mermaid
graph TD
Client["MCP Client<br/>(Inspector)"]
MCP["MCP Server<br/>(port 3232)<br/>• OAuth Server<br/>• Resource Server"]
Client <-->|"OAuth flow & MCP resources"| MCP
```
#### Separate Mode
```mermaid
graph TD
Client["MCP Client<br/>(Inspector)"]
MCP["MCP Server<br/>(port 3232)<br/>Resource Server"]
Auth["Auth Server<br/>(port 3001)<br/>OAuth Server"]
Client <-->|"1. Discover metadata"| MCP
Client <-->|"2. OAuth flow<br/>(register, authorize, token)"| Auth
Client <-->|"3. Use tokens for MCP resources"| MCP
MCP <-->|"Token validation<br/>(introspect)"| Auth
```
### OAuth 2.0 + PKCE Flow Analysis
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.
### 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
├── 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
```
### Scalability Architecture
The server is designed for horizontal scaling using Redis as the backbone:
#### Session State Management
- **Redis Storage**: All session state stored in Redis
- **5-minute TTL**: Automatic session cleanup
- **Session Ownership**: User isolation via Redis keys
- **Stateless Servers**: Any instance can handle any request
#### Message Routing
- **Pub/Sub Channels**: Redis channels for message distribution
- **Message Buffering**: Reliable delivery for disconnected clients
- **Connection State**: Tracked via pub/sub subscription counts
- **Automatic Cleanup**: No explicit cleanup required
#### Redis Key Structure
##### MCP Session Keys
```
session:{sessionId}:owner # Session ownership
mcp:shttp:toserver:{sessionId} # Client→Server messages
mcp:shttp:toclient:{sessionId}:{requestId} # Server→Client responses
mcp:control:{sessionId} # Control messages
```
##### OAuth/Auth Keys
```
auth:client:{clientId} # OAuth client registrations
auth:pending:{authCode} # Pending authorizations
auth:installation:{accessToken} # Active MCP installations
auth:exch:{authCode} # Token exchanges
auth:refresh:{refreshToken} # Refresh tokens
```
Note: The `auth:` prefix ensures complete namespace isolation between auth and MCP functions in both integrated and separate modes.
### Transport Methods
#### Streamable HTTP (Recommended)
Modern [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) supporting bidirectional communication over HTTP:
- Single endpoint for all operations
- Session management via headers
- Efficient message buffering
- Automatic reconnection support
See [docs/streamable-http-design.md](docs/streamable-http-design.md) for implementation details.
#### Server-Sent Events (Legacy)
Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#server-sent-events) using SSE:
- Separate endpoints for SSE streams and messages
- Session management via URL parameters
- Redis-backed message routing
- Real-time event delivery
## 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
## Security
@ -271,10 +627,11 @@ redis-cli GET "session:{sessionId}:owner"
```
### Debug Tools
- Development scripts in `scratch/` directory
- MCP Inspector for interactive debugging
- Comprehensive test suite
- Hot-reload development mode
- Source maps for debugging
- Redis monitoring commands
## Contributing

94
auth-server/README.md Normal file
View file

@ -0,0 +1,94 @@
# 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)

166
auth-server/index.ts Normal file
View file

@ -0,0 +1,166 @@
import express from 'express';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
import { EverythingAuthProvider } from '../src/auth/provider.js';
import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from '../src/handlers/fakeauth.js';
import { redisClient } from '../src/redis.js';
import { logger } from '../src/utils/logger.js';
import { AUTH_SERVER_PORT, AUTH_SERVER_URL, BASE_URI } from '../src/config.js';
const app = express();
console.log('=====================================');
console.log('MCP Demonstration Authorization Server');
console.log('=====================================');
console.log('This standalone server demonstrates OAuth 2.0');
console.log('authorization separate from the MCP resource server');
console.log('');
console.log('This is for demonstration purposes only.');
console.log('In production, you would use a real OAuth provider');
console.log('like Auth0, Okta, Google, GitHub, etc.');
console.log('=====================================');
// CORS for Inspector and MCP server
app.use(cors({
origin: true,
credentials: true
}));
app.use(express.json());
app.use(logger.middleware());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
mode: 'authorization-server',
endpoints: {
metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`,
authorize: `${AUTH_SERVER_URL}/authorize`,
token: `${AUTH_SERVER_URL}/token`,
register: `${AUTH_SERVER_URL}/register`,
introspect: `${AUTH_SERVER_URL}/introspect`
}
});
});
// Create auth provider instance for reuse
const authProvider = new EverythingAuthProvider();
// OAuth endpoints via SDK's mcpAuthRouter
app.use(mcpAuthRouter({
provider: authProvider,
issuerUrl: new URL(AUTH_SERVER_URL),
tokenOptions: {
rateLimit: { windowMs: 5000, limit: 100 }
},
clientRegistrationOptions: {
rateLimit: { windowMs: 60000, limit: 10 }
}
}));
// Rate limiting for custom endpoints
const introspectRateLimit = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
limit: 100, // 100 introspections per 5 minutes
message: { error: 'too_many_requests', error_description: 'Token introspection rate limit exceeded' }
});
const fakeAuthRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
limit: 20, // 20 auth attempts per minute
message: { error: 'too_many_requests', error_description: 'Authentication rate limit exceeded' }
});
const staticFileRateLimit = rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
limit: 25, // 25 requests per 10 minutes for static files
message: { error: 'too_many_requests', error_description: 'Static file rate limit exceeded' }
});
// Token introspection endpoint (RFC 7662)
app.post('/introspect', introspectRateLimit, express.urlencoded({ extended: false }), async (req, res) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({ error: 'invalid_request', error_description: 'Missing token parameter' });
}
// Verify the token using the auth provider
const authInfo = await authProvider.verifyAccessToken(token);
// Return RFC 7662 compliant response
res.json({
active: true,
client_id: authInfo.clientId,
scope: authInfo.scopes.join(' '),
exp: authInfo.expiresAt,
sub: authInfo.extra?.userId || 'unknown',
userId: authInfo.extra?.userId, // Custom field for our implementation
username: authInfo.extra?.username,
iss: AUTH_SERVER_URL,
aud: BASE_URI, // The resource server URL this token is intended for
token_type: 'Bearer'
});
} catch (error) {
logger.debug('Token introspection failed', { error: (error as Error).message });
// Return inactive token response (don't leak error details)
res.json({
active: false
});
}
});
// Fake upstream auth endpoints (for user authentication simulation)
app.get('/fakeupstreamauth/authorize', fakeAuthRateLimit, cors(), handleFakeAuthorize);
app.get('/fakeupstreamauth/callback', fakeAuthRateLimit, cors(), handleFakeAuthorizeRedirect);
// Static assets (for auth page styling)
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.get('/mcp-logo.png', staticFileRateLimit, (req, res) => {
// Serve from the main server's static directory
const logoPath = path.join(__dirname, '../src/static/mcp.png');
res.sendFile(logoPath);
});
// Connect to Redis (shared with MCP server in dev)
try {
await redisClient.connect();
logger.info('Connected to Redis', { url: redisClient.options?.url });
} catch (error) {
logger.error('Could not connect to Redis', error as Error);
process.exit(1);
}
app.listen(AUTH_SERVER_PORT, () => {
logger.info('Authorization server started', {
port: AUTH_SERVER_PORT,
url: AUTH_SERVER_URL,
endpoints: {
metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`,
authorize: `${AUTH_SERVER_URL}/authorize`,
token: `${AUTH_SERVER_URL}/token`,
register: `${AUTH_SERVER_URL}/register`,
introspect: `${AUTH_SERVER_URL}/introspect`
}
});
console.log('');
console.log('🚀 Auth server ready! Test with:');
console.log(` curl ${AUTH_SERVER_URL}/health`);
console.log(` curl ${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`);
console.log('');
console.log('💡 To test separate mode:');
console.log(' 1. Keep this server running');
console.log(' 2. In another terminal: AUTH_MODE=separate npm run dev');
console.log(' 3. Connect Inspector to http://localhost:3232/mcp');
});

8
docker-compose.yml Normal file
View file

@ -0,0 +1,8 @@
version: '3.8'
services:
redis:
image: redis:7.2-bookworm
restart: unless-stopped
ports:
- "6379:6379"

View file

@ -1,21 +1,45 @@
# Design Document: Implementing Streamable HTTP Transport for Example Remote Server
# Design Document: Transport Implementation for Example Remote Server
## Research Summary
## Current Implementation
### Current SSE Transport Architecture
The example remote server implements both MCP transport methods to support different client needs.
The example remote server currently uses the following architecture:
### 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. **Redis Integration**: Messages are published/subscribed through Redis channels using session IDs
4. **Auth**: Uses `requireBearerAuth` middleware with `EverythingAuthProvider`
5. **Session Management**: Each SSE connection gets a unique session ID used as Redis channel key
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:91` - SSE endpoint with auth and headers
- `/src/handlers/mcp.ts:55-118` - SSE connection handler with Redis integration
- `/src/handlers/mcp.ts:120-144` - Message POST handler
- `/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)

View file

@ -127,6 +127,7 @@ 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)
@ -134,6 +135,17 @@ mcp:shttp:toclient:{sessionId}:{requestId} → [pub/sub channel] # Server→Clie
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 |

533
package-lock.json generated
View file

@ -13,6 +13,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^8.0.1",
"raw-body": "^3.0.0"
},
"devDependencies": {
@ -22,6 +23,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.0",
"concurrently": "^8.2.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"tsx": "^4.19.2",
@ -29,20 +31,6 @@
"typescript-eslint": "^8.18.0"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@ -59,9 +47,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz",
"integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==",
"dev": true,
"license": "MIT",
"engines": {
@ -69,22 +57,22 @@
}
},
"node_modules/@babel/core": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.28.3",
"@babel/helpers": "^7.28.3",
"@babel/parser": "^7.28.3",
"@babel/helpers": "^7.28.4",
"@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@babel/traverse": "^7.28.4",
"@babel/types": "^7.28.4",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@ -216,27 +204,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
"integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.2"
"@babel/types": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
"@babel/types": "^7.28.4"
},
"bin": {
"parser": "bin/babel-parser.js"
@ -484,6 +472,16 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@ -500,18 +498,18 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
"integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
"integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.2",
"@babel/types": "^7.28.4",
"debug": "^4.3.1"
},
"engines": {
@ -519,9 +517,9 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -982,9 +980,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1090,9 +1088,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz",
"integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==",
"version": "9.35.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz",
"integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==",
"dev": true,
"license": "MIT",
"engines": {
@ -1140,35 +1138,20 @@
}
},
"node_modules/@humanfs/node": {
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
"integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.3.0"
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
"integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@ -1619,6 +1602,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@ -1637,9 +1631,9 @@
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1648,9 +1642,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.17.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.4.tgz",
"integrity": "sha512-zq24hfuAmmlNZvik0FLI58uE5sriN0WWsQzIlYnzSuKDAHFqJtBFrl/LfB1NLgJT5Y7dEBzaX4yAKqOPrcetaw==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.0.tgz",
"integrity": "sha512-JvKyB6YwS3quM+88JPR0axeRgvdDu3Pv6mdZUy+w4qVkCzGgumb9bXG/TmtDRQv+671yaofVfXSQmFLlWU5qPQ==",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
@ -1766,6 +1760,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
@ -2187,9 +2196,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.18.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
"integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
"version": "22.18.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz",
"integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2258,17 +2267,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz",
"integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz",
"integrity": "sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.41.0",
"@typescript-eslint/type-utils": "8.41.0",
"@typescript-eslint/utils": "8.41.0",
"@typescript-eslint/visitor-keys": "8.41.0",
"@typescript-eslint/scope-manager": "8.43.0",
"@typescript-eslint/type-utils": "8.43.0",
"@typescript-eslint/utils": "8.43.0",
"@typescript-eslint/visitor-keys": "8.43.0",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@ -2282,7 +2291,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.41.0",
"@typescript-eslint/parser": "^8.43.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
@ -2298,16 +2307,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz",
"integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.43.0.tgz",
"integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.41.0",
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/typescript-estree": "8.41.0",
"@typescript-eslint/visitor-keys": "8.41.0",
"@typescript-eslint/scope-manager": "8.43.0",
"@typescript-eslint/types": "8.43.0",
"@typescript-eslint/typescript-estree": "8.43.0",
"@typescript-eslint/visitor-keys": "8.43.0",
"debug": "^4.3.4"
},
"engines": {
@ -2323,14 +2332,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz",
"integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.43.0.tgz",
"integrity": "sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.41.0",
"@typescript-eslint/types": "^8.41.0",
"@typescript-eslint/tsconfig-utils": "^8.43.0",
"@typescript-eslint/types": "^8.43.0",
"debug": "^4.3.4"
},
"engines": {
@ -2345,14 +2354,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz",
"integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.43.0.tgz",
"integrity": "sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/visitor-keys": "8.41.0"
"@typescript-eslint/types": "8.43.0",
"@typescript-eslint/visitor-keys": "8.43.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2363,9 +2372,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz",
"integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.43.0.tgz",
"integrity": "sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==",
"dev": true,
"license": "MIT",
"engines": {
@ -2380,15 +2389,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz",
"integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.43.0.tgz",
"integrity": "sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/typescript-estree": "8.41.0",
"@typescript-eslint/utils": "8.41.0",
"@typescript-eslint/types": "8.43.0",
"@typescript-eslint/typescript-estree": "8.43.0",
"@typescript-eslint/utils": "8.43.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@ -2405,9 +2414,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz",
"integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.43.0.tgz",
"integrity": "sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==",
"dev": true,
"license": "MIT",
"engines": {
@ -2419,16 +2428,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz",
"integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.43.0.tgz",
"integrity": "sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.41.0",
"@typescript-eslint/tsconfig-utils": "8.41.0",
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/visitor-keys": "8.41.0",
"@typescript-eslint/project-service": "8.43.0",
"@typescript-eslint/tsconfig-utils": "8.43.0",
"@typescript-eslint/types": "8.43.0",
"@typescript-eslint/visitor-keys": "8.43.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@ -2487,16 +2496,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz",
"integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.43.0.tgz",
"integrity": "sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.41.0",
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/typescript-estree": "8.41.0"
"@typescript-eslint/scope-manager": "8.43.0",
"@typescript-eslint/types": "8.43.0",
"@typescript-eslint/typescript-estree": "8.43.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2511,13 +2520,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz",
"integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.43.0.tgz",
"integrity": "sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.41.0",
"@typescript-eslint/types": "8.43.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@ -2854,9 +2863,9 @@
}
},
"node_modules/browserslist": {
"version": "4.25.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz",
"integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==",
"version": "4.25.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz",
"integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==",
"dev": true,
"funding": [
{
@ -2874,8 +2883,8 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001735",
"electron-to-chromium": "^1.5.204",
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3"
},
@ -2975,9 +2984,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001737",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz",
"integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==",
"version": "1.0.30001741",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz",
"integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==",
"dev": true,
"funding": [
{
@ -3012,6 +3021,19 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/char-regex": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
@ -3114,6 +3136,34 @@
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -3206,6 +3256,23 @@
"node": ">= 8"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -3224,9 +3291,9 @@
}
},
"node_modules/dedent": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz",
"integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz",
"integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@ -3328,9 +3395,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.209",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.209.tgz",
"integrity": "sha512-Xoz0uMrim9ZETCQt8UgM5FxQF9+imA7PBpokoGcZloA1uw2LeHzTlip5cb5KOAsXZLjh/moN2vReN3ZjJmjI9A==",
"version": "1.5.218",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz",
"integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==",
"dev": true,
"license": "ISC"
},
@ -3476,20 +3543,20 @@
}
},
"node_modules/eslint": {
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz",
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
"version": "9.35.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz",
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.1",
"@eslint/core": "^0.15.2",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.34.0",
"@eslint/js": "9.35.0",
"@eslint/plugin-kit": "^0.3.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@ -3673,12 +3740,12 @@
}
},
"node_modules/eventsource-parser": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.5.tgz",
"integrity": "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
"node": ">=18.0.0"
}
},
"node_modules/execa": {
@ -3778,10 +3845,13 @@
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz",
"integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
@ -4383,6 +4453,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -4548,6 +4627,19 @@
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
@ -5157,22 +5249,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-worker/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -5319,6 +5395,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -5567,9 +5650,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"version": "2.0.21",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz",
"integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==",
"dev": true,
"license": "MIT"
},
@ -6046,30 +6129,34 @@
}
},
"node_modules/raw-body": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.6.3",
"iconv-lite": "0.7.0",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
"node": ">= 0.10"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/react-is": {
@ -6192,12 +6279,13 @@
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"engines": {
"node": ">=16"
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/run-parallel": {
@ -6224,6 +6312,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -6350,6 +6448,19 @@
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -6467,6 +6578,12 @@
"source-map": "^0.6.0"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@ -6582,16 +6699,19 @@
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/supports-preserve-symlinks-flag": {
@ -6651,6 +6771,16 @@
"node": ">=0.6"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@ -6743,6 +6873,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.5",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
@ -6828,16 +6965,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.41.0.tgz",
"integrity": "sha512-n66rzs5OBXW3SFSnZHr2T685q1i4ODm2nulFJhMZBotaTavsS8TrI3d7bDlRSs9yWo7HmyWrN9qDu14Qv7Y0Dw==",
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.43.0.tgz",
"integrity": "sha512-FyRGJKUGvcFekRRcBKFBlAhnp4Ng8rhe8tuvvkR9OiU0gfd4vyvTRQHEckO6VDlH57jbeUQem2IpqPq9kLJH+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.41.0",
"@typescript-eslint/parser": "8.41.0",
"@typescript-eslint/typescript-estree": "8.41.0",
"@typescript-eslint/utils": "8.41.0"
"@typescript-eslint/eslint-plugin": "8.43.0",
"@typescript-eslint/parser": "8.43.0",
"@typescript-eslint/typescript-estree": "8.43.0",
"@typescript-eslint/utils": "8.43.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View file

@ -3,15 +3,24 @@
"version": "0.1.0",
"description": "Example MCP Server",
"type": "module",
"main": "dist/index.js",
"main": "dist/src/index.js",
"scripts": {
"start": "node dist/index.js",
"start": "node dist/src/index.js",
"start:auth-server": "node dist/auth-server/index.js",
"dev": "tsx watch --inspect src/index.ts",
"dev:break": "tsx --inspect-brk watch src/index.ts",
"dev:integrated": "AUTH_MODE=integrated npm run dev",
"dev:separate": "AUTH_MODE=separate AUTH_SERVER_URL=http://localhost:3001 npm run dev",
"dev:auth-server": "AUTH_SERVER_PORT=3001 tsx watch --inspect auth-server/index.ts",
"dev:with-separate-auth": "concurrently -n \"AUTH,MCP\" -c \"yellow,cyan\" \"npm run dev:auth-server\" \"npm run dev:separate\"",
"build": "tsc && npm run copy-static",
"copy-static": "mkdir -p dist/static && cp -r src/static/* dist/static/",
"lint": "eslint src/",
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
"copy-static": "mkdir -p dist/src/static && cp -r src/static/* dist/src/static/",
"lint": "eslint src/ auth-server/ shared/",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"test:integrated": "AUTH_MODE=integrated npm test",
"test:separate": "AUTH_MODE=separate npm test",
"test:e2e:integrated": "./scripts/test-integrated-e2e.sh",
"test:e2e:separate": "./scripts/test-separate-e2e.sh"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
@ -20,6 +29,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.0",
"concurrently": "^8.2.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"tsx": "^4.19.2",
@ -32,6 +42,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^8.0.1",
"raw-body": "^3.0.0"
},
"overrides": {

235
scripts/test-integrated-e2e.sh Executable file
View file

@ -0,0 +1,235 @@
#!/bin/bash
set -e
echo "=================================================="
echo "End-to-End Test - Integrated Mode"
echo "=================================================="
# Kill any existing servers
echo "🛑 Cleaning up existing servers..."
pkill -f "node.*dist/src/index" || true
pkill -f "tsx watch.*src/index" || true
sleep 2
# Use environment variables if available, otherwise defaults
MCP_SERVER="${BASE_URI:-http://localhost:3232}"
USER_ID="e2e-test-$(date +%s)"
echo "🔧 Configuration:"
echo " MCP Server: $MCP_SERVER"
echo " User ID: $USER_ID"
echo " Auth Mode: ${AUTH_MODE:-integrated} (from environment)"
echo ""
# Check prerequisites
echo "🔍 Checking prerequisites..."
# Check Redis
if ! docker ps | grep -q redis; then
echo "❌ Redis not running"
echo " Start Redis: docker compose up -d"
exit 1
fi
echo "✅ Redis is running"
# Check if wrong mode is set
if [ "${AUTH_MODE:-integrated}" != "integrated" ]; then
echo "⚠️ AUTH_MODE is set to '${AUTH_MODE}' but this script tests integrated mode"
echo " Either run: AUTH_MODE=integrated $0"
echo " Or use: ./scripts/test-separate-e2e.sh"
fi
# Start MCP server in integrated mode
echo "🚀 Starting MCP server in integrated mode..."
AUTH_MODE=integrated npm start &
MCP_PID=$!
sleep 5
# Check MCP server
if ! curl -s -f "$MCP_SERVER/" > /dev/null; then
echo "❌ MCP server failed to start at $MCP_SERVER"
kill $MCP_PID 2>/dev/null || true
exit 1
fi
echo "✅ MCP server is running (PID: $MCP_PID)"
# Clean up on exit
trap "kill $MCP_PID 2>/dev/null || true" EXIT
echo "🔐 PHASE 1: OAuth Authentication"
echo "================================"
# OAuth Step 1: Client Registration
# Register a new OAuth client application with the authorization server
# This would typically be done once during app setup, not for each user
CLIENT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d '{"client_name":"e2e-fixed","redirect_uris":["http://localhost:3000/callback"]}' "$MCP_SERVER/register")
CLIENT_ID=$(echo "$CLIENT_RESPONSE" | jq -r .client_id)
CLIENT_SECRET=$(echo "$CLIENT_RESPONSE" | jq -r .client_secret)
# OAuth Step 2: Generate PKCE (Proof Key for Code Exchange) parameters
# PKCE adds security to the OAuth flow by preventing authorization code interception attacks
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr "+/" "-_" | tr -d "=")
# OAuth Step 3: Authorization Request
# Direct the user to the authorization server's /authorize endpoint
# Include state parameter for CSRF protection
STATE_PARAM="e2e-state-$(date +%s)"
AUTH_PAGE=$(curl -s "$MCP_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=$STATE_PARAM")
# Extract the authorization code from the HTML response (normally would be in redirect URL)
AUTH_CODE=$(echo "$AUTH_PAGE" | grep -o 'state=[^"&]*' | cut -d= -f2)
# OAuth Step 4: User Authentication & Authorization
# In a real flow, the user would authenticate with the auth server here
# For testing, we simulate this with the fake upstream auth endpoint
CALLBACK_RESPONSE=$(curl -s -i "$MCP_SERVER/fakeupstreamauth/callback?state=$AUTH_CODE&code=fakecode&userId=$USER_ID")
# OAuth Step 5: Authorization Code Redirect
# Verify the auth server redirects back to our redirect_uri with the code and state
# The state parameter MUST match what we sent to prevent CSRF attacks
LOCATION_HEADER=$(echo "$CALLBACK_RESPONSE" | grep -i "^location:" | tr -d '\r')
if echo "$LOCATION_HEADER" | grep -q "state=$STATE_PARAM"; then
echo "✅ State parameter verified in callback"
else
echo "❌ State parameter mismatch or missing in callback"
echo " Expected state: $STATE_PARAM"
echo " Location header: $LOCATION_HEADER"
exit 1
fi
# OAuth Step 6: Token Exchange
# Exchange the authorization code for access and refresh tokens
# Include the PKCE code_verifier to prove we initiated the flow
TOKEN_RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=authorization_code&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&code=$AUTH_CODE&redirect_uri=http://localhost:3000/callback&code_verifier=$CODE_VERIFIER" "$MCP_SERVER/token")
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r .access_token)
echo "✅ OAuth complete, token: ${ACCESS_TOKEN:0:15}..."
echo ""
echo "🧪 PHASE 2: MCP Feature Testing"
echo "==============================="
# Step 1: Initialize MCP session (no session ID header)
echo ""
echo "📱 Step 1: Initialize MCP session"
INIT_RESPONSE=$(curl -i -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e-test","version":"1.0"}}}' \
"$MCP_SERVER/mcp")
# Extract session ID from response header
SESSION_ID=$(echo "$INIT_RESPONSE" | grep -i "mcp-session-id:" | cut -d' ' -f2 | tr -d '\r')
if [ -n "$SESSION_ID" ]; then
echo " ✅ Session initialized: $SESSION_ID"
else
echo " ❌ No session ID in response headers"
echo "Headers:"
echo "$INIT_RESPONSE" | head -20
exit 1
fi
# Step 2: Test tools with session ID
echo ""
echo "🔧 Step 2: Test Tools"
TOOLS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"tools","method":"tools/list"}' \
"$MCP_SERVER/mcp")
if echo "$TOOLS_RESPONSE" | grep -q "event: message"; then
TOOLS_JSON=$(echo "$TOOLS_RESPONSE" | grep "^data: " | sed 's/^data: //')
TOOL_COUNT=$(echo "$TOOLS_JSON" | jq '.result.tools | length')
echo " ✅ Tools: $TOOL_COUNT (README claims: 7)"
# Test echo tool
ECHO_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"echo","method":"tools/call","params":{"name":"echo","arguments":{"message":"E2E working!"}}}' \
"$MCP_SERVER/mcp")
if echo "$ECHO_RESPONSE" | grep -q "event: message"; then
ECHO_JSON=$(echo "$ECHO_RESPONSE" | grep "^data: " | sed 's/^data: //')
ECHO_RESULT=$(echo "$ECHO_JSON" | jq -r '.result.content[0].text')
echo " 🔊 Echo test: '$ECHO_RESULT'"
fi
else
echo " ❌ Tools test failed: $TOOLS_RESPONSE"
fi
# Step 3: Test resources
echo ""
echo "📚 Step 3: Test Resources (counting all pages)"
TOTAL_RESOURCES=0
CURSOR=""
PAGE=1
while true; do
if [ -n "$CURSOR" ]; then
PARAMS="{\"cursor\":\"$CURSOR\"}"
else
PARAMS="{}"
fi
RESOURCES_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-X POST -H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":\"resources$PAGE\",\"method\":\"resources/list\",\"params\":$PARAMS}" \
"$MCP_SERVER/mcp")
if echo "$RESOURCES_RESPONSE" | grep -q "event: message"; then
RESOURCES_JSON=$(echo "$RESOURCES_RESPONSE" | grep "^data: " | sed 's/^data: //')
PAGE_COUNT=$(echo "$RESOURCES_JSON" | jq '.result.resources | length')
NEXT_CURSOR=$(echo "$RESOURCES_JSON" | jq -r '.result.nextCursor // empty')
TOTAL_RESOURCES=$((TOTAL_RESOURCES + PAGE_COUNT))
echo " 📄 Page $PAGE: $PAGE_COUNT resources (total: $TOTAL_RESOURCES)"
if [ -z "$NEXT_CURSOR" ]; then
break
fi
CURSOR="$NEXT_CURSOR"
PAGE=$((PAGE + 1))
else
echo " ❌ Resources page $PAGE failed: $RESOURCES_RESPONSE"
break
fi
done
RESOURCE_COUNT=$TOTAL_RESOURCES
echo " 📊 Total Resources: $RESOURCE_COUNT (README claims: 100)"
# Step 4: Test prompts
echo ""
echo "💭 Step 4: Test Prompts"
PROMPTS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"prompts","method":"prompts/list"}' \
"$MCP_SERVER/mcp")
if echo "$PROMPTS_RESPONSE" | grep -q "event: message"; then
PROMPTS_JSON=$(echo "$PROMPTS_RESPONSE" | grep "^data: " | sed 's/^data: //')
PROMPT_COUNT=$(echo "$PROMPTS_JSON" | jq '.result.prompts | length')
echo " 💬 Prompts: $PROMPT_COUNT"
else
echo " ❌ Prompts test failed: $PROMPTS_RESPONSE"
fi
echo ""
echo "🎉 INTEGRATED MODE E2E TEST COMPLETE!"
echo "====================================="
echo "📊 Verification Results:"
echo " Tools: $TOOL_COUNT (README: 7) $([ "$TOOL_COUNT" = "7" ] && echo "✅" || echo "❌")"
echo " Resources: $RESOURCE_COUNT (README: 100) $([ "$RESOURCE_COUNT" = "100" ] && echo "✅" || echo "❌")"
echo " Prompts: $PROMPT_COUNT"
echo " OAuth flow: ✅ Working"
echo " MCP features: ✅ Working"

295
scripts/test-separate-e2e.sh Executable file
View file

@ -0,0 +1,295 @@
#!/bin/bash
set -e
echo "=================================================="
echo "End-to-End Test - Separate Mode"
echo "=================================================="
echo "This script tests the complete OAuth flow and MCP features"
echo "using separate auth server and MCP server."
echo ""
# Kill any existing servers
echo "🛑 Cleaning up existing servers..."
pkill -f "node.*dist/src/index" || true
pkill -f "node.*dist/auth-server/index" || true
pkill -f "tsx watch.*src/index" || true
pkill -f "tsx watch.*auth-server/index" || true
sleep 2
# Use environment variables if available, otherwise defaults
AUTH_SERVER="${AUTH_SERVER_URL:-http://localhost:3001}"
MCP_SERVER="${BASE_URI:-http://localhost:3232}"
USER_ID="e2e-separate-$(date +%s)"
echo "🔧 Configuration:"
echo " Auth Server: $AUTH_SERVER"
echo " MCP Server: $MCP_SERVER"
echo " User ID: $USER_ID"
echo " Auth Mode: ${AUTH_MODE:-separate} (from environment)"
echo ""
# Check prerequisites
echo "🔍 Checking prerequisites..."
# Check Redis
if ! docker ps | grep -q redis; then
echo "❌ Redis not running"
echo " Start Redis: docker compose up -d"
exit 1
fi
echo "✅ Redis is running"
# Check if wrong mode is set
if [ "${AUTH_MODE}" = "integrated" ]; then
echo "⚠️ AUTH_MODE is set to 'integrated' but this script tests separate mode"
echo " Either run: AUTH_MODE=separate $0"
echo " Or use: ./scripts/test-integrated-e2e-fixed.sh"
fi
# Start auth server
echo "🚀 Starting auth server..."
npm run start:auth-server &
AUTH_PID=$!
sleep 5
# Check auth server
if ! curl -s -f "$AUTH_SERVER/health" > /dev/null; then
echo "❌ Auth server failed to start at $AUTH_SERVER"
kill $AUTH_PID 2>/dev/null || true
exit 1
fi
echo "✅ Auth server is running (PID: $AUTH_PID)"
# Start MCP server in separate mode
echo "🚀 Starting MCP server in separate mode..."
AUTH_MODE=separate npm start &
MCP_PID=$!
sleep 5
# Check MCP server
if ! curl -s -f "$MCP_SERVER/" > /dev/null; then
echo "❌ MCP server failed to start at $MCP_SERVER"
kill $AUTH_PID 2>/dev/null || true
kill $MCP_PID 2>/dev/null || true
exit 1
fi
echo "✅ MCP server is running (PID: $MCP_PID)"
# Clean up on exit
trap "kill $AUTH_PID $MCP_PID 2>/dev/null || true" EXIT
echo ""
echo "🔐 PHASE 1: OAuth Authentication (with Auth Server)"
echo "================================================="
# OAuth Step 1: Client Registration
# Register a new OAuth client application with the authorization server
# This would typically be done once during app setup, not for each user
echo "📝 Step 1: Register OAuth client with auth server"
CLIENT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"client_name\":\"e2e-separate-client\",\"redirect_uris\":[\"http://localhost:3000/callback\"]}" \
"$AUTH_SERVER/register")
CLIENT_ID=$(echo "$CLIENT_RESPONSE" | jq -r .client_id)
CLIENT_SECRET=$(echo "$CLIENT_RESPONSE" | jq -r .client_secret)
echo " Client ID: $CLIENT_ID"
# OAuth Step 2: Generate PKCE (Proof Key for Code Exchange) parameters
# PKCE adds security to the OAuth flow by preventing authorization code interception attacks
echo ""
echo "🔐 Step 2: Generate PKCE challenge"
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr "+/" "-_" | tr -d "=")
echo " Code verifier generated"
# OAuth Step 3: Authorization Request
# Direct the user to the authorization server's /authorize endpoint
# Include state parameter for CSRF protection
echo ""
echo "🎫 Step 3: Get authorization code from auth server"
STATE_PARAM="separate-test-$(date +%s)"
AUTH_URL="$AUTH_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=$STATE_PARAM"
AUTH_PAGE=$(curl -s "$AUTH_URL")
# Extract the authorization code from the HTML response (normally would be in redirect URL)
AUTH_CODE=$(echo "$AUTH_PAGE" | grep -o 'state=[^"&]*' | cut -d= -f2 | head -1)
if [ -z "$AUTH_CODE" ]; then
echo " ❌ Failed to extract authorization code from auth server"
exit 1
fi
echo " Auth Code: ${AUTH_CODE:0:20}..."
# OAuth Step 4: User Authentication & Authorization
# In a real flow, the user would authenticate with the auth server here
# For testing, we simulate this with the fake upstream auth endpoint
echo ""
echo "🔄 Step 4: Complete fake upstream auth with auth server"
CALLBACK_URL="$AUTH_SERVER/fakeupstreamauth/callback?state=$AUTH_CODE&code=fakecode&userId=$USER_ID"
CALLBACK_RESPONSE=$(curl -s -i "$CALLBACK_URL")
# OAuth Step 5: Authorization Code Redirect
# Verify the auth server redirects back to our redirect_uri with the code and state
# The state parameter MUST match what we sent to prevent CSRF attacks
LOCATION_HEADER=$(echo "$CALLBACK_RESPONSE" | grep -i "^location:" | tr -d '\r')
if echo "$LOCATION_HEADER" | grep -q "state=$STATE_PARAM"; then
echo " ✅ State parameter verified in callback"
else
echo " ❌ State parameter mismatch or missing in callback"
echo " Expected state: $STATE_PARAM"
echo " Location header: $LOCATION_HEADER"
exit 1
fi
echo " Fake upstream auth completed"
# OAuth Step 6: Token Exchange
# Exchange the authorization code for access and refresh tokens
# Include the PKCE code_verifier to prove we initiated the flow
echo ""
echo "🎟️ Step 5: Exchange code for access token with auth server"
TOKEN_RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&code=$AUTH_CODE&redirect_uri=http://localhost:3000/callback&code_verifier=$CODE_VERIFIER" \
"$AUTH_SERVER/token")
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r .access_token)
if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then
echo " ❌ Token exchange failed"
echo "Response: $TOKEN_RESPONSE"
exit 1
fi
echo " ✅ Access token from auth server: ${ACCESS_TOKEN:0:20}..."
echo ""
echo "🧪 PHASE 2: MCP Feature Testing (with MCP Server)"
echo "=============================================="
# Step 1: Initialize MCP session with MCP SERVER using auth server token
echo ""
echo "📱 Step 1: Initialize MCP session with MCP server"
INIT_RESPONSE=$(curl -i -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e-separate","version":"1.0"}}}' \
"$MCP_SERVER/mcp")
# Extract session ID from response header
SESSION_ID=$(echo "$INIT_RESPONSE" | grep -i "mcp-session-id:" | cut -d' ' -f2 | tr -d '\r')
if [ -n "$SESSION_ID" ]; then
echo " ✅ MCP session initialized: $SESSION_ID"
echo " ✅ Auth server token accepted by MCP server!"
else
echo " ❌ MCP session initialization failed"
echo "$INIT_RESPONSE"
exit 1
fi
# Step 2: Test tools with MCP SERVER
echo ""
echo "🔧 Step 2: Test Tools with MCP server"
TOOLS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"tools","method":"tools/list"}' \
"$MCP_SERVER/mcp")
if echo "$TOOLS_RESPONSE" | grep -q "event: message"; then
TOOLS_JSON=$(echo "$TOOLS_RESPONSE" | grep "^data: " | sed 's/^data: //')
TOOL_COUNT=$(echo "$TOOLS_JSON" | jq '.result.tools | length')
echo " ✅ Tools: $TOOL_COUNT (README claims: 7)"
# Test echo tool
ECHO_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"echo","method":"tools/call","params":{"name":"echo","arguments":{"message":"Separate mode working!"}}}' \
"$MCP_SERVER/mcp")
if echo "$ECHO_RESPONSE" | grep -q "event: message"; then
ECHO_JSON=$(echo "$ECHO_RESPONSE" | grep "^data: " | sed 's/^data: //')
ECHO_RESULT=$(echo "$ECHO_JSON" | jq -r '.result.content[0].text')
echo " 🔊 Echo test: '$ECHO_RESULT'"
fi
else
echo " ❌ Tools test failed: $TOOLS_RESPONSE"
fi
# Step 3: Test resources with MCP SERVER (with pagination)
echo ""
echo "📚 Step 3: Test Resources with MCP server (counting all pages)"
TOTAL_RESOURCES=0
CURSOR=""
PAGE=1
while true; do
if [ -n "$CURSOR" ]; then
PARAMS="{\"cursor\":\"$CURSOR\"}"
else
PARAMS="{}"
fi
RESOURCES_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-X POST -H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":\"resources$PAGE\",\"method\":\"resources/list\",\"params\":$PARAMS}" \
"$MCP_SERVER/mcp")
if echo "$RESOURCES_RESPONSE" | grep -q "event: message"; then
RESOURCES_JSON=$(echo "$RESOURCES_RESPONSE" | grep "^data: " | sed 's/^data: //')
PAGE_COUNT=$(echo "$RESOURCES_JSON" | jq '.result.resources | length')
NEXT_CURSOR=$(echo "$RESOURCES_JSON" | jq -r '.result.nextCursor // empty')
TOTAL_RESOURCES=$((TOTAL_RESOURCES + PAGE_COUNT))
echo " 📄 Page $PAGE: $PAGE_COUNT resources (total: $TOTAL_RESOURCES)"
if [ -z "$NEXT_CURSOR" ]; then
break
fi
CURSOR="$NEXT_CURSOR"
PAGE=$((PAGE + 1))
else
echo " ❌ Resources page $PAGE failed: $RESOURCES_RESPONSE"
break
fi
done
RESOURCE_COUNT=$TOTAL_RESOURCES
echo " 📊 Total Resources: $RESOURCE_COUNT (README claims: 100)"
# Step 4: Test prompts with MCP SERVER
echo ""
echo "💭 Step 4: Test Prompts with MCP server"
PROMPTS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"prompts","method":"prompts/list"}' \
"$MCP_SERVER/mcp")
if echo "$PROMPTS_RESPONSE" | grep -q "event: message"; then
PROMPTS_JSON=$(echo "$PROMPTS_RESPONSE" | grep "^data: " | sed 's/^data: //')
PROMPT_COUNT=$(echo "$PROMPTS_JSON" | jq '.result.prompts | length')
echo " 💬 Prompts: $PROMPT_COUNT"
else
echo " ❌ Prompts test failed: $PROMPTS_RESPONSE"
fi
echo ""
echo "🎉 SEPARATE MODE E2E TEST COMPLETE!"
echo "==================================="
echo "✅ OAuth flow: Auth server → MCP server delegation working"
echo "✅ Token validation: MCP server accepts auth server tokens"
echo "✅ Session management: MCP server creates sessions for external tokens"
echo ""
echo "📊 Verification Results:"
echo " Tools: $TOOL_COUNT (README: 7) $([ "$TOOL_COUNT" = "7" ] && echo "✅" || echo "❌")"
echo " Resources: $RESOURCE_COUNT (README: 100) $([ "$RESOURCE_COUNT" = "100" ] && echo "✅" || echo "❌")"
echo " Prompts: $PROMPT_COUNT"
echo ""
echo "🏗️ Architecture Verified:"
echo " ✅ Separate auth server provides OAuth endpoints"
echo " ✅ MCP server validates tokens via introspection"
echo " ✅ Session ownership works across server boundaries"

87
shared/auth-core.ts Normal file
View file

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

285
shared/redis-auth.ts Normal file
View file

@ -0,0 +1,285 @@
import { SetOptions } from "@redis/client";
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
import { RedisClient } from "../src/redis.js";
import { McpInstallation, PendingAuthorization, TokenExchange } from "./types.js";
import { sha256, encryptString, decryptString } from "./auth-core.js";
import { logger } from "../src/utils/logger.js";
/**
* Redis key prefixes for different data types
* All auth-related keys use "auth:" prefix to avoid collision with MCP session keys
*/
export const REDIS_KEY_PREFIXES = {
CLIENT_REGISTRATION: "auth:client:",
PENDING_AUTHORIZATION: "auth:pending:",
MCP_AUTHORIZATION: "auth:installation:", // Changed from "mcp:" to avoid collision
TOKEN_EXCHANGE: "auth:exch:",
REFRESH_TOKEN: "auth:refresh:",
} as const;
/**
* Redis key expiry times in seconds
*/
export const REDIS_EXPIRY_TIMES = {
CLIENT_REGISTRATION: 30 * 24 * 60 * 60, // 30 days - client app credentials
PENDING_AUTHORIZATION: 10 * 60, // 10 minutes - authorization code -> PendingAuthorization
TOKEN_EXCHANGE: 10 * 60, // 10 minutes - authorization code -> MCP access token
UPSTREAM_INSTALLATION: 7 * 24 * 60 * 60, // 7 days - MCP access token -> UpstreamInstallation
REFRESH_TOKEN: 7 * 24 * 60 * 60, // 7 days - MCP refresh token -> access token
} as const;
/**
* Saves encrypted data to Redis with optional expiry.
*/
async function saveEncrypted<T>(
redisClient: RedisClient,
{
prefix,
key,
data,
options,
}: {
prefix: string;
key: string;
data: T;
options?: SetOptions;
}
): Promise<string | null> {
const value = encryptString({
text: JSON.stringify(data),
key: key,
});
return await redisClient.set(prefix + sha256(key), value, options);
}
/**
* Reads and decrypts data from Redis.
*/
async function readEncrypted<T>(
redisClient: RedisClient,
{
prefix,
key,
del = false,
}: {
prefix: string;
key: string;
del?: boolean;
}
): Promise<T | undefined> {
const data = del
? await redisClient.getDel(prefix + sha256(key))
: await redisClient.get(prefix + sha256(key));
if (!data) {
return undefined;
}
const decoded = decryptString({
encryptedText: data,
key: key,
});
return JSON.parse(decoded);
}
/**
* Saves a client registration to Redis.
*/
export async function saveClientRegistration(
redisClient: RedisClient,
clientId: string,
registration: OAuthClientInformationFull
): Promise<void> {
await redisClient.set(
REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId,
JSON.stringify(registration),
{ EX: REDIS_EXPIRY_TIMES.CLIENT_REGISTRATION }
);
}
/**
* Retrieves a client registration from Redis.
*/
export async function getClientRegistration(
redisClient: RedisClient,
clientId: string
): Promise<OAuthClientInformationFull | undefined> {
const data = await redisClient.get(REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId);
if (!data) {
return undefined;
}
return JSON.parse(data);
}
/**
* Saves a pending authorization to Redis.
*/
export async function savePendingAuthorization(
redisClient: RedisClient,
authorizationCode: string,
pendingAuthorization: PendingAuthorization
): Promise<void> {
await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION,
key: authorizationCode,
data: pendingAuthorization,
options: { EX: REDIS_EXPIRY_TIMES.PENDING_AUTHORIZATION },
});
}
/**
* Reads a pending authorization from Redis.
*/
export async function readPendingAuthorization(
redisClient: RedisClient,
authorizationCode: string
): Promise<PendingAuthorization | undefined> {
return readEncrypted<PendingAuthorization>(redisClient, {
prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION,
key: authorizationCode,
});
}
/**
* Saves an MCP installation to Redis.
*/
export async function saveMcpInstallation(
redisClient: RedisClient,
mcpAccessToken: string,
installation: McpInstallation
): Promise<void> {
await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
key: mcpAccessToken,
data: installation,
options: { EX: REDIS_EXPIRY_TIMES.UPSTREAM_INSTALLATION },
});
}
/**
* Reads an MCP installation from Redis.
*/
export async function readMcpInstallation(
redisClient: RedisClient,
mcpAccessToken: string
): Promise<McpInstallation | undefined> {
return readEncrypted<McpInstallation>(redisClient, {
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
key: mcpAccessToken,
});
}
/**
* Links a refresh token to an MCP access token.
*/
export async function saveRefreshToken(
redisClient: RedisClient,
refreshToken: string,
mcpAccessToken: string
): Promise<void> {
await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN,
key: refreshToken,
data: mcpAccessToken,
options: { EX: REDIS_EXPIRY_TIMES.REFRESH_TOKEN },
});
}
/**
* Reads the access token associated with a refresh token.
*/
export async function readRefreshToken(
redisClient: RedisClient,
refreshToken: string
): Promise<string | undefined> {
return readEncrypted<string>(redisClient, {
prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN,
key: refreshToken,
});
}
/**
* Revokes an MCP installation.
*/
export async function revokeMcpInstallation(
redisClient: RedisClient,
mcpAccessToken: string
): Promise<void> {
const installation = await readEncrypted<McpInstallation>(redisClient, {
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
key: mcpAccessToken,
del: true,
});
if (!installation) {
return;
}
// In production, would revoke upstream tokens here
}
/**
* Saves a token exchange record.
*/
export async function saveTokenExchange(
redisClient: RedisClient,
authorizationCode: string,
tokenExchange: TokenExchange
): Promise<void> {
await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE,
key: authorizationCode,
data: tokenExchange,
options: { EX: REDIS_EXPIRY_TIMES.TOKEN_EXCHANGE },
});
}
/**
* Exchanges a temporary authorization code for an MCP access token.
* Will only succeed the first time to prevent replay attacks.
*/
export async function exchangeToken(
redisClient: RedisClient,
authorizationCode: string
): Promise<TokenExchange | undefined> {
const data = await redisClient.get(
REDIS_KEY_PREFIXES.TOKEN_EXCHANGE + sha256(authorizationCode)
);
if (!data) {
return undefined;
}
const decoded = decryptString({
encryptedText: data,
key: authorizationCode,
});
const tokenExchange: TokenExchange = JSON.parse(decoded);
if (tokenExchange.alreadyUsed) {
logger.error('Duplicate use of authorization code detected; revoking tokens', undefined, {
authorizationCode: authorizationCode.substring(0, 8) + '...'
});
await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken);
throw new Error("Duplicate use of authorization code detected; tokens revoked");
}
const rereadData = await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE,
key: authorizationCode,
data: { ...tokenExchange, alreadyUsed: true },
options: { KEEPTTL: true, GET: true },
});
if (rereadData !== data) {
// Data concurrently changed while we were updating it. This necessarily means a duplicate use.
logger.error('Duplicate use of authorization code detected (concurrent update); revoking tokens', undefined, {
authorizationCode: authorizationCode.substring(0, 8) + '...'
});
await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken);
throw new Error("Duplicate use of authorization code detected; tokens revoked");
}
return tokenExchange;
}

92
shared/types.ts Normal file
View file

@ -0,0 +1,92 @@
import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
/**
* Represents a pending OAuth authorization that hasn't been exchanged for tokens yet.
* Stored in Redis with the authorization code as the key.
*/
export interface PendingAuthorization {
/** The redirect URI where the client expects to receive the authorization code */
redirectUri: string;
/** PKCE code challenge - a derived value from the code verifier */
codeChallenge: string;
/** Method used to derive the code challenge (currently only S256 supported) */
codeChallengeMethod: string;
/** The OAuth client ID that initiated the authorization */
clientId: string;
/** Optional state parameter for CSRF protection */
state?: string;
}
/**
* Represents the exchange of an authorization code for an MCP access token.
* Used to prevent replay attacks by tracking if a code has been used.
*/
export interface TokenExchange {
/** The MCP access token that was issued for this authorization code */
mcpAccessToken: string;
/** Whether this authorization code has already been exchanged for tokens */
alreadyUsed: boolean;
}
/**
* Represents fake upstream tokens for demonstration purposes.
* In production, this would contain real upstream provider tokens.
*/
export interface FakeUpstreamInstallation {
/** Simulated access token from the fake upstream provider */
fakeAccessTokenForDemonstration: string;
/** Simulated refresh token from the fake upstream provider */
fakeRefreshTokenForDemonstration: string;
}
/**
* The complete installation object stored in Redis, containing both
* upstream provider information and MCP-specific tokens.
* This object is encrypted using the MCP access token as the key.
*/
export interface McpInstallation {
/** Information from the upstream authentication provider */
fakeUpstreamInstallation: FakeUpstreamInstallation;
/** MCP OAuth tokens issued to the client */
mcpTokens: OAuthTokens;
/** The OAuth client ID associated with this installation */
clientId: string;
/** Unix timestamp (seconds) when the tokens were issued */
issuedAt: number;
/** Unique identifier for the user (not the OAuth client) */
userId: string;
}
/**
* OAuth 2.0 Token Introspection Response
* Based on RFC 7662: https://tools.ietf.org/html/rfc7662
* Used when validating tokens with an external authorization server.
*/
export interface TokenIntrospectionResponse {
/** Whether the token is currently active */
active: boolean;
/** Space-separated list of scopes associated with the token */
scope?: string;
/** Client identifier for the OAuth client that requested the token */
client_id?: string;
/** Human-readable identifier for the resource owner */
username?: string;
/** Type of the token (e.g., "Bearer") */
token_type?: string;
/** Expiration time as seconds since Unix epoch */
exp?: number;
/** Time at which the token was issued as seconds since Unix epoch */
iat?: number;
/** Time before which the token is not valid as seconds since Unix epoch */
nbf?: number;
/** Subject identifier for the resource owner */
sub?: string;
/** Intended audience for the token */
aud?: string | string[];
/** Issuer of the token */
iss?: string;
/** Unique identifier for the token */
jti?: string;
/** Custom field for our implementation to store user ID */
userId?: string;
}

View file

@ -0,0 +1,181 @@
import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { TokenIntrospectionResponse } from '../../shared/types.js';
import { logger } from '../utils/logger.js';
import { BASE_URI } from '../config.js';
/**
* Token verifier that validates tokens with an external authorization server.
* Used when the MCP server is running in 'separate' mode.
*/
export class ExternalAuthVerifier implements OAuthTokenVerifier {
// Token validation cache: token -> { authInfo, expiresAt }
private tokenCache = new Map<string, { authInfo: AuthInfo; expiresAt: number }>();
// Default cache TTL: 60 seconds (conservative for security)
private readonly defaultCacheTTL = 60 * 1000; // milliseconds
// The canonical URI of this MCP server for audience validation
private readonly canonicalUri: string;
/**
* Creates a new external auth verifier.
* @param authServerUrl Base URL of the external authorization server
* @param canonicalUri Optional canonical URI for audience validation (defaults to BASE_URI)
*/
constructor(private authServerUrl: string, canonicalUri?: string) {
this.canonicalUri = canonicalUri || BASE_URI;
// Periodically clean up expired cache entries
setInterval(() => this.cleanupCache(), 60 * 1000); // Every minute
}
/**
* Removes expired entries from the cache.
*/
private cleanupCache(): void {
const now = Date.now();
for (const [token, entry] of this.tokenCache.entries()) {
if (entry.expiresAt <= now) {
this.tokenCache.delete(token);
}
}
}
/**
* Verifies an access token by calling the external auth server's introspection endpoint.
* @param token The access token to verify
* @returns Authentication information if the token is valid
* @throws InvalidTokenError if the token is invalid or expired
*/
async verifyAccessToken(token: string): Promise<AuthInfo> {
// Check cache first
const cached = this.tokenCache.get(token);
if (cached && cached.expiresAt > Date.now()) {
logger.debug('Token validation cache hit', {
token: token.substring(0, 8) + '...',
expiresIn: Math.round((cached.expiresAt - Date.now()) / 1000) + 's'
});
return cached.authInfo;
}
try {
// Token introspection is OAuth 2.0 standard (RFC 7662) for validating tokens
// The auth server checks if the token is valid and returns metadata about it
const response = await fetch(`${this.authServerUrl}/introspect`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `token=${encodeURIComponent(token)}`,
});
if (!response.ok) {
// On 401/403, the token might be invalid - don't cache
if (response.status === 401 || response.status === 403) {
this.tokenCache.delete(token); // Clear any stale cache
}
logger.error('Token introspection request failed', undefined, {
status: response.status,
statusText: response.statusText,
});
throw new InvalidTokenError('Token validation failed');
}
const data: TokenIntrospectionResponse = await response.json();
// Check if token is active
if (!data.active) {
throw new InvalidTokenError('Token is not active');
}
// Check if token is expired
if (data.exp && data.exp < Date.now() / 1000) {
throw new InvalidTokenError('Token has expired');
}
// Validate audience (aud) claim to ensure token is for this MCP server
// According to MCP spec, servers MUST validate that tokens were issued specifically for them
if (data.aud) {
const audiences = Array.isArray(data.aud) ? data.aud : [data.aud];
if (!audiences.includes(this.canonicalUri)) {
logger.error('Token audience mismatch', undefined, {
expectedAudience: this.canonicalUri,
actualAudience: data.aud,
});
throw new InvalidTokenError('Token was not issued for this resource server');
}
} else {
// Log warning if no audience claim present (permissive for backwards compatibility)
logger.info('Token introspection response missing audience claim', {
warning: true,
tokenSub: data.sub,
clientId: data.client_id,
});
}
// Validate token is not used before its 'not before' time (nbf) if present
if (data.nbf && data.nbf > Date.now() / 1000) {
throw new InvalidTokenError('Token is not yet valid (nbf)');
}
// Validate token was issued in the past (iat) if present
if (data.iat && data.iat > Date.now() / 1000 + 60) { // Allow 60s clock skew
throw new InvalidTokenError('Token issued in the future (iat)');
}
// Extract user ID from standard 'sub' claim or custom 'userId' field
const userId = data.sub || data.userId;
if (!userId) {
logger.info('Token introspection response missing user ID', {
hasSub: !!data.sub,
hasUserId: !!data.userId,
});
}
const authInfo: AuthInfo = {
token,
clientId: data.client_id || 'unknown',
scopes: data.scope?.split(' ') || [], // Empty array if no scopes specified (permissive)
expiresAt: data.exp,
extra: {
userId: userId || 'unknown',
// Include other potentially useful fields
username: data.username,
iss: data.iss,
aud: data.aud,
},
};
// Cache the successful introspection result
// Use token expiration if available, otherwise default TTL
const cacheDuration = data.exp
? Math.min((data.exp * 1000) - Date.now(), this.defaultCacheTTL)
: this.defaultCacheTTL;
if (cacheDuration > 0) {
this.tokenCache.set(token, {
authInfo,
expiresAt: Date.now() + cacheDuration
});
logger.debug('Token validation cached', {
token: token.substring(0, 8) + '...',
cacheDuration: Math.round(cacheDuration / 1000) + 's'
});
}
return authInfo;
} catch (error) {
if (error instanceof InvalidTokenError) {
throw error;
}
logger.error('Failed to verify token with external auth server', error as Error, {
authServerUrl: this.authServerUrl,
});
// Network or other errors should be treated as invalid token
// to prevent access with unverifiable tokens
throw new InvalidTokenError('Unable to verify token');
}
}
}

View file

@ -18,6 +18,7 @@ import {
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
@ -67,6 +68,12 @@ export class EverythingAuthProvider implements OAuthServerProvider {
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

View file

@ -1,6 +1,50 @@
import "dotenv/config";
/**
* Port for the MCP server to listen on
*/
export const PORT = Number(process.env.PORT) || 3232;
export const BASE_URI =
process.env.BASE_URI || "https://localhost: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';
/**
* Authentication mode:
* - 'integrated': MCP server acts as its own OAuth server (default)
* - 'separate': MCP server delegates to external auth server
*/
export const AUTH_MODE = (process.env.AUTH_MODE as 'integrated' | 'separate') || 'integrated';
/**
* Port for the standalone auth server (only used in separate mode)
* Used when running the auth-server component
*/
export const AUTH_SERVER_PORT = parseInt(process.env.AUTH_SERVER_PORT || '3001');
/**
* URL of the external authorization server (only used when AUTH_MODE='separate')
* This is where the MCP server will redirect clients for authentication
*/
export const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || `http://localhost:${AUTH_SERVER_PORT}`;
// Validate AUTH_SERVER configuration
if (AUTH_MODE === 'separate') {
const authUrl = new URL(AUTH_SERVER_URL);
if (authUrl.port && parseInt(authUrl.port) !== AUTH_SERVER_PORT) {
throw new Error(`Configuration error: AUTH_SERVER_URL port (${authUrl.port}) doesn't match AUTH_SERVER_PORT (${AUTH_SERVER_PORT})`);
}
}

View file

@ -1,6 +1,7 @@
import { Request, Response } from "express";
import { generateMcpTokens, readPendingAuthorization, saveMcpInstallation, saveRefreshToken, saveTokenExchange } from "../services/auth.js";
import { McpInstallation } from "../types.js";
import { logger } from "../utils/logger.js";
// this module has a fake upstream auth server that returns a fake auth code, it also allows you to authorize or fail
// authorization, to test the different flows
@ -266,6 +267,12 @@ export async function handleFakeAuthorizeRedirect(req: Request, res: Response) {
userId, // User ID from the authorization flow
} = req.query;
logger.debug('Fake auth redirect received', {
mcpAuthorizationCode: typeof mcpAuthorizationCode === 'string' ? mcpAuthorizationCode.substring(0, 8) + '...' : mcpAuthorizationCode,
upstreamAuthorizationCode: typeof upstreamAuthorizationCode === 'string' ? upstreamAuthorizationCode.substring(0, 8) + '...' : upstreamAuthorizationCode,
userId
});
// This is where you'd exchange the upstreamAuthorizationCode for access/refresh tokens
// In this case, we're just going to fake it
const upstreamTokens = await fakeUpstreamTokenExchange(upstreamAuthorizationCode as string);
@ -276,12 +283,21 @@ export async function handleFakeAuthorizeRedirect(req: Request, res: Response) {
}
const pendingAuth = await readPendingAuthorization(mcpAuthorizationCode);
logger.debug('Reading pending authorization', {
mcpAuthorizationCode: mcpAuthorizationCode.substring(0, 8) + '...',
found: !!pendingAuth
});
if (!pendingAuth) {
throw new Error("No matching authorization found");
}
logger.debug('Generating MCP tokens');
const mcpTokens = generateMcpTokens();
logger.debug('MCP tokens generated', {
hasAccessToken: !!mcpTokens.access_token,
hasRefreshToken: !!mcpTokens.refresh_token
});
const mcpInstallation: McpInstallation = {
fakeUpstreamInstallation: {
@ -294,25 +310,37 @@ export async function handleFakeAuthorizeRedirect(req: Request, res: Response) {
userId: (userId as string) || 'anonymous-user', // Include user ID from auth flow
}
logger.debug('Saving MCP installation');
// Store the upstream authorization data
await saveMcpInstallation(mcpTokens.access_token, mcpInstallation);
logger.debug('MCP installation saved');
// Store the refresh token -> access token mapping
if (mcpTokens.refresh_token) {
logger.debug('Saving refresh token mapping');
await saveRefreshToken(mcpTokens.refresh_token, mcpTokens.access_token);
logger.debug('Refresh token mapping saved');
}
logger.debug('Saving token exchange data');
// Store the token exchange data
await saveTokenExchange(mcpAuthorizationCode, {
mcpAccessToken: mcpTokens.access_token,
alreadyUsed: false,
});
logger.debug('Token exchange data saved');
// Redirect back to the original application with the authorization code and state
const redirectUrl = pendingAuth.state ?
`${pendingAuth.redirectUri}?code=${mcpAuthorizationCode}&state=${pendingAuth.state}` :
`${pendingAuth.redirectUri}?code=${mcpAuthorizationCode}`;
logger.debug('Redirecting to callback', {
redirectUrl,
hasState: !!pendingAuth.state
});
res.redirect(redirectUrl);
logger.debug('Redirect completed');
};
function fakeUpstreamTokenExchange(

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