Add rate limiting and improve e2e testing workflow

- Add express-rate-limit to custom endpoints for security:
  - /introspect: 100 requests per 5 minutes
  - /fakeupstreamauth/*: 20 requests per minute
  - Static files: 25 requests per 10 minutes
- Add automated e2e testing npm scripts using concurrently:
  - npm run test:e2e:integrated: Auto-starts server and runs test
  - npm run test:e2e:separate: Auto-starts both servers and runs test
- Update README with automated testing approach documentation
- Update docs/streamable-http-design.md with current file structure
- Remove redundancy in README e2e testing documentation
- Verified: All tests, linting, and e2e scripts pass with rate limiting
This commit is contained in:
Basil Hosmer 2025-09-12 00:27:07 -04:00
commit 890beed75a
No known key found for this signature in database
6 changed files with 114 additions and 35 deletions

View file

@ -210,8 +210,12 @@ npm run start:auth-server
# 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
```
### Testing with MCP Inspector
@ -283,14 +287,16 @@ The `scripts/` directory contains automated test scripts that verify the complet
#### Usage
```bash
# Test integrated mode
./scripts/test-integrated-e2e.sh
# Recommended: Automated testing (handles server lifecycle)
npm run test:e2e:integrated # Tests integrated mode
npm run test:e2e:separate # Tests separate mode
# Test separate mode
# Advanced: Manual script execution (requires manual server setup)
./scripts/test-integrated-e2e.sh
./scripts/test-separate-e2e.sh
```
**Prerequisites:** Scripts check for Redis and servers, providing setup instructions if missing.
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.
@ -405,9 +411,9 @@ This creates a logical hierarchy where each layer outlives the layers it support
│ ├── auth-core.ts # Core auth logic
│ ├── redis-auth.ts # Redis auth operations
│ └── types.ts # Shared type definitions
├── scripts/ # Automated testing scripts
│ ├── test-integrated-e2e.sh # End-to-end test for integrated mode
│ └── test-separate-e2e.sh # End-to-end test for separate mode
├── 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

View file

@ -1,5 +1,6 @@
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';
@ -59,8 +60,21 @@ app.use(mcpAuthRouter({
}
}));
// 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' }
});
// Token introspection endpoint (RFC 7662)
app.post('/introspect', express.urlencoded({ extended: false }), async (req, res) => {
app.post('/introspect', introspectRateLimit, express.urlencoded({ extended: false }), async (req, res) => {
try {
const { token } = req.body;
@ -96,8 +110,8 @@ app.post('/introspect', express.urlencoded({ extended: false }), async (req, res
});
// Fake upstream auth endpoints (for user authentication simulation)
app.get('/fakeupstreamauth/authorize', cors(), handleFakeAuthorize);
app.get('/fakeupstreamauth/callback', cors(), handleFakeAuthorizeRedirect);
app.get('/fakeupstreamauth/authorize', fakeAuthRateLimit, cors(), handleFakeAuthorize);
app.get('/fakeupstreamauth/callback', fakeAuthRateLimit, cors(), handleFakeAuthorizeRedirect);
// Static assets (for auth page styling)
import path from 'path';

View file

@ -1,30 +1,45 @@
# Design Document: Streamable HTTP Transport Implementation
# Design Document: Transport Implementation for Example Remote Server
## Current Implementation
### Dual Transport Architecture
The example remote server implements both MCP transport methods to support different client needs.
The example remote server implements both transport methods:
### 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:
**Legacy SSE Transport:**
1. **SSE Endpoint**: `/sse` - Creates SSE connection using `SSEServerTransport`
2. **Message Endpoint**: `/message` - Receives POST requests and forwards them via Redis
**Modern Streamable HTTP Transport:**
1. **Unified Endpoint**: `/mcp` - Handles GET, POST, DELETE with `StreamableHTTPServerTransport`
2. **Stateful Sessions**: Requires initialization and session ID tracking
3. **SSE Response Format**: Returns results via Server-Sent Events streams
**Shared Infrastructure:**
1. **Redis Integration**: Messages published/subscribed through Redis channels using session IDs
2. **Auth**: Uses `requireBearerAuth` middleware with mode-dependent auth providers
3. **Session Management**: Session ownership tracked via Redis for multi-user isolation
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:156-162` - SSE and Streamable HTTP endpoints with auth
- `/src/index.ts:236` - SSE endpoint registration
- `/src/handlers/sse.ts` - SSE connection handler with Redis integration
- `/src/handlers/shttp.ts` - Streamable HTTP handler
- `/src/services/mcp.ts` - MCP server implementation with tools, resources, prompts
- `/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)

34
package-lock.json generated
View file

@ -13,6 +13,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^8.0.1",
"raw-body": "^3.0.0"
},
"devDependencies": {
@ -1777,6 +1778,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/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",
@ -3850,10 +3866,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.0.1",
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/express-rate-limit/-/express-rate-limit-8.0.1.tgz",
"integrity": "sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
@ -4455,6 +4474,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/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",

View file

@ -20,7 +20,9 @@
"lint": "eslint src/ auth-server/",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"test:integrated": "AUTH_MODE=integrated npm test",
"test:separate": "AUTH_MODE=separate npm test"
"test:separate": "AUTH_MODE=separate npm test",
"test:e2e:integrated": "concurrently --kill-others --success first \"npm run dev:integrated\" \"sleep 4 && ./scripts/test-integrated-e2e.sh\"",
"test:e2e:separate": "concurrently --kill-others --success first \"npm run dev:with-separate-auth\" \"sleep 6 && ./scripts/test-separate-e2e.sh\""
},
"devDependencies": {
"@eslint/js": "^9.15.0",
@ -42,6 +44,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": {

View file

@ -1,6 +1,7 @@
import { BearerAuthMiddlewareOptions, requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import cors from "cors";
import rateLimit from "express-rate-limit";
import express from "express";
import path from "path";
import { fileURLToPath } from "url";
@ -122,6 +123,18 @@ app.use(baseSecurityHeaders);
// Enable CORS pre-flight requests
app.options('*', cors(corsOptions));
// Rate limiting for custom endpoints
const fakeAuthRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
limit: 20, // 20 auth attempts per minute
message: { error: 'too_many_requests', error_description: 'Authentication rate limit exceeded' }
});
const 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' }
});
// Mode-dependent auth configuration
let bearerAuth: express.RequestHandler;
@ -242,12 +255,12 @@ app.post("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTT
app.delete("/mcp", cors(corsOptions), bearerAuth, authContext, handleStreamableHTTP);
// Static assets
app.get("/mcp-logo.png", (req, res) => {
app.get("/mcp-logo.png", staticFileRateLimit, (req, res) => {
const logoPath = path.join(__dirname, "static", "mcp.png");
res.sendFile(logoPath);
});
app.get("/styles.css", (req, res) => {
app.get("/styles.css", staticFileRateLimit, (req, res) => {
const cssPath = path.join(__dirname, "static", "styles.css");
res.setHeader('Content-Type', 'text/css');
res.sendFile(cssPath);
@ -261,8 +274,8 @@ app.get("/", (req, res) => {
// Upstream auth routes (only in integrated mode)
if (AUTH_MODE === 'integrated') {
app.get("/fakeupstreamauth/authorize", cors(corsOptions), handleFakeAuthorize);
app.get("/fakeupstreamauth/callback", cors(corsOptions), handleFakeAuthorizeRedirect);
app.get("/fakeupstreamauth/authorize", fakeAuthRateLimit, cors(corsOptions), handleFakeAuthorize);
app.get("/fakeupstreamauth/callback", fakeAuthRateLimit, cors(corsOptions), handleFakeAuthorizeRedirect);
}
try {