diff --git a/.env.integrated b/.env.integrated new file mode 100644 index 0000000..aca7862 --- /dev/null +++ b/.env.integrated @@ -0,0 +1,4 @@ +AUTH_MODE=integrated +BASE_URI=http://localhost:3232 +PORT=3232 +REDIS_URL=redis://localhost:6379 \ No newline at end of file diff --git a/.env.separate b/.env.separate new file mode 100644 index 0000000..e0c6565 --- /dev/null +++ b/.env.separate @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index dabd58e..133e221 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Claude config directory +.claude/ diff --git a/README.md b/README.md index e025ec2..c924f96 100644 --- a/README.md +++ b/README.md @@ -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,150 +89,439 @@ 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 +``` + +### Step 3: Configure Environment +```bash +# Use integrated mode (default, simpler setup) +cp .env.integrated .env -# Configure environment (optional) -cp .env.example .env -# Edit .env with your settings +# OR use separate mode (for testing external auth) +cp .env.separate .env ``` -### Configuration -Environment variables (`.env` file): +### Step 4: Start Redis +```bash +# Ensure Docker/OrbStack is running first! +docker compose up -d + +# Verify Redis is running +docker compose ps ``` -PORT=3232 # Server port + +### Step 5: Verify Installation +```bash +# Run the development server +npm run dev + +# Server should start on http://localhost:3232 +``` + +## Configuration + +Environment variables (`.env` file): +```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 +### 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 ``` -├── 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 + +#### Test Separate Mode +```bash +# 1. Start both servers (Redis must already be running) +npm run dev:with-separate-auth + +# 2. Launch MCP Inspector in a new terminal +npx -y @modelcontextprotocol/inspector + +# 3. Connect to: http://localhost:3232/mcp +# 4. Auth flow will redirect to :3001 for authentication ``` -## API Endpoints +### Running Tests +```bash +# Run all tests +npm test -### 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 +# Run specific test suites +npm test -- --testNamePattern="User Session Isolation" +npm test -- --testNamePattern="session ownership" -### 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 +# Run with coverage +npm test -- --coverage +``` -### Headers -- `Mcp-Session-Id`: Session identifier for Streamable HTTP -- `Authorization: Bearer `: OAuth access token -- Standard MCP headers as per protocol specification +### Test Categories +- **Unit Tests**: Individual component testing +- **Integration Tests**: Transport and Redis integration +- **Auth Tests**: OAuth flow and session ownership +- **Multi-user Tests**: User isolation and access control -## Authentication Flow +### Automated End-to-End Testing -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: +The `scripts/` directory contains automated test scripts that verify the complete OAuth flow and all MCP features: -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 +#### Scripts +- **`test-integrated-e2e.sh`** - Tests integrated mode (MCP server as OAuth server) +- **`test-separate-e2e.sh`** - Tests separate mode (external auth server) -See [docs/user-id-system.md](docs/user-id-system.md) for detailed authentication documentation. +#### 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 -## Transport Methods +#### Usage +```bash +# Recommended: Automated testing (handles server lifecycle) +npm run test:e2e:integrated # Tests integrated mode +npm run test:e2e:separate # Tests separate mode -### 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 +# Advanced: Manual script execution (requires manual server setup) +./scripts/test-integrated-e2e.sh +./scripts/test-separate-e2e.sh +``` -See [docs/streamable-http-design.md](docs/streamable-http-design.md) for implementation details. +The npm scripts automatically start required servers, run tests, and clean up. Manual scripts require you to start Redis and servers first. -### 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 +### 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 -## Scalability Architecture +### Authentication Architecture + +#### Integrated Mode +```mermaid +graph TD + Client["MCP Client
(Inspector)"] + MCP["MCP Server
(port 3232)
• OAuth Server
• Resource Server"] + + Client <-->|"OAuth flow & MCP resources"| MCP +``` + +#### Separate Mode +```mermaid +graph TD + Client["MCP Client
(Inspector)"] + MCP["MCP Server
(port 3232)
Resource Server"] + Auth["Auth Server
(port 3001)
OAuth Server"] + + Client <-->|"1. Discover metadata"| MCP + Client <-->|"2. OAuth flow
(register, authorize, token)"| Auth + Client <-->|"3. Use tokens for MCP resources"| MCP + MCP <-->|"Token validation
(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 +#### 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 +#### 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 +#### Redis Key Structure + +##### MCP Session Keys ``` session:{sessionId}:owner # Session ownership mcp:shttp:toserver:{sessionId} # Client→Server messages @@ -194,33 +529,54 @@ mcp:shttp:toclient:{sessionId}:{requestId} # Server→Client responses mcp:control:{sessionId} # Control messages ``` -## Testing +##### 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 +``` -### Running Tests -```bash -# Run all tests -npm test +Note: The `auth:` prefix ensures complete namespace isolation between auth and MCP functions in both integrated and separate modes. -# Run specific test suites -npm test -- --testNamePattern="User Session Isolation" -npm test -- --testNamePattern="session ownership" +### Transport Methods -# Run with coverage -npm test -- --coverage -``` +#### 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 -### Test Categories -- **Unit Tests**: Individual component testing -- **Integration Tests**: Transport and Redis integration -- **Auth Tests**: OAuth flow and session ownership -- **Multi-user Tests**: User isolation and access control +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 -### 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 +### 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 `: 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 diff --git a/auth-server/README.md b/auth-server/README.md new file mode 100644 index 0000000..0323731 --- /dev/null +++ b/auth-server/README.md @@ -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) \ No newline at end of file diff --git a/auth-server/index.ts b/auth-server/index.ts new file mode 100644 index 0000000..8db58a0 --- /dev/null +++ b/auth-server/index.ts @@ -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'); +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4007541 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: '3.8' + +services: + redis: + image: redis:7.2-bookworm + restart: unless-stopped + ports: + - "6379:6379" diff --git a/docs/streamable-http-design.md b/docs/streamable-http-design.md index 2d31081..28a3a9d 100644 --- a/docs/streamable-http-design.md +++ b/docs/streamable-http-design.md @@ -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: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: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: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) diff --git a/docs/user-id-system.md b/docs/user-id-system.md index eb4daff..917b41c 100644 --- a/docs/user-id-system.md +++ b/docs/user-id-system.md @@ -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 | diff --git a/package-lock.json b/package-lock.json index 25a0241..47aeec8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 793ab33..2820ca8 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/test-integrated-e2e.sh b/scripts/test-integrated-e2e.sh new file mode 100755 index 0000000..ceebcc7 --- /dev/null +++ b/scripts/test-integrated-e2e.sh @@ -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" \ No newline at end of file diff --git a/scripts/test-separate-e2e.sh b/scripts/test-separate-e2e.sh new file mode 100755 index 0000000..846baa6 --- /dev/null +++ b/scripts/test-separate-e2e.sh @@ -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" \ No newline at end of file diff --git a/shared/auth-core.ts b/shared/auth-core.ts new file mode 100644 index 0000000..5feb735 --- /dev/null +++ b/shared/auth-core.ts @@ -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", + }; +} \ No newline at end of file diff --git a/shared/redis-auth.ts b/shared/redis-auth.ts new file mode 100644 index 0000000..fc4d636 --- /dev/null +++ b/shared/redis-auth.ts @@ -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( + redisClient: RedisClient, + { + prefix, + key, + data, + options, + }: { + prefix: string; + key: string; + data: T; + options?: SetOptions; + } +): Promise { + 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( + redisClient: RedisClient, + { + prefix, + key, + del = false, + }: { + prefix: string; + key: string; + del?: boolean; + } +): Promise { + 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 { + 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 { + 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 { + 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 { + return readEncrypted(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 { + 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 { + return readEncrypted(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 { + 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 { + return readEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN, + key: refreshToken, + }); +} + +/** + * Revokes an MCP installation. + */ +export async function revokeMcpInstallation( + redisClient: RedisClient, + mcpAccessToken: string +): Promise { + const installation = await readEncrypted(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 { + 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 { + 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; +} \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..d87ce13 --- /dev/null +++ b/shared/types.ts @@ -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; +} \ No newline at end of file diff --git a/src/auth/external-verifier.ts b/src/auth/external-verifier.ts new file mode 100644 index 0000000..5fbb1bb --- /dev/null +++ b/src/auth/external-verifier.ts @@ -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(); + + // 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 { + // 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'); + } + } +} \ No newline at end of file diff --git a/src/auth/provider.ts b/src/auth/provider.ts index 3d77c61..d522f2f 100644 --- a/src/auth/provider.ts +++ b/src/auth/provider.ts @@ -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 diff --git a/src/config.ts b/src/config.ts index c3a29f7..cffc9c9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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})`); + } +} diff --git a/src/handlers/fakeauth.ts b/src/handlers/fakeauth.ts index 49f1f70..0ccc5d3 100644 --- a/src/handlers/fakeauth.ts +++ b/src/handlers/fakeauth.ts @@ -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( diff --git a/src/handlers/shttp.ts b/src/handlers/shttp.ts index 37fee5e..1b89498 100644 --- a/src/handlers/shttp.ts +++ b/src/handlers/shttp.ts @@ -21,6 +21,16 @@ function getUserIdFromAuth(auth?: AuthInfo): string | null { return auth?.extra?.userId as string || null; } +// TODO: Document Streamable HTTP implementation choices: +// 1. STATEFUL: Requires clients to initialize sessions and track session IDs +// - First request must be 'initialize' without Mcp-Session-Id header +// - Server returns session ID, client must include it in subsequent requests +// - Alternative: Could implement STATELESS mode (each request independent) +// 2. SSE RESPONSES: Returns results via Server-Sent Events stream, not JSON responses +// - Requires Accept: application/json, text/event-stream header +// - Responses formatted as: event: message\ndata: {...} +// - Alternative: Could use JSON response mode (check StreamableHTTPServerTransport options) + export async function handleStreamableHTTP(req: Request, res: Response) { let shttpTransport: StreamableHTTPServerTransport | undefined = undefined; @@ -41,13 +51,27 @@ export async function handleStreamableHTTP(req: Request, res: Response) { const sessionId = req.headers['mcp-session-id'] as string | undefined; const userId = getUserIdFromAuth(req.auth); + logger.debug('SHTTP request received', { + method: req.method, + sessionId, + userId, + hasAuth: !!req.auth, + authExtra: req.auth?.extra + }); + // if no userid, return 401, we shouldn't get here ideally if (!userId) { logger.warning('Request without user ID', { sessionId, hasAuth: !!req.auth }); - res.status(401) + res.status(401).json({ + "jsonrpc": "2.0", + "error": { + "code": -32002, + "message": "User ID required" + } + }); return; } @@ -61,7 +85,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { userId, requestMethod: req.method }); - res.status(401) + res.status(401).json({ + "jsonrpc": "2.0", + "error": { + "code": -32001, + "message": "Session not found or access denied" + } + }); return; } // Reuse existing transport for owned session @@ -73,6 +103,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { shttpTransport = await getShttpTransport(sessionId, onsessionclosed, isGetRequest); } else if (isInitializeRequest(req.body)) { // New initialization request - use JSON response mode + logger.debug('Processing initialize request', { + body: req.body, + userId, + headerSessionId: sessionId, // This is the sessionId from header (should be undefined for init) + isInitializeRequest: true + }); + const onsessioninitialized = async (sessionId: string) => { logger.info('Initializing new session', { sessionId, @@ -94,13 +131,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { }); } - const sessionId = randomUUID(); + const newSessionId = randomUUID(); shttpTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => sessionId, + sessionIdGenerator: () => newSessionId, onsessionclosed, onsessioninitialized, }); - shttpTransport.onclose = await redisRelayToMcpServer(sessionId, shttpTransport); + shttpTransport.onclose = await redisRelayToMcpServer(newSessionId, shttpTransport); } else { // Invalid request - no session ID and not initialization request logger.warning('Invalid request: no session ID and not initialization', { @@ -109,7 +146,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { userId, method: req.method }); - res.status(400) + res.status(400).json({ + "jsonrpc": "2.0", + "error": { + "code": -32600, + "message": "Invalid request method for existing session" + } + }); return; } // Handle the request with existing transport - no need to reconnect @@ -122,7 +165,13 @@ export async function handleStreamableHTTP(req: Request, res: Response) { }); if (!res.headersSent) { - res.status(500) + res.status(500).json({ + "jsonrpc": "2.0", + "error": { + "code": -32603, + "message": "Internal error during request processing" + } + }); } } } diff --git a/src/index.ts b/src/index.ts index 3a63374..28166b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ import { BearerAuthMiddlewareOptions, requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; -import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; +import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import cors from "cors"; +import rateLimit from "express-rate-limit"; import express from "express"; import path from "path"; import { fileURLToPath } from "url"; import { EverythingAuthProvider } from "./auth/provider.js"; -import { BASE_URI, PORT } from "./config.js"; +import { ExternalAuthVerifier } from "./auth/external-verifier.js"; +import { BASE_URI, PORT, AUTH_MODE, AUTH_SERVER_URL } from "./config.js"; import { authContext } from "./handlers/common.js"; import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from "./handlers/fakeauth.js"; import { handleStreamableHTTP } from "./handlers/shttp.js"; @@ -121,36 +123,130 @@ app.use(baseSecurityHeaders); // Enable CORS pre-flight requests app.options('*', cors(corsOptions)); +// Rate limiting for custom endpoints +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 authProvider = new EverythingAuthProvider(); -// Auth configuration -const options: AuthRouterOptions = { - provider: new EverythingAuthProvider(), - issuerUrl: new URL(BASE_URI), - tokenOptions: { - rateLimit: { - windowMs: 5 * 1000, - limit: 100, - } - }, - clientRegistrationOptions: { - rateLimit: { - windowMs: 60 * 1000, // 1 minute - limit: 10, // Limit to 10 registrations per minute - }, - }, -}; +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' } +}); -const dearerAuthMiddlewareOptions: BearerAuthMiddlewareOptions = { - // verifyAccessToken(token: string): Promise; - verifier: { - verifyAccessToken: authProvider.verifyAccessToken.bind(authProvider), - }, - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)), -} +// Mode-dependent auth configuration +let bearerAuth: express.RequestHandler; -app.use(mcpAuthRouter(options)); -const bearerAuth = requireBearerAuth(dearerAuthMiddlewareOptions); +if (AUTH_MODE === 'integrated') { + // Integrated mode: MCP server acts as its own OAuth server + logger.info('Starting MCP server in INTEGRATED mode', { + mode: AUTH_MODE, + baseUri: BASE_URI, + port: PORT + }); + + const authProvider = new EverythingAuthProvider(); + + const authRouterOptions: AuthRouterOptions = { + provider: authProvider, + issuerUrl: new URL(BASE_URI), + tokenOptions: { + rateLimit: { + windowMs: 5 * 1000, + limit: 100, + } + }, + clientRegistrationOptions: { + rateLimit: { + windowMs: 60 * 1000, // 1 minute + limit: 10, // Limit to 10 registrations per minute + }, + }, + }; + + // Serve OAuth endpoints + app.use(mcpAuthRouter(authRouterOptions)); + + // Configure bearer auth middleware + const bearerAuthOptions: BearerAuthMiddlewareOptions = { + verifier: { + verifyAccessToken: authProvider.verifyAccessToken.bind(authProvider), + }, + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)), + }; + + bearerAuth = requireBearerAuth(bearerAuthOptions); + +} else { + // Separate mode: MCP server uses external auth server + logger.info('Starting MCP server in SEPARATE mode', { + mode: AUTH_MODE, + baseUri: BASE_URI, + port: PORT, + authServerUrl: AUTH_SERVER_URL + }); + + // Fetch metadata from external auth server with retry logic + let authMetadata; + const maxRetries = 5; + const retryDelay = 3000; // 3 seconds + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + logger.info(`Attempting to connect to auth server (attempt ${attempt}/${maxRetries})`, { + authServerUrl: AUTH_SERVER_URL + }); + + const authMetadataResponse = await fetch(`${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`); + if (!authMetadataResponse.ok) { + throw new Error(`Failed to fetch auth server metadata: ${authMetadataResponse.status} ${authMetadataResponse.statusText}`); + } + authMetadata = await authMetadataResponse.json(); + logger.info('Successfully fetched auth server metadata', { + issuer: authMetadata.issuer, + authorizationEndpoint: authMetadata.authorization_endpoint, + tokenEndpoint: authMetadata.token_endpoint + }); + break; // Success, exit retry loop + + } catch (error) { + if (attempt < maxRetries) { + logger.info(`Failed to connect to auth server, retrying in ${retryDelay/1000} seconds...`, { + attempt, + maxRetries, + error: (error as Error).message + }); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } else { + logger.error('Failed to fetch auth server metadata after all retries', error as Error); + logger.error('Make sure the auth server is running at', undefined, { authServerUrl: AUTH_SERVER_URL }); + process.exit(1); + } + } + } + + // BACKWARDS COMPATIBILITY: We serve OAuth metadata from the MCP server even in separate mode + // This is technically redundant since the auth server handles all OAuth operations, + // but some clients may expect to find .well-known/oauth-authorization-server on the + // resource server itself. The metadata points to the external auth server endpoints. + app.use(mcpAuthMetadataRouter({ + oauthMetadata: authMetadata, + resourceServerUrl: new URL(BASE_URI), + resourceName: "MCP Everything Server" + })); + + // Configure bearer auth with external verifier + const externalVerifier = new ExternalAuthVerifier(AUTH_SERVER_URL); + + const bearerAuthOptions: BearerAuthMiddlewareOptions = { + verifier: externalVerifier, + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)), + }; + + bearerAuth = requireBearerAuth(bearerAuthOptions); +} // MCP routes (legacy SSE transport) app.get("/sse", cors(corsOptions), bearerAuth, authContext, sseHeaders, handleSSEConnection); @@ -162,12 +258,12 @@ app.post("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTT app.delete("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP); // Static assets -app.get("/mcp-logo.png", (req, res) => { +app.get("/mcp-logo.png", staticFileRateLimit, (req, res) => { const logoPath = path.join(__dirname, "static", "mcp.png"); res.sendFile(logoPath); }); -app.get("/styles.css", (req, res) => { +app.get("/styles.css", staticFileRateLimit, (req, res) => { const cssPath = path.join(__dirname, "static", "styles.css"); res.setHeader('Content-Type', 'text/css'); res.sendFile(cssPath); @@ -179,9 +275,11 @@ app.get("/", (req, res) => { res.sendFile(splashPath); }); -// Upstream auth routes -app.get("/fakeupstreamauth/authorize", cors(corsOptions), handleFakeAuthorize); -app.get("/fakeupstreamauth/callback", cors(corsOptions), handleFakeAuthorizeRedirect); +// Upstream auth routes (only in integrated mode) +if (AUTH_MODE === 'integrated') { + app.get("/fakeupstreamauth/authorize", fakeAuthRateLimit, cors(corsOptions), handleFakeAuthorize); + app.get("/fakeupstreamauth/callback", fakeAuthRateLimit, cors(corsOptions), handleFakeAuthorizeRedirect); +} try { await redisClient.connect(); diff --git a/src/redis.ts b/src/redis.ts index 1826bff..3fc1a47 100644 --- a/src/redis.ts +++ b/src/redis.ts @@ -1,5 +1,6 @@ import { createClient, SetOptions } from "@redis/client"; import { logger } from "./utils/logger.js"; +import { REDIS_URL } from "./config.js"; /** * Describes the Redis primitives we use in this application, to be able to mock @@ -37,7 +38,7 @@ export interface RedisClient { export class RedisClientImpl implements RedisClient { private redis = createClient({ - url: process.env.REDIS_URL || "redis://localhost:6379", + url: REDIS_URL, password: process.env.REDIS_PASSWORD, socket: { tls: process.env.REDIS_TLS === "1", @@ -97,7 +98,7 @@ export class RedisClientImpl implements RedisClient { } get options() { - return { url: process.env.REDIS_URL || "redis://localhost:6379" }; + return { url: REDIS_URL }; } async createSubscription( diff --git a/src/services/auth.test.ts b/src/services/auth.test.ts index 6a761bd..4964031 100644 --- a/src/services/auth.test.ts +++ b/src/services/auth.test.ts @@ -124,8 +124,8 @@ describe("auth utils", () => { // instead of using exchangeToken which changes the value await saveTokenExchange(authCode, tokenExchange); - // Get the key used by saveTokenExchange - const key = "exch:" + crypto.createHash("sha256").update(authCode).digest("hex"); + // Get the key used by saveTokenExchange (now with auth: prefix) + const key = "auth:exch:" + crypto.createHash("sha256").update(authCode).digest("hex"); // Get the encrypted data const encryptedData = await mockRedis.get(key); @@ -266,8 +266,8 @@ describe("auth utils", () => { await revokeMcpInstallation(accessToken); - // Should have called getDel with the correct key - expect(getDel).toHaveBeenCalledWith(expect.stringContaining("mcp:")); + // Should have called getDel with the correct key (now auth:installation:) + expect(getDel).toHaveBeenCalledWith(expect.stringContaining("auth:installation:")); }); diff --git a/src/services/auth.ts b/src/services/auth.ts index 02dae0f..2067de0 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,271 +1,86 @@ -import { SetOptions } from "@redis/client"; -import crypto from "crypto"; import { redisClient } from "../redis.js"; import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js"; -import { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { logger } from "../utils/logger.js"; +import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js"; -export function generatePKCEChallenge(verifier: string): string { - const buffer = Buffer.from(verifier); - const hash = crypto.createHash("sha256").update(buffer); - return hash.digest("base64url"); -} - -export function generateToken(): string { - return crypto.randomBytes(32).toString("hex"); -} - -function sha256(data: string): string { - return crypto.createHash("sha256").update(data).digest("hex"); -} - -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}`; -} +// Re-export from shared modules for backward compatibility +export { + generatePKCEChallenge, + generateToken, + decryptString, + generateMcpTokens +} from "../../shared/auth-core.js"; -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; -} - -const CLIENT_REGISTRATION_KEY_PREFIX = "client:"; -const PENDING_AUTHORIZATION_KEY_PREFIX = "pending:"; -const MCP_AUTHORIZATION_KEY_PREFIX = "mcp:"; -const TOKEN_EXCHANGE_KEY_PREFIX = "exch:"; -const REFRESH_TOKEN_KEY_PREFIX = "refresh:"; - -// Timeouts of redis keys for different stages of the OAuth flow -const REDIS_PENDING_AUTHORIZATION_EXPIRY_SEC = 10 * 60; // 10 minutes in seconds - authorization code -> PendingAuthorization -const REDIS_TOKEN_EXCHANGE_EXPIRY_SEC = 10 * 60; // 10 minutes in seconds - authorization code -> MCP access token -const REDIS_UPSTREAM_INSTALLATION_EXPIRY_SEC = 7 * 24 * 60 * 60; // 7 days in seconds - MCP access token -> UpstreamInstallation -const REDIS_REFRESH_TOKEN_EXPIRY_SEC = 7 * 24 * 60 * 60; // 7 days in seconds - MCP refresh token -> access token - -// Access token expiry -const ACCESS_TOKEN_EXPIRY_SEC = 60 * 60 // 1 hour in seconds - -async function saveEncrypted({ - prefix, - key, - data, - options, -}: { - prefix: string; - key: string; - data: T; - options?: SetOptions; -}) { - const value = encryptString({ - text: JSON.stringify(data), - key: key, - }); - - return await redisClient.set(prefix + sha256(key), value, options); -} +import * as sharedRedisAuth from "../../shared/redis-auth.js"; -async function readEncrypted({ - prefix, - key, - del = false, -}: { - prefix: string; - key: string; - del?: boolean; -}): Promise { - 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); -} - -export function generateMcpTokens(): OAuthTokens { - // Generate MCP access token and store both tokens - const mcpAccessToken = generateToken(); - const mcpRefreshToken = generateToken(); - - return { - access_token: mcpAccessToken, - refresh_token: mcpRefreshToken, - expires_in: ACCESS_TOKEN_EXPIRY_SEC, - token_type: "Bearer", - } -} +// Wrapper functions that pass redisClient to shared module functions export async function saveClientRegistration( clientId: string, registration: OAuthClientInformationFull, ) { - await redisClient.set( - CLIENT_REGISTRATION_KEY_PREFIX + clientId, - JSON.stringify(registration), - ); + return sharedRedisAuth.saveClientRegistration(redisClient, clientId, registration); } export async function getClientRegistration( clientId: string, ): Promise { - const data = await redisClient.get(CLIENT_REGISTRATION_KEY_PREFIX + clientId); - if (!data) { - return undefined; - } - return JSON.parse(data); + return sharedRedisAuth.getClientRegistration(redisClient, clientId); } export async function savePendingAuthorization( authorizationCode: string, pendingAuthorization: PendingAuthorization, ) { - await saveEncrypted({ - prefix: PENDING_AUTHORIZATION_KEY_PREFIX, - key: authorizationCode, - data: pendingAuthorization, - options: { EX: REDIS_PENDING_AUTHORIZATION_EXPIRY_SEC }, - }); + return sharedRedisAuth.savePendingAuthorization(redisClient, authorizationCode, pendingAuthorization); } export async function readPendingAuthorization( authorizationCode: string, ): Promise { - return readEncrypted({ - prefix: PENDING_AUTHORIZATION_KEY_PREFIX, - key: authorizationCode, - }); + return sharedRedisAuth.readPendingAuthorization(redisClient, authorizationCode); } export async function saveMcpInstallation( mcpAccessToken: string, installation: McpInstallation, ) { - await saveEncrypted({ - prefix: MCP_AUTHORIZATION_KEY_PREFIX, - key: mcpAccessToken, - data: installation, - options: { EX: REDIS_UPSTREAM_INSTALLATION_EXPIRY_SEC }, - }); + return sharedRedisAuth.saveMcpInstallation(redisClient, mcpAccessToken, installation); } export async function readMcpInstallation( mcpAccessToken: string, ): Promise { - return readEncrypted({ - prefix: MCP_AUTHORIZATION_KEY_PREFIX, - key: mcpAccessToken, - }); + return sharedRedisAuth.readMcpInstallation(redisClient, mcpAccessToken); } -// This just links the refresh token to the upstream installation + mcp access token export async function saveRefreshToken( refreshToken: string, mcpAccessToken: string, ) { - saveEncrypted({ - prefix: REFRESH_TOKEN_KEY_PREFIX, - key: refreshToken, - data: mcpAccessToken, - options: { EX: REDIS_REFRESH_TOKEN_EXPIRY_SEC }, - }) + return sharedRedisAuth.saveRefreshToken(redisClient, refreshToken, mcpAccessToken); } export async function readRefreshToken( refreshToken: string, ): Promise { - return readEncrypted({ - prefix: REFRESH_TOKEN_KEY_PREFIX, - key: refreshToken, - }); + return sharedRedisAuth.readRefreshToken(redisClient, refreshToken); } export async function revokeMcpInstallation( mcpAccessToken: string, ): Promise { - const installation = await readEncrypted({ - prefix: MCP_AUTHORIZATION_KEY_PREFIX, - key: mcpAccessToken, - del: true, - }); - - if (!installation) { - return; - } - // Revoke upstream tokens here + return sharedRedisAuth.revokeMcpInstallation(redisClient, mcpAccessToken); } export async function saveTokenExchange( authorizationCode: string, tokenExchange: TokenExchange, ) { - await saveEncrypted({ - prefix: TOKEN_EXCHANGE_KEY_PREFIX, - key: authorizationCode, - data: tokenExchange, - options: { EX: REDIS_TOKEN_EXCHANGE_EXPIRY_SEC }, - }); + return sharedRedisAuth.saveTokenExchange(redisClient, authorizationCode, tokenExchange); } -/** - * Exchanges a temporary authorization code for an MCP access token. Will only succeed the first time. - */ export async function exchangeToken( authorizationCode: string, ): Promise { - const data = await redisClient.get(TOKEN_EXCHANGE_KEY_PREFIX + 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(tokenExchange.mcpAccessToken); - throw new Error("Duplicate use of authorization code detected; tokens revoked"); - } - - const rereadData = await saveEncrypted({ - prefix: TOKEN_EXCHANGE_KEY_PREFIX, - 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(tokenExchange.mcpAccessToken); - throw new Error("Duplicate use of authorization code detected; tokens revoked"); - } - - return tokenExchange; + return sharedRedisAuth.exchangeToken(redisClient, authorizationCode); } diff --git a/tsconfig.json b/tsconfig.json index fee103a..4c27ee1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "isolatedModules": true, "skipLibCheck": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "shared/**/*", "auth-server/**/*"], "exclude": ["node_modules", "dist"] }