Merge pull request #9 from modelcontextprotocol/basil/separate_auth
Add Support for Separate Authorization Server Mode
This commit is contained in:
commit
22b6268380
26 changed files with 2618 additions and 579 deletions
4
.env.integrated
Normal file
4
.env.integrated
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
AUTH_MODE=integrated
|
||||
BASE_URI=http://localhost:3232
|
||||
PORT=3232
|
||||
REDIS_URL=redis://localhost:6379
|
||||
6
.env.separate
Normal file
6
.env.separate
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
AUTH_MODE=separate
|
||||
BASE_URI=http://localhost:3232
|
||||
PORT=3232
|
||||
REDIS_URL=redis://localhost:6379
|
||||
AUTH_SERVER_URL=http://localhost:3001
|
||||
AUTH_SERVER_PORT=3001
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -129,3 +129,6 @@ dist
|
|||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Claude config directory
|
||||
.claude/
|
||||
|
|
|
|||
607
README.md
607
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,158 +89,219 @@ This server serves as both primarily as a learning resource, and an example impl
|
|||
|
||||
### Prerequisites
|
||||
- Node.js >= 16
|
||||
- Redis server
|
||||
- npm or yarn
|
||||
- Docker runtime (for Redis)
|
||||
|
||||
### Setup
|
||||
### Step 1: Install Docker Runtime
|
||||
Choose one option:
|
||||
|
||||
**macOS (Recommended: OrbStack)**
|
||||
```bash
|
||||
brew install orbstack
|
||||
# Start OrbStack daemon (required before using Docker commands)
|
||||
orbctl start
|
||||
# Or download from https://orbstack.dev/download
|
||||
```
|
||||
|
||||
**Windows/Linux: Docker Desktop**
|
||||
- Download from https://www.docker.com/products/docker-desktop
|
||||
- Start Docker Desktop after installation
|
||||
|
||||
**Alternative: Local Redis Installation**
|
||||
```bash
|
||||
# macOS
|
||||
brew install redis && brew services start redis
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install redis-server && sudo systemctl start redis
|
||||
```
|
||||
|
||||
### Step 2: Clone and Install Dependencies
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/modelcontextprotocol/example-remote-server.git
|
||||
cd example-remote-server
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Configure environment (optional)
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
### Configuration
|
||||
### Step 3: Configure Environment
|
||||
```bash
|
||||
# Use integrated mode (default, simpler setup)
|
||||
cp .env.integrated .env
|
||||
|
||||
# OR use separate mode (for testing external auth)
|
||||
cp .env.separate .env
|
||||
```
|
||||
|
||||
### Step 4: Start Redis
|
||||
```bash
|
||||
# Ensure Docker/OrbStack is running first!
|
||||
docker compose up -d
|
||||
|
||||
# Verify Redis is running
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Step 5: Verify Installation
|
||||
```bash
|
||||
# Run the development server
|
||||
npm run dev
|
||||
|
||||
# Server should start on http://localhost:3232
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (`.env` file):
|
||||
```
|
||||
PORT=3232 # Server port
|
||||
```bash
|
||||
# Server Configuration
|
||||
PORT=3232 # MCP server port
|
||||
BASE_URI=http://localhost:3232 # Base URI for OAuth redirects
|
||||
REDIS_HOST=localhost # Redis server host
|
||||
REDIS_PORT=6379 # Redis server port
|
||||
REDIS_PASSWORD= # Redis password (if required)
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://localhost:6379 # Redis connection URL
|
||||
|
||||
# Authentication Mode (integrated | separate)
|
||||
AUTH_MODE=integrated # Default: integrated mode
|
||||
|
||||
# Separate Mode Configuration (only used when AUTH_MODE=separate)
|
||||
AUTH_SERVER_URL=http://localhost:3001 # External auth server URL
|
||||
AUTH_SERVER_PORT=3001 # Auth server port (for standalone server)
|
||||
```
|
||||
|
||||
**Pre-configured environment files:**
|
||||
- `.env.integrated` - Configuration for integrated mode
|
||||
- `.env.separate` - Configuration for separate mode
|
||||
|
||||
```bash
|
||||
# Use integrated mode
|
||||
cp .env.integrated .env
|
||||
|
||||
# Use separate mode
|
||||
cp .env.separate .env
|
||||
```
|
||||
|
||||
## Authentication Modes
|
||||
|
||||
The Everything Server supports two authentication modes to demonstrate different MCP deployment patterns:
|
||||
|
||||
### Integrated Mode (Default)
|
||||
The MCP server acts as its own OAuth 2.0 authorization server. This is simpler to deploy and suitable for standalone MCP servers.
|
||||
|
||||
```bash
|
||||
npm run dev:integrated
|
||||
```
|
||||
|
||||
### Separate Mode
|
||||
The MCP server delegates authentication to a standalone authorization server. This demonstrates how MCP servers can integrate with existing OAuth infrastructure. See [auth-server/README.md](auth-server/README.md) for more details about the standalone auth server.
|
||||
|
||||
```bash
|
||||
# Start both the auth server and MCP server
|
||||
npm run dev:with-separate-auth
|
||||
|
||||
# Or run them separately:
|
||||
# Terminal 1: Start the authorization server
|
||||
npm run dev:auth-server
|
||||
|
||||
# Terminal 2: Start the MCP server in separate mode
|
||||
npm run dev:separate
|
||||
```
|
||||
|
||||
In production, the separate authorization server would typically be replaced with:
|
||||
- Corporate SSO (Auth0, Okta)
|
||||
- Cloud providers (AWS Cognito, Azure AD)
|
||||
- Social providers (Google, GitHub)
|
||||
|
||||
## Development
|
||||
|
||||
### Commands
|
||||
### Quick Start
|
||||
If you've completed installation, you're ready to develop:
|
||||
|
||||
```bash
|
||||
# Integrated mode (MCP server handles auth)
|
||||
npm run dev:integrated
|
||||
|
||||
# Separate mode (external auth server)
|
||||
npm run dev:with-separate-auth
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
```bash
|
||||
# Start development server with hot reload
|
||||
npm run dev
|
||||
|
||||
# Start in integrated mode (MCP server as OAuth server)
|
||||
npm run dev:integrated
|
||||
|
||||
# Start in separate mode (external auth server)
|
||||
npm run dev:separate
|
||||
|
||||
# Start standalone authorization server
|
||||
npm run dev:auth-server
|
||||
|
||||
# Start both auth server and MCP server in separate mode
|
||||
npm run dev:with-separate-auth
|
||||
|
||||
# Start development server with debugging
|
||||
npm run dev:break
|
||||
```
|
||||
|
||||
# Build TypeScript to JavaScript
|
||||
#### Build & Production
|
||||
```bash
|
||||
# Build TypeScript to JavaScript (builds both servers)
|
||||
npm run build
|
||||
|
||||
# Run production server
|
||||
npm start
|
||||
|
||||
# Run production auth server
|
||||
npm run start:auth-server
|
||||
```
|
||||
|
||||
#### Testing & Quality
|
||||
```bash
|
||||
# Run linting
|
||||
npm run lint
|
||||
|
||||
# Run tests
|
||||
# Run unit tests
|
||||
npm test
|
||||
|
||||
# Run end-to-end tests (automated server management)
|
||||
npm run test:e2e:integrated # Test integrated mode OAuth + features
|
||||
npm run test:e2e:separate # Test separate mode OAuth + features
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
├── src/
|
||||
│ ├── index.ts # Express app setup and routes
|
||||
│ ├── config.ts # Configuration management
|
||||
│ ├── redis.ts # Redis client setup
|
||||
│ ├── auth/
|
||||
│ │ └── provider.ts # OAuth auth provider implementation
|
||||
│ ├── handlers/
|
||||
│ │ ├── shttp.ts # Streamable HTTP handler
|
||||
│ │ ├── sse.ts # SSE transport handler
|
||||
│ │ ├── fakeauth.ts # Fake upstream auth handler
|
||||
│ │ └── common.ts # Shared middleware
|
||||
│ ├── services/
|
||||
│ │ ├── mcp.ts # MCP server implementation
|
||||
│ │ ├── auth.ts # Session ownership management
|
||||
│ │ └── redisTransport.ts # Redis-backed transport
|
||||
│ └── utils/
|
||||
│ └── logger.ts # Structured logging
|
||||
├── docs/
|
||||
│ ├── streamable-http-design.md # SHTTP implementation details
|
||||
│ └── user-id-system.md # Authentication flow documentation
|
||||
├── scratch/ # Development scripts and tests
|
||||
└── dist/ # Compiled JavaScript output
|
||||
### Testing with MCP Inspector
|
||||
|
||||
The MCP Inspector is a web-based tool for testing MCP servers.
|
||||
|
||||
#### Prerequisites
|
||||
1. Ensure Docker/OrbStack is running
|
||||
2. Ensure Redis is running: `docker compose ps`
|
||||
3. Ensure environment is configured: Check `.env` file exists
|
||||
|
||||
#### Test Integrated Mode
|
||||
```bash
|
||||
# 1. Start the server (Redis must already be running)
|
||||
npm run dev:integrated
|
||||
|
||||
# 2. Launch MCP Inspector in a new terminal
|
||||
npx -y @modelcontextprotocol/inspector
|
||||
|
||||
# 3. Connect to: http://localhost:3232/mcp
|
||||
# 4. Navigate to Auth tab and complete OAuth flow
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
#### Test Separate Mode
|
||||
```bash
|
||||
# 1. Start both servers (Redis must already be running)
|
||||
npm run dev:with-separate-auth
|
||||
|
||||
### MCP Endpoints
|
||||
- `GET/POST/DELETE /mcp` - Streamable HTTP transport endpoint
|
||||
- `POST`: Initialize sessions or send messages
|
||||
- `GET`: Establish SSE streams
|
||||
- `DELETE`: Terminate sessions
|
||||
- `GET /sse` - Legacy SSE transport endpoint
|
||||
- `POST /message` - Legacy message endpoint for SSE transport
|
||||
# 2. Launch MCP Inspector in a new terminal
|
||||
npx -y @modelcontextprotocol/inspector
|
||||
|
||||
### Authentication Endpoints
|
||||
- `GET /fakeupstreamauth/authorize` - Fake OAuth authorization page
|
||||
- `GET /fakeupstreamauth/redirect` - OAuth redirect handler
|
||||
- OAuth 2.0 endpoints provided by MCP SDK auth router
|
||||
|
||||
### Headers
|
||||
- `Mcp-Session-Id`: Session identifier for Streamable HTTP
|
||||
- `Authorization: Bearer <token>`: OAuth access token
|
||||
- Standard MCP headers as per protocol specification
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
The server implements a complete [OAuth 2.0](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) flow with a fake upstream provider for testing:
|
||||
|
||||
1. **Client Registration**: Clients register to obtain client_id and client_secret
|
||||
2. **Authorization**: Users authenticate through `/fakeupstreamauth/authorize`
|
||||
3. **User Management**: localStorage-based user ID system for testing
|
||||
4. **Token Exchange**: Authorization codes exchanged for access tokens
|
||||
5. **Session Creation**: Authenticated tokens create owned sessions
|
||||
6. **Access Control**: Sessions are isolated by user ownership
|
||||
|
||||
See [docs/user-id-system.md](docs/user-id-system.md) for detailed authentication documentation.
|
||||
|
||||
## Transport Methods
|
||||
|
||||
### Streamable HTTP (Recommended)
|
||||
Modern [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) supporting bidirectional communication over HTTP:
|
||||
- Single endpoint for all operations
|
||||
- Session management via headers
|
||||
- Efficient message buffering
|
||||
- Automatic reconnection support
|
||||
|
||||
See [docs/streamable-http-design.md](docs/streamable-http-design.md) for implementation details.
|
||||
|
||||
### Server-Sent Events (Legacy)
|
||||
Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#server-sent-events) using SSE:
|
||||
- Separate endpoints for SSE streams and messages
|
||||
- Session management via URL parameters
|
||||
- Redis-backed message routing
|
||||
- Real-time event delivery
|
||||
|
||||
## Scalability Architecture
|
||||
|
||||
The server is designed for horizontal scaling using Redis as the backbone:
|
||||
|
||||
### Session State Management
|
||||
- **Redis Storage**: All session state stored in Redis
|
||||
- **5-minute TTL**: Automatic session cleanup
|
||||
- **Session Ownership**: User isolation via Redis keys
|
||||
- **Stateless Servers**: Any instance can handle any request
|
||||
|
||||
### Message Routing
|
||||
- **Pub/Sub Channels**: Redis channels for message distribution
|
||||
- **Message Buffering**: Reliable delivery for disconnected clients
|
||||
- **Connection State**: Tracked via pub/sub subscription counts
|
||||
- **Automatic Cleanup**: No explicit cleanup required
|
||||
|
||||
### Redis Key Structure
|
||||
# 3. Connect to: http://localhost:3232/mcp
|
||||
# 4. Auth flow will redirect to :3001 for authentication
|
||||
```
|
||||
session:{sessionId}:owner # Session ownership
|
||||
mcp:shttp:toserver:{sessionId} # Client→Server messages
|
||||
mcp:shttp:toclient:{sessionId}:{requestId} # Server→Client responses
|
||||
mcp:control:{sessionId} # Control messages
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
|
|
@ -215,12 +322,261 @@ npm test -- --coverage
|
|||
- **Auth Tests**: OAuth flow and session ownership
|
||||
- **Multi-user Tests**: User isolation and access control
|
||||
|
||||
### Manual Testing
|
||||
The `scratch/` directory contains utility scripts for testing:
|
||||
- `oauth.sh` - Test OAuth flows
|
||||
- `simple-test-client.js` - Basic client implementation
|
||||
- `test-shttp-client.ts` - Streamable HTTP testing
|
||||
- `debug-mcp-flow.sh` - Debug MCP message flows
|
||||
### Automated End-to-End Testing
|
||||
|
||||
The `scripts/` directory contains automated test scripts that verify the complete OAuth flow and all MCP features:
|
||||
|
||||
#### Scripts
|
||||
- **`test-integrated-e2e.sh`** - Tests integrated mode (MCP server as OAuth server)
|
||||
- **`test-separate-e2e.sh`** - Tests separate mode (external auth server)
|
||||
|
||||
#### What the scripts test:
|
||||
- Complete OAuth 2.0 + PKCE flow from client registration to token usage
|
||||
- All MCP features: tools (7), resources (100 with pagination), prompts (3)
|
||||
- Session management and proper error handling
|
||||
- README claim verification
|
||||
|
||||
#### Usage
|
||||
```bash
|
||||
# Recommended: Automated testing (handles server lifecycle)
|
||||
npm run test:e2e:integrated # Tests integrated mode
|
||||
npm run test:e2e:separate # Tests separate mode
|
||||
|
||||
# Advanced: Manual script execution (requires manual server setup)
|
||||
./scripts/test-integrated-e2e.sh
|
||||
./scripts/test-separate-e2e.sh
|
||||
```
|
||||
|
||||
The npm scripts automatically start required servers, run tests, and clean up. Manual scripts require you to start Redis and servers first.
|
||||
|
||||
### Interactive Testing
|
||||
Use the MCP Inspector for interactive testing and debugging of OAuth flows, tool execution, and resource access.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**"Cannot connect to Docker daemon"**
|
||||
- Ensure Docker Desktop or OrbStack daemon is running
|
||||
- macOS with OrbStack: `orbctl start` (verify with `orbctl status`)
|
||||
- Windows/Linux/macOS with Docker Desktop: Start Docker Desktop application
|
||||
|
||||
**"Redis connection refused"**
|
||||
- Check Redis is running: `docker compose ps`
|
||||
- If not running: `docker compose up -d`
|
||||
- Ensure Docker/OrbStack is started first
|
||||
|
||||
**"Missing .env file"**
|
||||
- Run `cp .env.integrated .env` for default setup
|
||||
- Or `cp .env.separate .env` for separate auth mode
|
||||
|
||||
**"Port already in use"**
|
||||
- Check for existing processes: `lsof -i :3232` or `lsof -i :3001`
|
||||
- Kill existing processes or change PORT in .env
|
||||
|
||||
**"npm install fails"**
|
||||
- Ensure Node.js >= 16 is installed: `node --version`
|
||||
- Clear npm cache: `npm cache clean --force`
|
||||
- Delete node_modules and package-lock.json, then retry
|
||||
|
||||
**"Authentication flow fails"**
|
||||
- Check the server logs for error messages
|
||||
- Ensure Redis is running and accessible
|
||||
- Verify .env configuration matches your setup mode
|
||||
|
||||
## Architecture & Technical Details
|
||||
|
||||
### Authentication Architecture
|
||||
|
||||
#### Integrated Mode
|
||||
```mermaid
|
||||
graph TD
|
||||
Client["MCP Client<br/>(Inspector)"]
|
||||
MCP["MCP Server<br/>(port 3232)<br/>• OAuth Server<br/>• Resource Server"]
|
||||
|
||||
Client <-->|"OAuth flow & MCP resources"| MCP
|
||||
```
|
||||
|
||||
#### Separate Mode
|
||||
```mermaid
|
||||
graph TD
|
||||
Client["MCP Client<br/>(Inspector)"]
|
||||
MCP["MCP Server<br/>(port 3232)<br/>Resource Server"]
|
||||
Auth["Auth Server<br/>(port 3001)<br/>OAuth Server"]
|
||||
|
||||
Client <-->|"1. Discover metadata"| MCP
|
||||
Client <-->|"2. OAuth flow<br/>(register, authorize, token)"| Auth
|
||||
Client <-->|"3. Use tokens for MCP resources"| MCP
|
||||
MCP <-->|"Token validation<br/>(introspect)"| Auth
|
||||
```
|
||||
|
||||
### OAuth 2.0 + PKCE Flow Analysis
|
||||
|
||||
The server implements a complete OAuth 2.0 authorization code flow with PKCE. Here's how each step maps to data storage and expiry:
|
||||
|
||||
**1. Client Registration** (app setup - happens once)
|
||||
```
|
||||
App → Auth Server: "I want to use OAuth, here's my info"
|
||||
Auth Server → App: "OK, your client_id is XYZ, client_secret is ABC"
|
||||
```
|
||||
- **Storage**: Client credentials for future OAuth flows
|
||||
- **Expiry**: 30 days (long-lived app credentials)
|
||||
|
||||
**2. Authorization Request** (starts each OAuth flow)
|
||||
```
|
||||
User → App: "I want to connect to MCP server"
|
||||
App → Auth Server: "User wants access, here's my PKCE challenge"
|
||||
Auth Server: Stores pending authorization, shows auth page
|
||||
```
|
||||
- **Storage**: `PENDING_AUTHORIZATION` - temporary state during flow
|
||||
- **Expiry**: 10 minutes (short-lived temporary state)
|
||||
|
||||
**3. Authorization Code Exchange** (completes OAuth flow)
|
||||
```
|
||||
User → Auth Server: "I approve this app"
|
||||
Auth Server → App: "Here's your authorization code"
|
||||
App → Auth Server: "Exchange code + PKCE verifier for tokens"
|
||||
Auth Server → App: "Here are your access/refresh tokens"
|
||||
```
|
||||
- **Storage**: `TOKEN_EXCHANGE` - prevents replay attacks
|
||||
- **Expiry**: 10 minutes (single-use, consumed immediately)
|
||||
|
||||
**4. Token Storage** (long-term user session)
|
||||
```
|
||||
Auth Server: Issues access_token + refresh_token
|
||||
Server: Stores user installation with tokens
|
||||
```
|
||||
- **Storage**: `UPSTREAM_INSTALLATION` - the actual user session
|
||||
- **Expiry**: 7 days (balances security vs usability)
|
||||
|
||||
**5. Token Refresh** (extends user session)
|
||||
```
|
||||
App → Auth Server: "My access token expired, here's my refresh token"
|
||||
Auth Server → App: "Here's a new access token"
|
||||
```
|
||||
- **Storage**: `REFRESH_TOKEN` - mapping for token rotation
|
||||
- **Expiry**: 7 days (matches installation lifetime)
|
||||
|
||||
#### Data Lifecycle Hierarchy
|
||||
|
||||
**Timeline (shortest to longest expiry):**
|
||||
1. **OAuth flow state** (10 minutes) - very temporary
|
||||
2. **User sessions** (7 days) - medium-term
|
||||
3. **Client credentials** (30 days) - long-term
|
||||
|
||||
This creates a logical hierarchy where each layer outlives the layers it supports.
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
├── src/ # MCP server code
|
||||
│ ├── index.ts # Express app setup and routes
|
||||
│ ├── config.ts # Configuration management
|
||||
│ ├── redis.ts # Redis client setup
|
||||
│ ├── auth/
|
||||
│ │ ├── provider.ts # OAuth auth provider implementation
|
||||
│ │ └── external-verifier.ts # External token verification
|
||||
│ ├── handlers/
|
||||
│ │ ├── shttp.ts # Streamable HTTP handler
|
||||
│ │ ├── sse.ts # SSE transport handler
|
||||
│ │ ├── fakeauth.ts # Fake upstream auth handler
|
||||
│ │ └── common.ts # Shared middleware
|
||||
│ ├── services/
|
||||
│ │ ├── mcp.ts # MCP server implementation
|
||||
│ │ ├── auth.ts # Auth service wrappers
|
||||
│ │ └── redisTransport.ts # Redis-backed transport
|
||||
│ └── utils/
|
||||
│ └── logger.ts # Structured logging
|
||||
├── auth-server/ # Standalone authorization server
|
||||
│ ├── index.ts # Auth server main entry point
|
||||
│ ├── README.md # Auth server documentation
|
||||
│ └── tsconfig.json # TypeScript configuration
|
||||
├── shared/ # Shared between both servers
|
||||
│ ├── auth-core.ts # Core auth logic
|
||||
│ ├── redis-auth.ts # Redis auth operations
|
||||
│ └── types.ts # Shared type definitions
|
||||
├── scripts/ # End-to-end testing scripts
|
||||
│ ├── test-integrated-e2e.sh # OAuth + feature verification (integrated)
|
||||
│ └── test-separate-e2e.sh # OAuth + feature verification (separate)
|
||||
├── docs/
|
||||
│ ├── streamable-http-design.md # SHTTP implementation details
|
||||
│ └── user-id-system.md # Authentication flow documentation
|
||||
└── dist/ # Compiled JavaScript output
|
||||
```
|
||||
|
||||
### Scalability Architecture
|
||||
|
||||
The server is designed for horizontal scaling using Redis as the backbone:
|
||||
|
||||
#### Session State Management
|
||||
- **Redis Storage**: All session state stored in Redis
|
||||
- **5-minute TTL**: Automatic session cleanup
|
||||
- **Session Ownership**: User isolation via Redis keys
|
||||
- **Stateless Servers**: Any instance can handle any request
|
||||
|
||||
#### Message Routing
|
||||
- **Pub/Sub Channels**: Redis channels for message distribution
|
||||
- **Message Buffering**: Reliable delivery for disconnected clients
|
||||
- **Connection State**: Tracked via pub/sub subscription counts
|
||||
- **Automatic Cleanup**: No explicit cleanup required
|
||||
|
||||
#### Redis Key Structure
|
||||
|
||||
##### MCP Session Keys
|
||||
```
|
||||
session:{sessionId}:owner # Session ownership
|
||||
mcp:shttp:toserver:{sessionId} # Client→Server messages
|
||||
mcp:shttp:toclient:{sessionId}:{requestId} # Server→Client responses
|
||||
mcp:control:{sessionId} # Control messages
|
||||
```
|
||||
|
||||
##### OAuth/Auth Keys
|
||||
```
|
||||
auth:client:{clientId} # OAuth client registrations
|
||||
auth:pending:{authCode} # Pending authorizations
|
||||
auth:installation:{accessToken} # Active MCP installations
|
||||
auth:exch:{authCode} # Token exchanges
|
||||
auth:refresh:{refreshToken} # Refresh tokens
|
||||
```
|
||||
|
||||
Note: The `auth:` prefix ensures complete namespace isolation between auth and MCP functions in both integrated and separate modes.
|
||||
|
||||
### Transport Methods
|
||||
|
||||
#### Streamable HTTP (Recommended)
|
||||
Modern [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) supporting bidirectional communication over HTTP:
|
||||
- Single endpoint for all operations
|
||||
- Session management via headers
|
||||
- Efficient message buffering
|
||||
- Automatic reconnection support
|
||||
|
||||
See [docs/streamable-http-design.md](docs/streamable-http-design.md) for implementation details.
|
||||
|
||||
#### Server-Sent Events (Legacy)
|
||||
Backwards-compatible [transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#server-sent-events) using SSE:
|
||||
- Separate endpoints for SSE streams and messages
|
||||
- Session management via URL parameters
|
||||
- Redis-backed message routing
|
||||
- Real-time event delivery
|
||||
|
||||
## API Reference
|
||||
|
||||
### MCP Endpoints
|
||||
- `GET/POST/DELETE /mcp` - Streamable HTTP transport endpoint
|
||||
- `POST`: Initialize sessions or send messages
|
||||
- `GET`: Establish SSE streams
|
||||
- `DELETE`: Terminate sessions
|
||||
- `GET /sse` - Legacy SSE transport endpoint
|
||||
- `POST /message` - Legacy message endpoint for SSE transport
|
||||
|
||||
### Authentication Endpoints (Integrated Mode Only)
|
||||
- `GET /fakeupstreamauth/authorize` - Fake OAuth authorization page
|
||||
- `GET /fakeupstreamauth/callback` - OAuth redirect handler
|
||||
- OAuth 2.0 endpoints provided by MCP SDK auth router
|
||||
|
||||
### Headers
|
||||
- `Mcp-Session-Id`: Session identifier for Streamable HTTP
|
||||
- `Authorization: Bearer <token>`: OAuth access token
|
||||
- Standard MCP headers as per protocol specification
|
||||
|
||||
## Security
|
||||
|
||||
|
|
@ -271,10 +627,11 @@ redis-cli GET "session:{sessionId}:owner"
|
|||
```
|
||||
|
||||
### Debug Tools
|
||||
- Development scripts in `scratch/` directory
|
||||
- MCP Inspector for interactive debugging
|
||||
- Comprehensive test suite
|
||||
- Hot-reload development mode
|
||||
- Source maps for debugging
|
||||
- Redis monitoring commands
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
94
auth-server/README.md
Normal file
94
auth-server/README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# MCP Standalone Authorization Server
|
||||
|
||||
This is a demonstration OAuth 2.0 authorization server for MCP's separate authentication mode.
|
||||
|
||||
## Purpose
|
||||
|
||||
This server demonstrates how MCP servers can delegate authentication to a separate authorization server. See the main [README Authentication Modes](../README.md#authentication-modes) section for a complete overview of integrated vs separate modes.
|
||||
|
||||
In production environments, you would typically use established OAuth providers like:
|
||||
- Auth0, Okta
|
||||
- Google OAuth, GitHub OAuth
|
||||
- Microsoft Azure AD, AWS Cognito
|
||||
|
||||
## Architecture
|
||||
|
||||
For detailed architecture information and OAuth flow analysis, see:
|
||||
- [Authentication Modes](../README.md#authentication-modes) - Overview and comparison
|
||||
- [OAuth 2.0 + PKCE Flow Analysis](../README.md#oauth-20--pkce-flow-analysis) - Step-by-step flow breakdown
|
||||
- [Authentication Architecture](../README.md#authentication-architecture) - Visual diagrams
|
||||
|
||||
This auth server specifically implements the "Auth Server" component in the separate mode architecture diagram.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `/.well-known/oauth-authorization-server` - OAuth 2.0 server metadata
|
||||
- `/authorize` - Authorization endpoint
|
||||
- `/token` - Token endpoint
|
||||
- `/register` - Dynamic client registration
|
||||
- `/introspect` - Token introspection (for MCP server validation)
|
||||
- `/fakeupstreamauth/authorize` - Fake upstream auth page (demo only)
|
||||
- `/fakeupstreamauth/callback` - Fake upstream callback (demo only)
|
||||
- `/health` - Health check endpoint
|
||||
|
||||
## Development
|
||||
|
||||
This server shares Redis with the MCP server for development convenience.
|
||||
In production, these would typically be separate.
|
||||
|
||||
## Running the Auth Server
|
||||
|
||||
### Standalone
|
||||
```bash
|
||||
# From the repository root
|
||||
npm run dev:auth-server
|
||||
```
|
||||
|
||||
### With MCP Server (Separate Mode)
|
||||
```bash
|
||||
# Start both servers together
|
||||
npm run dev:with-separate-auth
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:3001/health
|
||||
```
|
||||
|
||||
### OAuth Metadata
|
||||
```bash
|
||||
curl http://localhost:3001/.well-known/oauth-authorization-server
|
||||
```
|
||||
|
||||
### With MCP Inspector
|
||||
See the main [Testing with MCP Inspector](../README.md#testing-with-mcp-inspector) section for complete testing instructions for both modes.
|
||||
|
||||
**Quick test for this auth server:**
|
||||
1. Start this auth server: `npm run dev:auth-server`
|
||||
2. Start MCP server in separate mode: `AUTH_MODE=separate npm run dev`
|
||||
3. Follow the separate mode testing steps in the main README
|
||||
|
||||
## Configuration
|
||||
|
||||
The auth server uses the same configuration system as the main server. See [Configuration](../README.md#configuration) in the main README for complete environment variable documentation.
|
||||
|
||||
**Auth server specific variables:**
|
||||
- `AUTH_SERVER_PORT` - Port to run on (default: 3001)
|
||||
- `AUTH_SERVER_URL` - Base URL (default: http://localhost:3001)
|
||||
- `REDIS_URL` - Redis connection (shared with MCP server)
|
||||
|
||||
## Production Considerations
|
||||
|
||||
**This server is for demonstration only.** In production, use established OAuth providers.
|
||||
|
||||
For comprehensive security and deployment guidance, see:
|
||||
- [Security](../README.md#security) - Security measures and best practices
|
||||
- [Configuration](../README.md#configuration) - Environment setup
|
||||
- [Monitoring & Debugging](../README.md#monitoring--debugging) - Operational guidance
|
||||
|
||||
**Production replacement options:**
|
||||
- Corporate SSO (Auth0, Okta)
|
||||
- Cloud providers (AWS Cognito, Azure AD)
|
||||
- Social providers (Google OAuth, GitHub OAuth)
|
||||
166
auth-server/index.ts
Normal file
166
auth-server/index.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||||
import { EverythingAuthProvider } from '../src/auth/provider.js';
|
||||
import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from '../src/handlers/fakeauth.js';
|
||||
import { redisClient } from '../src/redis.js';
|
||||
import { logger } from '../src/utils/logger.js';
|
||||
import { AUTH_SERVER_PORT, AUTH_SERVER_URL, BASE_URI } from '../src/config.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
console.log('=====================================');
|
||||
console.log('MCP Demonstration Authorization Server');
|
||||
console.log('=====================================');
|
||||
console.log('This standalone server demonstrates OAuth 2.0');
|
||||
console.log('authorization separate from the MCP resource server');
|
||||
console.log('');
|
||||
console.log('This is for demonstration purposes only.');
|
||||
console.log('In production, you would use a real OAuth provider');
|
||||
console.log('like Auth0, Okta, Google, GitHub, etc.');
|
||||
console.log('=====================================');
|
||||
|
||||
// CORS for Inspector and MCP server
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
app.use(logger.middleware());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
mode: 'authorization-server',
|
||||
endpoints: {
|
||||
metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`,
|
||||
authorize: `${AUTH_SERVER_URL}/authorize`,
|
||||
token: `${AUTH_SERVER_URL}/token`,
|
||||
register: `${AUTH_SERVER_URL}/register`,
|
||||
introspect: `${AUTH_SERVER_URL}/introspect`
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Create auth provider instance for reuse
|
||||
const authProvider = new EverythingAuthProvider();
|
||||
|
||||
// OAuth endpoints via SDK's mcpAuthRouter
|
||||
app.use(mcpAuthRouter({
|
||||
provider: authProvider,
|
||||
issuerUrl: new URL(AUTH_SERVER_URL),
|
||||
tokenOptions: {
|
||||
rateLimit: { windowMs: 5000, limit: 100 }
|
||||
},
|
||||
clientRegistrationOptions: {
|
||||
rateLimit: { windowMs: 60000, limit: 10 }
|
||||
}
|
||||
}));
|
||||
|
||||
// Rate limiting for custom endpoints
|
||||
const introspectRateLimit = rateLimit({
|
||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||
limit: 100, // 100 introspections per 5 minutes
|
||||
message: { error: 'too_many_requests', error_description: 'Token introspection rate limit exceeded' }
|
||||
});
|
||||
|
||||
const fakeAuthRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 20, // 20 auth attempts per minute
|
||||
message: { error: 'too_many_requests', error_description: 'Authentication rate limit exceeded' }
|
||||
});
|
||||
|
||||
const staticFileRateLimit = rateLimit({
|
||||
windowMs: 10 * 60 * 1000, // 10 minutes
|
||||
limit: 25, // 25 requests per 10 minutes for static files
|
||||
message: { error: 'too_many_requests', error_description: 'Static file rate limit exceeded' }
|
||||
});
|
||||
|
||||
// Token introspection endpoint (RFC 7662)
|
||||
app.post('/introspect', introspectRateLimit, express.urlencoded({ extended: false }), async (req, res) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'Missing token parameter' });
|
||||
}
|
||||
|
||||
// Verify the token using the auth provider
|
||||
const authInfo = await authProvider.verifyAccessToken(token);
|
||||
|
||||
// Return RFC 7662 compliant response
|
||||
res.json({
|
||||
active: true,
|
||||
client_id: authInfo.clientId,
|
||||
scope: authInfo.scopes.join(' '),
|
||||
exp: authInfo.expiresAt,
|
||||
sub: authInfo.extra?.userId || 'unknown',
|
||||
userId: authInfo.extra?.userId, // Custom field for our implementation
|
||||
username: authInfo.extra?.username,
|
||||
iss: AUTH_SERVER_URL,
|
||||
aud: BASE_URI, // The resource server URL this token is intended for
|
||||
token_type: 'Bearer'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.debug('Token introspection failed', { error: (error as Error).message });
|
||||
|
||||
// Return inactive token response (don't leak error details)
|
||||
res.json({
|
||||
active: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fake upstream auth endpoints (for user authentication simulation)
|
||||
app.get('/fakeupstreamauth/authorize', fakeAuthRateLimit, cors(), handleFakeAuthorize);
|
||||
app.get('/fakeupstreamauth/callback', fakeAuthRateLimit, cors(), handleFakeAuthorizeRedirect);
|
||||
|
||||
// Static assets (for auth page styling)
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
app.get('/mcp-logo.png', staticFileRateLimit, (req, res) => {
|
||||
// Serve from the main server's static directory
|
||||
const logoPath = path.join(__dirname, '../src/static/mcp.png');
|
||||
res.sendFile(logoPath);
|
||||
});
|
||||
|
||||
// Connect to Redis (shared with MCP server in dev)
|
||||
try {
|
||||
await redisClient.connect();
|
||||
logger.info('Connected to Redis', { url: redisClient.options?.url });
|
||||
} catch (error) {
|
||||
logger.error('Could not connect to Redis', error as Error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.listen(AUTH_SERVER_PORT, () => {
|
||||
logger.info('Authorization server started', {
|
||||
port: AUTH_SERVER_PORT,
|
||||
url: AUTH_SERVER_URL,
|
||||
endpoints: {
|
||||
metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`,
|
||||
authorize: `${AUTH_SERVER_URL}/authorize`,
|
||||
token: `${AUTH_SERVER_URL}/token`,
|
||||
register: `${AUTH_SERVER_URL}/register`,
|
||||
introspect: `${AUTH_SERVER_URL}/introspect`
|
||||
}
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('🚀 Auth server ready! Test with:');
|
||||
console.log(` curl ${AUTH_SERVER_URL}/health`);
|
||||
console.log(` curl ${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`);
|
||||
console.log('');
|
||||
console.log('💡 To test separate mode:');
|
||||
console.log(' 1. Keep this server running');
|
||||
console.log(' 2. In another terminal: AUTH_MODE=separate npm run dev');
|
||||
console.log(' 3. Connect Inspector to http://localhost:3232/mcp');
|
||||
});
|
||||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7.2-bookworm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
|
@ -1,21 +1,45 @@
|
|||
# Design Document: Implementing Streamable HTTP Transport for Example Remote Server
|
||||
# Design Document: Transport Implementation for Example Remote Server
|
||||
|
||||
## Research Summary
|
||||
## Current Implementation
|
||||
|
||||
### Current SSE Transport Architecture
|
||||
The example remote server implements both MCP transport methods to support different client needs.
|
||||
|
||||
The example remote server currently uses the following architecture:
|
||||
### Shared Infrastructure
|
||||
|
||||
All transports share the same foundational components:
|
||||
|
||||
1. **Authentication**: Uses `requireBearerAuth` middleware with mode-dependent auth providers
|
||||
2. **Redis Integration**: Messages published/subscribed through Redis channels using session IDs
|
||||
3. **Session Management**: Session ownership tracked via Redis for multi-user isolation
|
||||
4. **MCP Server**: Common `createMcpServer()` provides tools, resources, and prompts
|
||||
|
||||
### SSE Transport Architecture
|
||||
|
||||
Legacy transport for backwards compatibility:
|
||||
|
||||
1. **SSE Endpoint**: `/sse` - Creates SSE connection using `SSEServerTransport`
|
||||
2. **Message Endpoint**: `/message` - Receives POST requests and forwards them via Redis
|
||||
3. **Redis Integration**: Messages are published/subscribed through Redis channels using session IDs
|
||||
4. **Auth**: Uses `requireBearerAuth` middleware with `EverythingAuthProvider`
|
||||
5. **Session Management**: Each SSE connection gets a unique session ID used as Redis channel key
|
||||
3. **Session ID**: Generated by `SSEServerTransport`, used for Redis channel keys
|
||||
4. **Message Flow**: POST to `/message` → Redis pub/sub → SSE stream to client
|
||||
|
||||
**Key Files:**
|
||||
- `/src/index.ts:91` - SSE endpoint with auth and headers
|
||||
- `/src/handlers/mcp.ts:55-118` - SSE connection handler with Redis integration
|
||||
- `/src/handlers/mcp.ts:120-144` - Message POST handler
|
||||
- `/src/index.ts:236` - SSE endpoint registration
|
||||
- `/src/handlers/sse.ts` - SSE connection handler with Redis integration
|
||||
- `/src/handlers/sse.ts:75-95` - Message POST handler
|
||||
|
||||
### Streamable HTTP Transport Architecture
|
||||
|
||||
Modern unified transport:
|
||||
|
||||
1. **Unified Endpoint**: `/mcp` - Handles GET, POST, DELETE with `StreamableHTTPServerTransport`
|
||||
2. **Stateful Sessions**: Requires initialization with session ID tracking
|
||||
3. **Response Format**: Returns results via Server-Sent Events streams
|
||||
4. **Session Management**: Custom session ownership integration via Redis
|
||||
|
||||
**Key Files:**
|
||||
- `/src/index.ts:240-242` - Streamable HTTP endpoint registration
|
||||
- `/src/handlers/shttp.ts` - Complete Streamable HTTP handler
|
||||
- `/src/services/redisTransport.ts` - Redis-backed transport integration
|
||||
|
||||
### Streamable HTTP Transport Specification (2025-03-26)
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ Redis stores session ownership information using a structured key system.
|
|||
|
||||
### Redis Key Structure
|
||||
|
||||
#### MCP Session Keys (MCP Server)
|
||||
```
|
||||
session:{sessionId}:owner → userId # Session ownership
|
||||
mcp:shttp:toserver:{sessionId} → [pub/sub channel] # Client→Server messages (also indicates liveness)
|
||||
|
|
@ -134,6 +135,17 @@ mcp:shttp:toclient:{sessionId}:{requestId} → [pub/sub channel] # Server→Clie
|
|||
mcp:control:{sessionId} → [pub/sub channel] # Control messages
|
||||
```
|
||||
|
||||
#### Auth Keys (Auth Server)
|
||||
```
|
||||
auth:client:{clientId} → client registration # OAuth client registrations
|
||||
auth:pending:{authCode} → pending authorization # Pending auth (10 min TTL)
|
||||
auth:installation:{accessToken} → MCP installation # Active sessions (7 days TTL)
|
||||
auth:exch:{authCode} → token exchange # Token exchange (10 min TTL)
|
||||
auth:refresh:{refreshToken} → access token # Refresh tokens (7 days TTL)
|
||||
```
|
||||
|
||||
Note: The `auth:` prefix ensures complete isolation from MCP session keys, allowing both integrated and separate modes to work consistently.
|
||||
|
||||
### Redis Operations
|
||||
|
||||
| Operation | Key Pattern | Value | Purpose |
|
||||
|
|
|
|||
533
package-lock.json
generated
533
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
21
package.json
21
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": {
|
||||
|
|
|
|||
235
scripts/test-integrated-e2e.sh
Executable file
235
scripts/test-integrated-e2e.sh
Executable file
|
|
@ -0,0 +1,235 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=================================================="
|
||||
echo "End-to-End Test - Integrated Mode"
|
||||
echo "=================================================="
|
||||
|
||||
# Kill any existing servers
|
||||
echo "🛑 Cleaning up existing servers..."
|
||||
pkill -f "node.*dist/src/index" || true
|
||||
pkill -f "tsx watch.*src/index" || true
|
||||
sleep 2
|
||||
|
||||
# Use environment variables if available, otherwise defaults
|
||||
MCP_SERVER="${BASE_URI:-http://localhost:3232}"
|
||||
USER_ID="e2e-test-$(date +%s)"
|
||||
|
||||
echo "🔧 Configuration:"
|
||||
echo " MCP Server: $MCP_SERVER"
|
||||
echo " User ID: $USER_ID"
|
||||
echo " Auth Mode: ${AUTH_MODE:-integrated} (from environment)"
|
||||
echo ""
|
||||
|
||||
# Check prerequisites
|
||||
echo "🔍 Checking prerequisites..."
|
||||
|
||||
# Check Redis
|
||||
if ! docker ps | grep -q redis; then
|
||||
echo "❌ Redis not running"
|
||||
echo " Start Redis: docker compose up -d"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Redis is running"
|
||||
|
||||
# Check if wrong mode is set
|
||||
if [ "${AUTH_MODE:-integrated}" != "integrated" ]; then
|
||||
echo "⚠️ AUTH_MODE is set to '${AUTH_MODE}' but this script tests integrated mode"
|
||||
echo " Either run: AUTH_MODE=integrated $0"
|
||||
echo " Or use: ./scripts/test-separate-e2e.sh"
|
||||
fi
|
||||
|
||||
# Start MCP server in integrated mode
|
||||
echo "🚀 Starting MCP server in integrated mode..."
|
||||
AUTH_MODE=integrated npm start &
|
||||
MCP_PID=$!
|
||||
sleep 5
|
||||
|
||||
# Check MCP server
|
||||
if ! curl -s -f "$MCP_SERVER/" > /dev/null; then
|
||||
echo "❌ MCP server failed to start at $MCP_SERVER"
|
||||
kill $MCP_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ MCP server is running (PID: $MCP_PID)"
|
||||
|
||||
# Clean up on exit
|
||||
trap "kill $MCP_PID 2>/dev/null || true" EXIT
|
||||
|
||||
echo "🔐 PHASE 1: OAuth Authentication"
|
||||
echo "================================"
|
||||
|
||||
# OAuth Step 1: Client Registration
|
||||
# Register a new OAuth client application with the authorization server
|
||||
# This would typically be done once during app setup, not for each user
|
||||
CLIENT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d '{"client_name":"e2e-fixed","redirect_uris":["http://localhost:3000/callback"]}' "$MCP_SERVER/register")
|
||||
CLIENT_ID=$(echo "$CLIENT_RESPONSE" | jq -r .client_id)
|
||||
CLIENT_SECRET=$(echo "$CLIENT_RESPONSE" | jq -r .client_secret)
|
||||
|
||||
# OAuth Step 2: Generate PKCE (Proof Key for Code Exchange) parameters
|
||||
# PKCE adds security to the OAuth flow by preventing authorization code interception attacks
|
||||
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43)
|
||||
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr "+/" "-_" | tr -d "=")
|
||||
|
||||
# OAuth Step 3: Authorization Request
|
||||
# Direct the user to the authorization server's /authorize endpoint
|
||||
# Include state parameter for CSRF protection
|
||||
STATE_PARAM="e2e-state-$(date +%s)"
|
||||
|
||||
AUTH_PAGE=$(curl -s "$MCP_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=$STATE_PARAM")
|
||||
# Extract the authorization code from the HTML response (normally would be in redirect URL)
|
||||
AUTH_CODE=$(echo "$AUTH_PAGE" | grep -o 'state=[^"&]*' | cut -d= -f2)
|
||||
|
||||
# OAuth Step 4: User Authentication & Authorization
|
||||
# In a real flow, the user would authenticate with the auth server here
|
||||
# For testing, we simulate this with the fake upstream auth endpoint
|
||||
CALLBACK_RESPONSE=$(curl -s -i "$MCP_SERVER/fakeupstreamauth/callback?state=$AUTH_CODE&code=fakecode&userId=$USER_ID")
|
||||
|
||||
# OAuth Step 5: Authorization Code Redirect
|
||||
# Verify the auth server redirects back to our redirect_uri with the code and state
|
||||
# The state parameter MUST match what we sent to prevent CSRF attacks
|
||||
LOCATION_HEADER=$(echo "$CALLBACK_RESPONSE" | grep -i "^location:" | tr -d '\r')
|
||||
if echo "$LOCATION_HEADER" | grep -q "state=$STATE_PARAM"; then
|
||||
echo "✅ State parameter verified in callback"
|
||||
else
|
||||
echo "❌ State parameter mismatch or missing in callback"
|
||||
echo " Expected state: $STATE_PARAM"
|
||||
echo " Location header: $LOCATION_HEADER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# OAuth Step 6: Token Exchange
|
||||
# Exchange the authorization code for access and refresh tokens
|
||||
# Include the PKCE code_verifier to prove we initiated the flow
|
||||
TOKEN_RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=authorization_code&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&code=$AUTH_CODE&redirect_uri=http://localhost:3000/callback&code_verifier=$CODE_VERIFIER" "$MCP_SERVER/token")
|
||||
|
||||
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r .access_token)
|
||||
echo "✅ OAuth complete, token: ${ACCESS_TOKEN:0:15}..."
|
||||
|
||||
echo ""
|
||||
echo "🧪 PHASE 2: MCP Feature Testing"
|
||||
echo "==============================="
|
||||
|
||||
# Step 1: Initialize MCP session (no session ID header)
|
||||
echo ""
|
||||
echo "📱 Step 1: Initialize MCP session"
|
||||
INIT_RESPONSE=$(curl -i -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e-test","version":"1.0"}}}' \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
# Extract session ID from response header
|
||||
SESSION_ID=$(echo "$INIT_RESPONSE" | grep -i "mcp-session-id:" | cut -d' ' -f2 | tr -d '\r')
|
||||
|
||||
if [ -n "$SESSION_ID" ]; then
|
||||
echo " ✅ Session initialized: $SESSION_ID"
|
||||
else
|
||||
echo " ❌ No session ID in response headers"
|
||||
echo "Headers:"
|
||||
echo "$INIT_RESPONSE" | head -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Test tools with session ID
|
||||
echo ""
|
||||
echo "🔧 Step 2: Test Tools"
|
||||
TOOLS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":"tools","method":"tools/list"}' \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
if echo "$TOOLS_RESPONSE" | grep -q "event: message"; then
|
||||
TOOLS_JSON=$(echo "$TOOLS_RESPONSE" | grep "^data: " | sed 's/^data: //')
|
||||
TOOL_COUNT=$(echo "$TOOLS_JSON" | jq '.result.tools | length')
|
||||
echo " ✅ Tools: $TOOL_COUNT (README claims: 7)"
|
||||
|
||||
# Test echo tool
|
||||
ECHO_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":"echo","method":"tools/call","params":{"name":"echo","arguments":{"message":"E2E working!"}}}' \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
if echo "$ECHO_RESPONSE" | grep -q "event: message"; then
|
||||
ECHO_JSON=$(echo "$ECHO_RESPONSE" | grep "^data: " | sed 's/^data: //')
|
||||
ECHO_RESULT=$(echo "$ECHO_JSON" | jq -r '.result.content[0].text')
|
||||
echo " 🔊 Echo test: '$ECHO_RESULT'"
|
||||
fi
|
||||
else
|
||||
echo " ❌ Tools test failed: $TOOLS_RESPONSE"
|
||||
fi
|
||||
|
||||
# Step 3: Test resources
|
||||
echo ""
|
||||
echo "📚 Step 3: Test Resources (counting all pages)"
|
||||
TOTAL_RESOURCES=0
|
||||
CURSOR=""
|
||||
PAGE=1
|
||||
|
||||
while true; do
|
||||
if [ -n "$CURSOR" ]; then
|
||||
PARAMS="{\"cursor\":\"$CURSOR\"}"
|
||||
else
|
||||
PARAMS="{}"
|
||||
fi
|
||||
|
||||
RESOURCES_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":\"resources$PAGE\",\"method\":\"resources/list\",\"params\":$PARAMS}" \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
if echo "$RESOURCES_RESPONSE" | grep -q "event: message"; then
|
||||
RESOURCES_JSON=$(echo "$RESOURCES_RESPONSE" | grep "^data: " | sed 's/^data: //')
|
||||
PAGE_COUNT=$(echo "$RESOURCES_JSON" | jq '.result.resources | length')
|
||||
NEXT_CURSOR=$(echo "$RESOURCES_JSON" | jq -r '.result.nextCursor // empty')
|
||||
|
||||
TOTAL_RESOURCES=$((TOTAL_RESOURCES + PAGE_COUNT))
|
||||
echo " 📄 Page $PAGE: $PAGE_COUNT resources (total: $TOTAL_RESOURCES)"
|
||||
|
||||
if [ -z "$NEXT_CURSOR" ]; then
|
||||
break
|
||||
fi
|
||||
CURSOR="$NEXT_CURSOR"
|
||||
PAGE=$((PAGE + 1))
|
||||
else
|
||||
echo " ❌ Resources page $PAGE failed: $RESOURCES_RESPONSE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
RESOURCE_COUNT=$TOTAL_RESOURCES
|
||||
echo " 📊 Total Resources: $RESOURCE_COUNT (README claims: 100)"
|
||||
|
||||
# Step 4: Test prompts
|
||||
echo ""
|
||||
echo "💭 Step 4: Test Prompts"
|
||||
PROMPTS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":"prompts","method":"prompts/list"}' \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
if echo "$PROMPTS_RESPONSE" | grep -q "event: message"; then
|
||||
PROMPTS_JSON=$(echo "$PROMPTS_RESPONSE" | grep "^data: " | sed 's/^data: //')
|
||||
PROMPT_COUNT=$(echo "$PROMPTS_JSON" | jq '.result.prompts | length')
|
||||
echo " 💬 Prompts: $PROMPT_COUNT"
|
||||
else
|
||||
echo " ❌ Prompts test failed: $PROMPTS_RESPONSE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 INTEGRATED MODE E2E TEST COMPLETE!"
|
||||
echo "====================================="
|
||||
echo "📊 Verification Results:"
|
||||
echo " Tools: $TOOL_COUNT (README: 7) $([ "$TOOL_COUNT" = "7" ] && echo "✅" || echo "❌")"
|
||||
echo " Resources: $RESOURCE_COUNT (README: 100) $([ "$RESOURCE_COUNT" = "100" ] && echo "✅" || echo "❌")"
|
||||
echo " Prompts: $PROMPT_COUNT"
|
||||
echo " OAuth flow: ✅ Working"
|
||||
echo " MCP features: ✅ Working"
|
||||
295
scripts/test-separate-e2e.sh
Executable file
295
scripts/test-separate-e2e.sh
Executable file
|
|
@ -0,0 +1,295 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=================================================="
|
||||
echo "End-to-End Test - Separate Mode"
|
||||
echo "=================================================="
|
||||
echo "This script tests the complete OAuth flow and MCP features"
|
||||
echo "using separate auth server and MCP server."
|
||||
echo ""
|
||||
|
||||
# Kill any existing servers
|
||||
echo "🛑 Cleaning up existing servers..."
|
||||
pkill -f "node.*dist/src/index" || true
|
||||
pkill -f "node.*dist/auth-server/index" || true
|
||||
pkill -f "tsx watch.*src/index" || true
|
||||
pkill -f "tsx watch.*auth-server/index" || true
|
||||
sleep 2
|
||||
|
||||
# Use environment variables if available, otherwise defaults
|
||||
AUTH_SERVER="${AUTH_SERVER_URL:-http://localhost:3001}"
|
||||
MCP_SERVER="${BASE_URI:-http://localhost:3232}"
|
||||
USER_ID="e2e-separate-$(date +%s)"
|
||||
|
||||
echo "🔧 Configuration:"
|
||||
echo " Auth Server: $AUTH_SERVER"
|
||||
echo " MCP Server: $MCP_SERVER"
|
||||
echo " User ID: $USER_ID"
|
||||
echo " Auth Mode: ${AUTH_MODE:-separate} (from environment)"
|
||||
echo ""
|
||||
|
||||
# Check prerequisites
|
||||
echo "🔍 Checking prerequisites..."
|
||||
|
||||
# Check Redis
|
||||
if ! docker ps | grep -q redis; then
|
||||
echo "❌ Redis not running"
|
||||
echo " Start Redis: docker compose up -d"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Redis is running"
|
||||
|
||||
# Check if wrong mode is set
|
||||
if [ "${AUTH_MODE}" = "integrated" ]; then
|
||||
echo "⚠️ AUTH_MODE is set to 'integrated' but this script tests separate mode"
|
||||
echo " Either run: AUTH_MODE=separate $0"
|
||||
echo " Or use: ./scripts/test-integrated-e2e-fixed.sh"
|
||||
fi
|
||||
|
||||
# Start auth server
|
||||
echo "🚀 Starting auth server..."
|
||||
npm run start:auth-server &
|
||||
AUTH_PID=$!
|
||||
sleep 5
|
||||
|
||||
# Check auth server
|
||||
if ! curl -s -f "$AUTH_SERVER/health" > /dev/null; then
|
||||
echo "❌ Auth server failed to start at $AUTH_SERVER"
|
||||
kill $AUTH_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Auth server is running (PID: $AUTH_PID)"
|
||||
|
||||
# Start MCP server in separate mode
|
||||
echo "🚀 Starting MCP server in separate mode..."
|
||||
AUTH_MODE=separate npm start &
|
||||
MCP_PID=$!
|
||||
sleep 5
|
||||
|
||||
# Check MCP server
|
||||
if ! curl -s -f "$MCP_SERVER/" > /dev/null; then
|
||||
echo "❌ MCP server failed to start at $MCP_SERVER"
|
||||
kill $AUTH_PID 2>/dev/null || true
|
||||
kill $MCP_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ MCP server is running (PID: $MCP_PID)"
|
||||
|
||||
# Clean up on exit
|
||||
trap "kill $AUTH_PID $MCP_PID 2>/dev/null || true" EXIT
|
||||
|
||||
echo ""
|
||||
echo "🔐 PHASE 1: OAuth Authentication (with Auth Server)"
|
||||
echo "================================================="
|
||||
|
||||
# OAuth Step 1: Client Registration
|
||||
# Register a new OAuth client application with the authorization server
|
||||
# This would typically be done once during app setup, not for each user
|
||||
echo "📝 Step 1: Register OAuth client with auth server"
|
||||
CLIENT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"client_name\":\"e2e-separate-client\",\"redirect_uris\":[\"http://localhost:3000/callback\"]}" \
|
||||
"$AUTH_SERVER/register")
|
||||
|
||||
CLIENT_ID=$(echo "$CLIENT_RESPONSE" | jq -r .client_id)
|
||||
CLIENT_SECRET=$(echo "$CLIENT_RESPONSE" | jq -r .client_secret)
|
||||
echo " Client ID: $CLIENT_ID"
|
||||
|
||||
# OAuth Step 2: Generate PKCE (Proof Key for Code Exchange) parameters
|
||||
# PKCE adds security to the OAuth flow by preventing authorization code interception attacks
|
||||
echo ""
|
||||
echo "🔐 Step 2: Generate PKCE challenge"
|
||||
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-43)
|
||||
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr "+/" "-_" | tr -d "=")
|
||||
echo " Code verifier generated"
|
||||
|
||||
# OAuth Step 3: Authorization Request
|
||||
# Direct the user to the authorization server's /authorize endpoint
|
||||
# Include state parameter for CSRF protection
|
||||
echo ""
|
||||
echo "🎫 Step 3: Get authorization code from auth server"
|
||||
STATE_PARAM="separate-test-$(date +%s)"
|
||||
AUTH_URL="$AUTH_SERVER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=$STATE_PARAM"
|
||||
|
||||
AUTH_PAGE=$(curl -s "$AUTH_URL")
|
||||
# Extract the authorization code from the HTML response (normally would be in redirect URL)
|
||||
AUTH_CODE=$(echo "$AUTH_PAGE" | grep -o 'state=[^"&]*' | cut -d= -f2 | head -1)
|
||||
|
||||
if [ -z "$AUTH_CODE" ]; then
|
||||
echo " ❌ Failed to extract authorization code from auth server"
|
||||
exit 1
|
||||
fi
|
||||
echo " Auth Code: ${AUTH_CODE:0:20}..."
|
||||
|
||||
# OAuth Step 4: User Authentication & Authorization
|
||||
# In a real flow, the user would authenticate with the auth server here
|
||||
# For testing, we simulate this with the fake upstream auth endpoint
|
||||
echo ""
|
||||
echo "🔄 Step 4: Complete fake upstream auth with auth server"
|
||||
CALLBACK_URL="$AUTH_SERVER/fakeupstreamauth/callback?state=$AUTH_CODE&code=fakecode&userId=$USER_ID"
|
||||
CALLBACK_RESPONSE=$(curl -s -i "$CALLBACK_URL")
|
||||
|
||||
# OAuth Step 5: Authorization Code Redirect
|
||||
# Verify the auth server redirects back to our redirect_uri with the code and state
|
||||
# The state parameter MUST match what we sent to prevent CSRF attacks
|
||||
LOCATION_HEADER=$(echo "$CALLBACK_RESPONSE" | grep -i "^location:" | tr -d '\r')
|
||||
if echo "$LOCATION_HEADER" | grep -q "state=$STATE_PARAM"; then
|
||||
echo " ✅ State parameter verified in callback"
|
||||
else
|
||||
echo " ❌ State parameter mismatch or missing in callback"
|
||||
echo " Expected state: $STATE_PARAM"
|
||||
echo " Location header: $LOCATION_HEADER"
|
||||
exit 1
|
||||
fi
|
||||
echo " Fake upstream auth completed"
|
||||
|
||||
# OAuth Step 6: Token Exchange
|
||||
# Exchange the authorization code for access and refresh tokens
|
||||
# Include the PKCE code_verifier to prove we initiated the flow
|
||||
echo ""
|
||||
echo "🎟️ Step 5: Exchange code for access token with auth server"
|
||||
TOKEN_RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=authorization_code&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&code=$AUTH_CODE&redirect_uri=http://localhost:3000/callback&code_verifier=$CODE_VERIFIER" \
|
||||
"$AUTH_SERVER/token")
|
||||
|
||||
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r .access_token)
|
||||
if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo " ❌ Token exchange failed"
|
||||
echo "Response: $TOKEN_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ Access token from auth server: ${ACCESS_TOKEN:0:20}..."
|
||||
|
||||
echo ""
|
||||
echo "🧪 PHASE 2: MCP Feature Testing (with MCP Server)"
|
||||
echo "=============================================="
|
||||
|
||||
# Step 1: Initialize MCP session with MCP SERVER using auth server token
|
||||
echo ""
|
||||
echo "📱 Step 1: Initialize MCP session with MCP server"
|
||||
INIT_RESPONSE=$(curl -i -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e-separate","version":"1.0"}}}' \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
# Extract session ID from response header
|
||||
SESSION_ID=$(echo "$INIT_RESPONSE" | grep -i "mcp-session-id:" | cut -d' ' -f2 | tr -d '\r')
|
||||
|
||||
if [ -n "$SESSION_ID" ]; then
|
||||
echo " ✅ MCP session initialized: $SESSION_ID"
|
||||
echo " ✅ Auth server token accepted by MCP server!"
|
||||
else
|
||||
echo " ❌ MCP session initialization failed"
|
||||
echo "$INIT_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Test tools with MCP SERVER
|
||||
echo ""
|
||||
echo "🔧 Step 2: Test Tools with MCP server"
|
||||
TOOLS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":"tools","method":"tools/list"}' \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
if echo "$TOOLS_RESPONSE" | grep -q "event: message"; then
|
||||
TOOLS_JSON=$(echo "$TOOLS_RESPONSE" | grep "^data: " | sed 's/^data: //')
|
||||
TOOL_COUNT=$(echo "$TOOLS_JSON" | jq '.result.tools | length')
|
||||
echo " ✅ Tools: $TOOL_COUNT (README claims: 7)"
|
||||
|
||||
# Test echo tool
|
||||
ECHO_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":"echo","method":"tools/call","params":{"name":"echo","arguments":{"message":"Separate mode working!"}}}' \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
if echo "$ECHO_RESPONSE" | grep -q "event: message"; then
|
||||
ECHO_JSON=$(echo "$ECHO_RESPONSE" | grep "^data: " | sed 's/^data: //')
|
||||
ECHO_RESULT=$(echo "$ECHO_JSON" | jq -r '.result.content[0].text')
|
||||
echo " 🔊 Echo test: '$ECHO_RESULT'"
|
||||
fi
|
||||
else
|
||||
echo " ❌ Tools test failed: $TOOLS_RESPONSE"
|
||||
fi
|
||||
|
||||
# Step 3: Test resources with MCP SERVER (with pagination)
|
||||
echo ""
|
||||
echo "📚 Step 3: Test Resources with MCP server (counting all pages)"
|
||||
TOTAL_RESOURCES=0
|
||||
CURSOR=""
|
||||
PAGE=1
|
||||
|
||||
while true; do
|
||||
if [ -n "$CURSOR" ]; then
|
||||
PARAMS="{\"cursor\":\"$CURSOR\"}"
|
||||
else
|
||||
PARAMS="{}"
|
||||
fi
|
||||
|
||||
RESOURCES_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":\"resources$PAGE\",\"method\":\"resources/list\",\"params\":$PARAMS}" \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
if echo "$RESOURCES_RESPONSE" | grep -q "event: message"; then
|
||||
RESOURCES_JSON=$(echo "$RESOURCES_RESPONSE" | grep "^data: " | sed 's/^data: //')
|
||||
PAGE_COUNT=$(echo "$RESOURCES_JSON" | jq '.result.resources | length')
|
||||
NEXT_CURSOR=$(echo "$RESOURCES_JSON" | jq -r '.result.nextCursor // empty')
|
||||
|
||||
TOTAL_RESOURCES=$((TOTAL_RESOURCES + PAGE_COUNT))
|
||||
echo " 📄 Page $PAGE: $PAGE_COUNT resources (total: $TOTAL_RESOURCES)"
|
||||
|
||||
if [ -z "$NEXT_CURSOR" ]; then
|
||||
break
|
||||
fi
|
||||
CURSOR="$NEXT_CURSOR"
|
||||
PAGE=$((PAGE + 1))
|
||||
else
|
||||
echo " ❌ Resources page $PAGE failed: $RESOURCES_RESPONSE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
RESOURCE_COUNT=$TOTAL_RESOURCES
|
||||
echo " 📊 Total Resources: $RESOURCE_COUNT (README claims: 100)"
|
||||
|
||||
# Step 4: Test prompts with MCP SERVER
|
||||
echo ""
|
||||
echo "💭 Step 4: Test Prompts with MCP server"
|
||||
PROMPTS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":"prompts","method":"prompts/list"}' \
|
||||
"$MCP_SERVER/mcp")
|
||||
|
||||
if echo "$PROMPTS_RESPONSE" | grep -q "event: message"; then
|
||||
PROMPTS_JSON=$(echo "$PROMPTS_RESPONSE" | grep "^data: " | sed 's/^data: //')
|
||||
PROMPT_COUNT=$(echo "$PROMPTS_JSON" | jq '.result.prompts | length')
|
||||
echo " 💬 Prompts: $PROMPT_COUNT"
|
||||
else
|
||||
echo " ❌ Prompts test failed: $PROMPTS_RESPONSE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 SEPARATE MODE E2E TEST COMPLETE!"
|
||||
echo "==================================="
|
||||
echo "✅ OAuth flow: Auth server → MCP server delegation working"
|
||||
echo "✅ Token validation: MCP server accepts auth server tokens"
|
||||
echo "✅ Session management: MCP server creates sessions for external tokens"
|
||||
echo ""
|
||||
echo "📊 Verification Results:"
|
||||
echo " Tools: $TOOL_COUNT (README: 7) $([ "$TOOL_COUNT" = "7" ] && echo "✅" || echo "❌")"
|
||||
echo " Resources: $RESOURCE_COUNT (README: 100) $([ "$RESOURCE_COUNT" = "100" ] && echo "✅" || echo "❌")"
|
||||
echo " Prompts: $PROMPT_COUNT"
|
||||
echo ""
|
||||
echo "🏗️ Architecture Verified:"
|
||||
echo " ✅ Separate auth server provides OAuth endpoints"
|
||||
echo " ✅ MCP server validates tokens via introspection"
|
||||
echo " ✅ Session ownership works across server boundaries"
|
||||
87
shared/auth-core.ts
Normal file
87
shared/auth-core.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import crypto from "crypto";
|
||||
import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
/**
|
||||
* Generates a PKCE code challenge from a verifier string.
|
||||
* Uses S256 method as specified in RFC 7636.
|
||||
* @param verifier The code verifier string
|
||||
* @returns Base64url-encoded SHA256 hash of the verifier
|
||||
*/
|
||||
export function generatePKCEChallenge(verifier: string): string {
|
||||
const buffer = Buffer.from(verifier);
|
||||
const hash = crypto.createHash("sha256").update(buffer);
|
||||
return hash.digest("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure random token.
|
||||
* @returns 64-character hexadecimal string
|
||||
*/
|
||||
export function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA256 hash of input data.
|
||||
* @param data The string to hash
|
||||
* @returns Hexadecimal representation of the hash
|
||||
*/
|
||||
export function sha256(data: string): string {
|
||||
return crypto.createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a string using AES-256-CBC encryption.
|
||||
* @param text The plaintext to encrypt
|
||||
* @param key The encryption key (64 hex characters)
|
||||
* @returns Encrypted string in format "iv:ciphertext"
|
||||
*/
|
||||
export function encryptString({ text, key }: { text: string; key: string }): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv);
|
||||
let encrypted = cipher.update(text, "utf-8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
return `${iv.toString("hex")}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a string encrypted with encryptString.
|
||||
* @param encryptedText The encrypted string in format "iv:ciphertext"
|
||||
* @param key The encryption key (64 hex characters)
|
||||
* @returns Decrypted plaintext
|
||||
*/
|
||||
export function decryptString({
|
||||
encryptedText,
|
||||
key,
|
||||
}: {
|
||||
encryptedText: string;
|
||||
key: string;
|
||||
}): string {
|
||||
const [ivHex, encrypted] = encryptedText.split(":");
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const decipher = crypto.createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv);
|
||||
let decrypted = decipher.update(encrypted, "hex", "utf-8");
|
||||
decrypted += decipher.final("utf-8");
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access token expiry time in seconds (1 hour)
|
||||
*/
|
||||
export const ACCESS_TOKEN_EXPIRY_SEC = 60 * 60;
|
||||
|
||||
/**
|
||||
* Generates a complete set of MCP OAuth tokens.
|
||||
* @returns OAuth tokens with access token, refresh token, and expiry
|
||||
*/
|
||||
export function generateMcpTokens(): OAuthTokens {
|
||||
const mcpAccessToken = generateToken();
|
||||
const mcpRefreshToken = generateToken();
|
||||
|
||||
return {
|
||||
access_token: mcpAccessToken,
|
||||
refresh_token: mcpRefreshToken,
|
||||
expires_in: ACCESS_TOKEN_EXPIRY_SEC,
|
||||
token_type: "Bearer",
|
||||
};
|
||||
}
|
||||
285
shared/redis-auth.ts
Normal file
285
shared/redis-auth.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { SetOptions } from "@redis/client";
|
||||
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { RedisClient } from "../src/redis.js";
|
||||
import { McpInstallation, PendingAuthorization, TokenExchange } from "./types.js";
|
||||
import { sha256, encryptString, decryptString } from "./auth-core.js";
|
||||
import { logger } from "../src/utils/logger.js";
|
||||
|
||||
/**
|
||||
* Redis key prefixes for different data types
|
||||
* All auth-related keys use "auth:" prefix to avoid collision with MCP session keys
|
||||
*/
|
||||
export const REDIS_KEY_PREFIXES = {
|
||||
CLIENT_REGISTRATION: "auth:client:",
|
||||
PENDING_AUTHORIZATION: "auth:pending:",
|
||||
MCP_AUTHORIZATION: "auth:installation:", // Changed from "mcp:" to avoid collision
|
||||
TOKEN_EXCHANGE: "auth:exch:",
|
||||
REFRESH_TOKEN: "auth:refresh:",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Redis key expiry times in seconds
|
||||
*/
|
||||
export const REDIS_EXPIRY_TIMES = {
|
||||
CLIENT_REGISTRATION: 30 * 24 * 60 * 60, // 30 days - client app credentials
|
||||
PENDING_AUTHORIZATION: 10 * 60, // 10 minutes - authorization code -> PendingAuthorization
|
||||
TOKEN_EXCHANGE: 10 * 60, // 10 minutes - authorization code -> MCP access token
|
||||
UPSTREAM_INSTALLATION: 7 * 24 * 60 * 60, // 7 days - MCP access token -> UpstreamInstallation
|
||||
REFRESH_TOKEN: 7 * 24 * 60 * 60, // 7 days - MCP refresh token -> access token
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Saves encrypted data to Redis with optional expiry.
|
||||
*/
|
||||
async function saveEncrypted<T>(
|
||||
redisClient: RedisClient,
|
||||
{
|
||||
prefix,
|
||||
key,
|
||||
data,
|
||||
options,
|
||||
}: {
|
||||
prefix: string;
|
||||
key: string;
|
||||
data: T;
|
||||
options?: SetOptions;
|
||||
}
|
||||
): Promise<string | null> {
|
||||
const value = encryptString({
|
||||
text: JSON.stringify(data),
|
||||
key: key,
|
||||
});
|
||||
|
||||
return await redisClient.set(prefix + sha256(key), value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and decrypts data from Redis.
|
||||
*/
|
||||
async function readEncrypted<T>(
|
||||
redisClient: RedisClient,
|
||||
{
|
||||
prefix,
|
||||
key,
|
||||
del = false,
|
||||
}: {
|
||||
prefix: string;
|
||||
key: string;
|
||||
del?: boolean;
|
||||
}
|
||||
): Promise<T | undefined> {
|
||||
const data = del
|
||||
? await redisClient.getDel(prefix + sha256(key))
|
||||
: await redisClient.get(prefix + sha256(key));
|
||||
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const decoded = decryptString({
|
||||
encryptedText: data,
|
||||
key: key,
|
||||
});
|
||||
|
||||
return JSON.parse(decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a client registration to Redis.
|
||||
*/
|
||||
export async function saveClientRegistration(
|
||||
redisClient: RedisClient,
|
||||
clientId: string,
|
||||
registration: OAuthClientInformationFull
|
||||
): Promise<void> {
|
||||
await redisClient.set(
|
||||
REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId,
|
||||
JSON.stringify(registration),
|
||||
{ EX: REDIS_EXPIRY_TIMES.CLIENT_REGISTRATION }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a client registration from Redis.
|
||||
*/
|
||||
export async function getClientRegistration(
|
||||
redisClient: RedisClient,
|
||||
clientId: string
|
||||
): Promise<OAuthClientInformationFull | undefined> {
|
||||
const data = await redisClient.get(REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a pending authorization to Redis.
|
||||
*/
|
||||
export async function savePendingAuthorization(
|
||||
redisClient: RedisClient,
|
||||
authorizationCode: string,
|
||||
pendingAuthorization: PendingAuthorization
|
||||
): Promise<void> {
|
||||
await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION,
|
||||
key: authorizationCode,
|
||||
data: pendingAuthorization,
|
||||
options: { EX: REDIS_EXPIRY_TIMES.PENDING_AUTHORIZATION },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a pending authorization from Redis.
|
||||
*/
|
||||
export async function readPendingAuthorization(
|
||||
redisClient: RedisClient,
|
||||
authorizationCode: string
|
||||
): Promise<PendingAuthorization | undefined> {
|
||||
return readEncrypted<PendingAuthorization>(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION,
|
||||
key: authorizationCode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an MCP installation to Redis.
|
||||
*/
|
||||
export async function saveMcpInstallation(
|
||||
redisClient: RedisClient,
|
||||
mcpAccessToken: string,
|
||||
installation: McpInstallation
|
||||
): Promise<void> {
|
||||
await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
|
||||
key: mcpAccessToken,
|
||||
data: installation,
|
||||
options: { EX: REDIS_EXPIRY_TIMES.UPSTREAM_INSTALLATION },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an MCP installation from Redis.
|
||||
*/
|
||||
export async function readMcpInstallation(
|
||||
redisClient: RedisClient,
|
||||
mcpAccessToken: string
|
||||
): Promise<McpInstallation | undefined> {
|
||||
return readEncrypted<McpInstallation>(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
|
||||
key: mcpAccessToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a refresh token to an MCP access token.
|
||||
*/
|
||||
export async function saveRefreshToken(
|
||||
redisClient: RedisClient,
|
||||
refreshToken: string,
|
||||
mcpAccessToken: string
|
||||
): Promise<void> {
|
||||
await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN,
|
||||
key: refreshToken,
|
||||
data: mcpAccessToken,
|
||||
options: { EX: REDIS_EXPIRY_TIMES.REFRESH_TOKEN },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the access token associated with a refresh token.
|
||||
*/
|
||||
export async function readRefreshToken(
|
||||
redisClient: RedisClient,
|
||||
refreshToken: string
|
||||
): Promise<string | undefined> {
|
||||
return readEncrypted<string>(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN,
|
||||
key: refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an MCP installation.
|
||||
*/
|
||||
export async function revokeMcpInstallation(
|
||||
redisClient: RedisClient,
|
||||
mcpAccessToken: string
|
||||
): Promise<void> {
|
||||
const installation = await readEncrypted<McpInstallation>(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
|
||||
key: mcpAccessToken,
|
||||
del: true,
|
||||
});
|
||||
|
||||
if (!installation) {
|
||||
return;
|
||||
}
|
||||
// In production, would revoke upstream tokens here
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a token exchange record.
|
||||
*/
|
||||
export async function saveTokenExchange(
|
||||
redisClient: RedisClient,
|
||||
authorizationCode: string,
|
||||
tokenExchange: TokenExchange
|
||||
): Promise<void> {
|
||||
await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE,
|
||||
key: authorizationCode,
|
||||
data: tokenExchange,
|
||||
options: { EX: REDIS_EXPIRY_TIMES.TOKEN_EXCHANGE },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges a temporary authorization code for an MCP access token.
|
||||
* Will only succeed the first time to prevent replay attacks.
|
||||
*/
|
||||
export async function exchangeToken(
|
||||
redisClient: RedisClient,
|
||||
authorizationCode: string
|
||||
): Promise<TokenExchange | undefined> {
|
||||
const data = await redisClient.get(
|
||||
REDIS_KEY_PREFIXES.TOKEN_EXCHANGE + sha256(authorizationCode)
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const decoded = decryptString({
|
||||
encryptedText: data,
|
||||
key: authorizationCode,
|
||||
});
|
||||
|
||||
const tokenExchange: TokenExchange = JSON.parse(decoded);
|
||||
if (tokenExchange.alreadyUsed) {
|
||||
logger.error('Duplicate use of authorization code detected; revoking tokens', undefined, {
|
||||
authorizationCode: authorizationCode.substring(0, 8) + '...'
|
||||
});
|
||||
await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken);
|
||||
throw new Error("Duplicate use of authorization code detected; tokens revoked");
|
||||
}
|
||||
|
||||
const rereadData = await saveEncrypted(redisClient, {
|
||||
prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE,
|
||||
key: authorizationCode,
|
||||
data: { ...tokenExchange, alreadyUsed: true },
|
||||
options: { KEEPTTL: true, GET: true },
|
||||
});
|
||||
|
||||
if (rereadData !== data) {
|
||||
// Data concurrently changed while we were updating it. This necessarily means a duplicate use.
|
||||
logger.error('Duplicate use of authorization code detected (concurrent update); revoking tokens', undefined, {
|
||||
authorizationCode: authorizationCode.substring(0, 8) + '...'
|
||||
});
|
||||
await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken);
|
||||
throw new Error("Duplicate use of authorization code detected; tokens revoked");
|
||||
}
|
||||
|
||||
return tokenExchange;
|
||||
}
|
||||
92
shared/types.ts
Normal file
92
shared/types.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
/**
|
||||
* Represents a pending OAuth authorization that hasn't been exchanged for tokens yet.
|
||||
* Stored in Redis with the authorization code as the key.
|
||||
*/
|
||||
export interface PendingAuthorization {
|
||||
/** The redirect URI where the client expects to receive the authorization code */
|
||||
redirectUri: string;
|
||||
/** PKCE code challenge - a derived value from the code verifier */
|
||||
codeChallenge: string;
|
||||
/** Method used to derive the code challenge (currently only S256 supported) */
|
||||
codeChallengeMethod: string;
|
||||
/** The OAuth client ID that initiated the authorization */
|
||||
clientId: string;
|
||||
/** Optional state parameter for CSRF protection */
|
||||
state?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the exchange of an authorization code for an MCP access token.
|
||||
* Used to prevent replay attacks by tracking if a code has been used.
|
||||
*/
|
||||
export interface TokenExchange {
|
||||
/** The MCP access token that was issued for this authorization code */
|
||||
mcpAccessToken: string;
|
||||
/** Whether this authorization code has already been exchanged for tokens */
|
||||
alreadyUsed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents fake upstream tokens for demonstration purposes.
|
||||
* In production, this would contain real upstream provider tokens.
|
||||
*/
|
||||
export interface FakeUpstreamInstallation {
|
||||
/** Simulated access token from the fake upstream provider */
|
||||
fakeAccessTokenForDemonstration: string;
|
||||
/** Simulated refresh token from the fake upstream provider */
|
||||
fakeRefreshTokenForDemonstration: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete installation object stored in Redis, containing both
|
||||
* upstream provider information and MCP-specific tokens.
|
||||
* This object is encrypted using the MCP access token as the key.
|
||||
*/
|
||||
export interface McpInstallation {
|
||||
/** Information from the upstream authentication provider */
|
||||
fakeUpstreamInstallation: FakeUpstreamInstallation;
|
||||
/** MCP OAuth tokens issued to the client */
|
||||
mcpTokens: OAuthTokens;
|
||||
/** The OAuth client ID associated with this installation */
|
||||
clientId: string;
|
||||
/** Unix timestamp (seconds) when the tokens were issued */
|
||||
issuedAt: number;
|
||||
/** Unique identifier for the user (not the OAuth client) */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 2.0 Token Introspection Response
|
||||
* Based on RFC 7662: https://tools.ietf.org/html/rfc7662
|
||||
* Used when validating tokens with an external authorization server.
|
||||
*/
|
||||
export interface TokenIntrospectionResponse {
|
||||
/** Whether the token is currently active */
|
||||
active: boolean;
|
||||
/** Space-separated list of scopes associated with the token */
|
||||
scope?: string;
|
||||
/** Client identifier for the OAuth client that requested the token */
|
||||
client_id?: string;
|
||||
/** Human-readable identifier for the resource owner */
|
||||
username?: string;
|
||||
/** Type of the token (e.g., "Bearer") */
|
||||
token_type?: string;
|
||||
/** Expiration time as seconds since Unix epoch */
|
||||
exp?: number;
|
||||
/** Time at which the token was issued as seconds since Unix epoch */
|
||||
iat?: number;
|
||||
/** Time before which the token is not valid as seconds since Unix epoch */
|
||||
nbf?: number;
|
||||
/** Subject identifier for the resource owner */
|
||||
sub?: string;
|
||||
/** Intended audience for the token */
|
||||
aud?: string | string[];
|
||||
/** Issuer of the token */
|
||||
iss?: string;
|
||||
/** Unique identifier for the token */
|
||||
jti?: string;
|
||||
/** Custom field for our implementation to store user ID */
|
||||
userId?: string;
|
||||
}
|
||||
181
src/auth/external-verifier.ts
Normal file
181
src/auth/external-verifier.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
||||
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import { TokenIntrospectionResponse } from '../../shared/types.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { BASE_URI } from '../config.js';
|
||||
|
||||
/**
|
||||
* Token verifier that validates tokens with an external authorization server.
|
||||
* Used when the MCP server is running in 'separate' mode.
|
||||
*/
|
||||
export class ExternalAuthVerifier implements OAuthTokenVerifier {
|
||||
// Token validation cache: token -> { authInfo, expiresAt }
|
||||
private tokenCache = new Map<string, { authInfo: AuthInfo; expiresAt: number }>();
|
||||
|
||||
// Default cache TTL: 60 seconds (conservative for security)
|
||||
private readonly defaultCacheTTL = 60 * 1000; // milliseconds
|
||||
|
||||
// The canonical URI of this MCP server for audience validation
|
||||
private readonly canonicalUri: string;
|
||||
|
||||
/**
|
||||
* Creates a new external auth verifier.
|
||||
* @param authServerUrl Base URL of the external authorization server
|
||||
* @param canonicalUri Optional canonical URI for audience validation (defaults to BASE_URI)
|
||||
*/
|
||||
constructor(private authServerUrl: string, canonicalUri?: string) {
|
||||
this.canonicalUri = canonicalUri || BASE_URI;
|
||||
// Periodically clean up expired cache entries
|
||||
setInterval(() => this.cleanupCache(), 60 * 1000); // Every minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes expired entries from the cache.
|
||||
*/
|
||||
private cleanupCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of this.tokenCache.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.tokenCache.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an access token by calling the external auth server's introspection endpoint.
|
||||
* @param token The access token to verify
|
||||
* @returns Authentication information if the token is valid
|
||||
* @throws InvalidTokenError if the token is invalid or expired
|
||||
*/
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
// Check cache first
|
||||
const cached = this.tokenCache.get(token);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
logger.debug('Token validation cache hit', {
|
||||
token: token.substring(0, 8) + '...',
|
||||
expiresIn: Math.round((cached.expiresAt - Date.now()) / 1000) + 's'
|
||||
});
|
||||
return cached.authInfo;
|
||||
}
|
||||
|
||||
try {
|
||||
// Token introspection is OAuth 2.0 standard (RFC 7662) for validating tokens
|
||||
// The auth server checks if the token is valid and returns metadata about it
|
||||
const response = await fetch(`${this.authServerUrl}/introspect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `token=${encodeURIComponent(token)}`,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// On 401/403, the token might be invalid - don't cache
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.tokenCache.delete(token); // Clear any stale cache
|
||||
}
|
||||
logger.error('Token introspection request failed', undefined, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
throw new InvalidTokenError('Token validation failed');
|
||||
}
|
||||
|
||||
const data: TokenIntrospectionResponse = await response.json();
|
||||
|
||||
// Check if token is active
|
||||
if (!data.active) {
|
||||
throw new InvalidTokenError('Token is not active');
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (data.exp && data.exp < Date.now() / 1000) {
|
||||
throw new InvalidTokenError('Token has expired');
|
||||
}
|
||||
|
||||
// Validate audience (aud) claim to ensure token is for this MCP server
|
||||
// According to MCP spec, servers MUST validate that tokens were issued specifically for them
|
||||
if (data.aud) {
|
||||
const audiences = Array.isArray(data.aud) ? data.aud : [data.aud];
|
||||
if (!audiences.includes(this.canonicalUri)) {
|
||||
logger.error('Token audience mismatch', undefined, {
|
||||
expectedAudience: this.canonicalUri,
|
||||
actualAudience: data.aud,
|
||||
});
|
||||
throw new InvalidTokenError('Token was not issued for this resource server');
|
||||
}
|
||||
} else {
|
||||
// Log warning if no audience claim present (permissive for backwards compatibility)
|
||||
logger.info('Token introspection response missing audience claim', {
|
||||
warning: true,
|
||||
tokenSub: data.sub,
|
||||
clientId: data.client_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate token is not used before its 'not before' time (nbf) if present
|
||||
if (data.nbf && data.nbf > Date.now() / 1000) {
|
||||
throw new InvalidTokenError('Token is not yet valid (nbf)');
|
||||
}
|
||||
|
||||
// Validate token was issued in the past (iat) if present
|
||||
if (data.iat && data.iat > Date.now() / 1000 + 60) { // Allow 60s clock skew
|
||||
throw new InvalidTokenError('Token issued in the future (iat)');
|
||||
}
|
||||
|
||||
// Extract user ID from standard 'sub' claim or custom 'userId' field
|
||||
const userId = data.sub || data.userId;
|
||||
if (!userId) {
|
||||
logger.info('Token introspection response missing user ID', {
|
||||
hasSub: !!data.sub,
|
||||
hasUserId: !!data.userId,
|
||||
});
|
||||
}
|
||||
|
||||
const authInfo: AuthInfo = {
|
||||
token,
|
||||
clientId: data.client_id || 'unknown',
|
||||
scopes: data.scope?.split(' ') || [], // Empty array if no scopes specified (permissive)
|
||||
expiresAt: data.exp,
|
||||
extra: {
|
||||
userId: userId || 'unknown',
|
||||
// Include other potentially useful fields
|
||||
username: data.username,
|
||||
iss: data.iss,
|
||||
aud: data.aud,
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the successful introspection result
|
||||
// Use token expiration if available, otherwise default TTL
|
||||
const cacheDuration = data.exp
|
||||
? Math.min((data.exp * 1000) - Date.now(), this.defaultCacheTTL)
|
||||
: this.defaultCacheTTL;
|
||||
|
||||
if (cacheDuration > 0) {
|
||||
this.tokenCache.set(token, {
|
||||
authInfo,
|
||||
expiresAt: Date.now() + cacheDuration
|
||||
});
|
||||
|
||||
logger.debug('Token validation cached', {
|
||||
token: token.substring(0, 8) + '...',
|
||||
cacheDuration: Math.round(cacheDuration / 1000) + 's'
|
||||
});
|
||||
}
|
||||
|
||||
return authInfo;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTokenError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('Failed to verify token with external auth server', error as Error, {
|
||||
authServerUrl: this.authServerUrl,
|
||||
});
|
||||
|
||||
// Network or other errors should be treated as invalid token
|
||||
// to prevent access with unverifiable tokens
|
||||
throw new InvalidTokenError('Unable to verify token');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Request, Response } from "express";
|
||||
import { generateMcpTokens, readPendingAuthorization, saveMcpInstallation, saveRefreshToken, saveTokenExchange } from "../services/auth.js";
|
||||
import { McpInstallation } from "../types.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
// this module has a fake upstream auth server that returns a fake auth code, it also allows you to authorize or fail
|
||||
// authorization, to test the different flows
|
||||
|
|
@ -266,6 +267,12 @@ export async function handleFakeAuthorizeRedirect(req: Request, res: Response) {
|
|||
userId, // User ID from the authorization flow
|
||||
} = req.query;
|
||||
|
||||
logger.debug('Fake auth redirect received', {
|
||||
mcpAuthorizationCode: typeof mcpAuthorizationCode === 'string' ? mcpAuthorizationCode.substring(0, 8) + '...' : mcpAuthorizationCode,
|
||||
upstreamAuthorizationCode: typeof upstreamAuthorizationCode === 'string' ? upstreamAuthorizationCode.substring(0, 8) + '...' : upstreamAuthorizationCode,
|
||||
userId
|
||||
});
|
||||
|
||||
// This is where you'd exchange the upstreamAuthorizationCode for access/refresh tokens
|
||||
// In this case, we're just going to fake it
|
||||
const upstreamTokens = await fakeUpstreamTokenExchange(upstreamAuthorizationCode as string);
|
||||
|
|
@ -276,12 +283,21 @@ export async function handleFakeAuthorizeRedirect(req: Request, res: Response) {
|
|||
}
|
||||
|
||||
const pendingAuth = await readPendingAuthorization(mcpAuthorizationCode);
|
||||
logger.debug('Reading pending authorization', {
|
||||
mcpAuthorizationCode: mcpAuthorizationCode.substring(0, 8) + '...',
|
||||
found: !!pendingAuth
|
||||
});
|
||||
|
||||
if (!pendingAuth) {
|
||||
throw new Error("No matching authorization found");
|
||||
}
|
||||
|
||||
|
||||
logger.debug('Generating MCP tokens');
|
||||
const mcpTokens = generateMcpTokens();
|
||||
logger.debug('MCP tokens generated', {
|
||||
hasAccessToken: !!mcpTokens.access_token,
|
||||
hasRefreshToken: !!mcpTokens.refresh_token
|
||||
});
|
||||
|
||||
const mcpInstallation: McpInstallation = {
|
||||
fakeUpstreamInstallation: {
|
||||
|
|
@ -294,25 +310,37 @@ export async function handleFakeAuthorizeRedirect(req: Request, res: Response) {
|
|||
userId: (userId as string) || 'anonymous-user', // Include user ID from auth flow
|
||||
}
|
||||
|
||||
logger.debug('Saving MCP installation');
|
||||
// Store the upstream authorization data
|
||||
await saveMcpInstallation(mcpTokens.access_token, mcpInstallation);
|
||||
logger.debug('MCP installation saved');
|
||||
|
||||
// Store the refresh token -> access token mapping
|
||||
if (mcpTokens.refresh_token) {
|
||||
logger.debug('Saving refresh token mapping');
|
||||
await saveRefreshToken(mcpTokens.refresh_token, mcpTokens.access_token);
|
||||
logger.debug('Refresh token mapping saved');
|
||||
}
|
||||
|
||||
logger.debug('Saving token exchange data');
|
||||
// Store the token exchange data
|
||||
await saveTokenExchange(mcpAuthorizationCode, {
|
||||
mcpAccessToken: mcpTokens.access_token,
|
||||
alreadyUsed: false,
|
||||
});
|
||||
logger.debug('Token exchange data saved');
|
||||
|
||||
// Redirect back to the original application with the authorization code and state
|
||||
const redirectUrl = pendingAuth.state ?
|
||||
`${pendingAuth.redirectUri}?code=${mcpAuthorizationCode}&state=${pendingAuth.state}` :
|
||||
`${pendingAuth.redirectUri}?code=${mcpAuthorizationCode}`;
|
||||
|
||||
logger.debug('Redirecting to callback', {
|
||||
redirectUrl,
|
||||
hasState: !!pendingAuth.state
|
||||
});
|
||||
res.redirect(redirectUrl);
|
||||
logger.debug('Redirect completed');
|
||||
};
|
||||
|
||||
function fakeUpstreamTokenExchange(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue