diff --git a/README.md b/README.md index 4b2251b..8168671 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/auth-server/index.ts b/auth-server/index.ts index b801958..7e8dd69 100644 --- a/auth-server/index.ts +++ b/auth-server/index.ts @@ -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'; diff --git a/docs/streamable-http-design.md b/docs/streamable-http-design.md index 6135ee3..28a3a9d 100644 --- a/docs/streamable-http-design.md +++ b/docs/streamable-http-design.md @@ -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) diff --git a/package-lock.json b/package-lock.json index 37c9d08..73e02c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-rate-limit": "^8.0.1", "raw-body": "^3.0.0" }, "devDependencies": { @@ -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", diff --git a/package.json b/package.json index 8a3956e..fe2207b 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/index.ts b/src/index.ts index 9af7a77..aef35f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 {