From 5dd0dceba7c95de3bb8db25393d0d76ba5625782 Mon Sep 17 00:00:00 2001 From: Emanuel Jesus Leiva Navarro Date: Sat, 6 Sep 2025 19:34:12 -0400 Subject: [PATCH 1/4] change: readme.md --- .gemini/settings.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .gemini/settings.json diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..e9e1234 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "GitHubApprecio": { + "command": "npx", + "args": [ + "-y", + "mcp-remote@0.1.15", + "https://mcp-github-323381129796.us-central1.run.app/sse", + "--header", + "Authorization: Bearer c50a4a55-b8c3-4f5e-ad0d-c575f7d52ed5", + "--transport", + "sse-only" + ] + } + } +} \ No newline at end of file From 7a3cdcdf71335a542603363e197f82df682296c0 Mon Sep 17 00:00:00 2001 From: Emanuel Jesus Leiva Navarro Date: Sat, 6 Sep 2025 19:35:45 -0400 Subject: [PATCH 2/4] change: readme.md --- .github/dependabot.yml | 35 ++++ .gitignore | 1 + README.md | 60 +++++- package-lock.json | 189 +++++++++++++++++- package.json | 5 + src/config/index.ts | 29 +-- src/features/issues/issues.validation.ts | 73 +++++++ .../pullRequests/pullRequest.validation.ts | 115 +++++++++++ src/middleware/auth.ts | 32 +++ src/server.ts | 173 +++++++++++++--- src/types/express.d.ts | 15 ++ 11 files changed, 674 insertions(+), 53 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 src/features/issues/issues.validation.ts create mode 100644 src/features/pullRequests/pullRequest.validation.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/types/express.d.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..00e610b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +# .github/dependabot.yml +version: 2 +updates: + # Enable version updates for npm + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 10 + reviewers: + - "JesusMaster" + assignees: + - "JesusMaster" + labels: + - "dependencies" + - "security" + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + + # Security updates (daily check) + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 5 + labels: + - "security" + - "critical" + commit-message: + prefix: "security" + include: "scope" diff --git a/.gitignore b/.gitignore index 16801b8..6803d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ dist/ coverage/ comandos.txt debug/ +.gemini/ diff --git a/README.md b/README.md index 128bab4..b60c571 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,16 @@ A Model Context Protocol (MCP) server that provides GitHub API integration throu - Configurable timeouts, CORS settings, and logging levels - Robust error handling and detailed logging +## Authentication + +This server uses API key authentication to protect its endpoints. All requests to `/mcp` and `/messages` must include an `Authorization` header with a valid bearer token. + +Example: `Authorization: Bearer your-secret-api-key` + +To set up authentication, add the following variable to your `.env` file: + +`API_KEY=your-secret-api-key` + ## Project Structure The project follows a modular, feature-based architecture. All source code is located in the `src` directory. @@ -66,6 +76,9 @@ The project follows a modular, feature-based architecture. All source code is lo # Generate a token at https://github.com/settings/tokens GITHUB_TOKEN=your_github_token_here + # Authentication + API_KEY=your-secret-api-key + # Server Port Configuration MCP_SSE_PORT=3200 @@ -82,6 +95,13 @@ The project follows a modular, feature-based architecture. All source code is lo # Set to 'true' to enable multiplexing SSE transport (handles multiple clients with a single transport) # Set to 'false' to use individual SSE transport for each client (legacy behavior) USE_MULTIPLEXING_SSE=false + + # Rate Limiting Configuration + RATE_LIMIT_WINDOW_MS=900000 # Time window for rate limiting in milliseconds (e.g., 900000 for 15 minutes) + RATE_LIMIT_MAX_REQUESTS=100 # Maximum number of requests allowed per window per IP + RATE_LIMIT_SSE_MAX=5 # Maximum number of SSE connections allowed per minute per IP + RATE_LIMIT_MESSAGES_MAX=30 # Maximum number of messages allowed per minute per IP + DEFAULT_USER_RATE_LIMIT=1000 # Default number of requests allowed per hour for a user ``` 4. Build the project: @@ -121,7 +141,27 @@ You can also run the server using Docker. ```bash docker build -t github-see-mcp-server . -docker run -d -p 3200:3200 -e MCP_TIMEOUT="180000" -e LOG_LEVEL="info" -e CORS_ALLOW_ORIGIN="*" -e GITHUB_TOKEN={YOUR_TOKEN_HERE} -e MCP_SSE_PORT="3200" -e USE_MULTIPLEXING_SSE="true" --name github-see-mcp-server github-see-mcp-server +docker run -d -p 8080:8080 \ + -e USE_MULTIPLEXING_SSE="true" \ + -e MCP_TIMEOUT="1800000" \ + -e SSE_TIMEOUT="1800000" \ + -e LOG_LEVEL="info" \ + -e CORS_ALLOW_ORIGIN="*" \ + -e GITHUB_TOKEN="{YOUR GITHUB TOKEN}" \ + -e MCP_SSE_PORT="8080" \ + -e RATE_LIMIT_WINDOW_MS="900000" \ + -e RATE_LIMIT_MAX_REQUESTS="100" \ + -e RATE_LIMIT_SSE_MAX="5" \ + -e RATE_LIMIT_MESSAGES_MAX="30" \ + -e DEFAULT_USER_RATE_LIMIT="1000" \ + -e HSTS_MAX_AGE="31536000" \ + -e CSP_REPORT_ONLY="true" \ + -e CSP_REPORT_URI="https://apprecio.cl/csp-report" \ + -e NODE_ENV="production" \ + -e DISABLE_HSTS="false" \ + -e API_KEY="{YOUR AUTHORIZATION TOKEN}" \ + --name github-see-mcp-server \ + github-see-mcp-server ``` This command: @@ -152,12 +192,14 @@ To connect to this MCP server with Claude, add the following configuration to yo ```json { "mcpServers": { - "GitHub": { + "GitHubApprecio": { "command": "npx", "args": [ "-y", "mcp-remote@0.1.15", "https://{Your domain}/sse", + "--header", + "Authorization: Bearer {YOUR AUTHORIZATION TOKEN}", "--transport", "sse-only" ] @@ -251,6 +293,18 @@ The server provides the following GitHub API tools: - `get_me` - Get details of the authenticated user +### Rate Limiting + +This server implements a robust rate limiting strategy to ensure fair usage and protect against abuse. The rate limiting is configured in `src/server.ts` and includes several layers of protection: + +- **General Limiter**: A global rate limit is applied to all incoming requests to prevent excessive traffic from a single IP address. +- **SSE Limiter**: A specific rate limit for Server-Sent Events (SSE) connections to manage real-time communication resources. +- **Message Limiter**: A rate limit on the number of messages that can be sent to the server to prevent spam and overload. +- **User-Specific Limiter**: A dynamic rate limit that can be customized for individual users, providing more flexible and granular control. +- **Critical Operations Limiter**: A stricter rate limit for critical operations such as creating repositories or merging pull requests to prevent accidental or malicious use of sensitive features. + +The rate limiting is implemented using the `express-rate-limit` library, which provides a flexible and easy-to-configure solution for Express-based applications. The configuration is managed through environment variables, allowing for easy adjustments without modifying the code. + ## Troubleshooting ### Connection Issues @@ -300,4 +354,4 @@ If you're experiencing issues with the multiplexing SSE transport: ## License -MIT \ No newline at end of file +MIT diff --git a/package-lock.json b/package-lock.json index 1f086ab..cd2a5b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,12 +24,15 @@ "@types/cors": "^2.8.19", "@types/dompurify": "^3.0.5", "@types/express": "^5.0.1", + "@types/express-rate-limit": "^5.1.3", "@types/jest": "^30.0.0", "@types/jsdom": "^21.1.7", "@types/node": "^22.14.1", "eventsource": "^4.0.0", + "express-rate-limit": "^8.1.0", "jest": "^30.0.5", "node-fetch": "^3.3.2", + "supertest": "^7.1.4", "ts-jest": "^29.4.1", "typescript": "^5.9.2" } @@ -1168,6 +1171,21 @@ "node": ">=18.0.0" } }, + "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/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1181,6 +1199,29 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1341,6 +1382,16 @@ "@types/serve-static": "*" } }, + "node_modules/@types/express-rate-limit": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-rate-limit/-/express-rate-limit-5.1.3.tgz", + "integrity": "sha512-H+TYy3K53uPU2TqPGFYaiWc2xJV6+bIFkDd/Ma2/h67Pa6ARk9kWE0p/K9OH1Okm0et9Sfm66fmXoAxsH2PHXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/express-serve-static-core": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", @@ -1886,6 +1937,13 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2371,6 +2429,16 @@ "node": ">= 0.8" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2424,6 +2492,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2575,6 +2650,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dompurify": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", @@ -2899,10 +2985,14 @@ } }, "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==", + "dev": true, "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -2934,6 +3024,13 @@ "node": ">=10.0" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3099,6 +3196,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3506,6 +3621,16 @@ "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==", + "dev": true, + "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", @@ -4518,6 +4643,16 @@ "dev": true, "license": "MIT" }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4532,6 +4667,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5671,6 +5819,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index f91713b..434de6d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "dompurify": "^3.2.6", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-rate-limit": "^8.1.0", + "helmet": "^8.1.0", "http-terminator": "^3.2.0", "jsdom": "^26.1.0", "raw-body": "^3.0.0", @@ -53,12 +55,15 @@ "@types/cors": "^2.8.19", "@types/dompurify": "^3.0.5", "@types/express": "^5.0.1", + "@types/helmet": "^0.0.48", + "@types/express-rate-limit": "^5.1.3", "@types/jest": "^30.0.0", "@types/jsdom": "^21.1.7", "@types/node": "^22.14.1", "eventsource": "^4.0.0", "jest": "^30.0.5", "node-fetch": "^3.3.2", + "supertest": "^7.1.4", "ts-jest": "^29.4.1", "typescript": "^5.9.2" } diff --git a/src/config/index.ts b/src/config/index.ts index 25d0ba4..105eb78 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,6 +1,4 @@ import dotenv from 'dotenv'; -import fs from 'fs'; -import path from 'path'; import { logger } from '#core/logger'; // Load environment variables from .env file @@ -13,24 +11,6 @@ function findAndLoadToken(): string | undefined { return token; } - try { - const possibleTokenPaths = [ - './.github_token', - path.join(process.env.HOME ?? '', '.github_token'), - path.join(process.env.HOME ?? '', '.config/github/token') - ]; - - for (const tokenPath of possibleTokenPaths) { - if (fs.existsSync(tokenPath)) { - token = fs.readFileSync(tokenPath, 'utf8').trim(); - logger.info('GitHub token loaded successfully.'); - return token; - } - } - } catch (error) { - logger.error('Error reading GitHub token file:', error); - } - logger.warn('WARNING: No GitHub token found. API requests may be rate limited or fail.'); return undefined; } @@ -41,6 +21,13 @@ export const config = { ssePort: process.env.MCP_SSE_PORT ? parseInt(process.env.MCP_SSE_PORT, 10) : 3200, githubToken: findAndLoadToken() ?? '', sseTimeout: process.env.SSE_TIMEOUT ? parseInt(process.env.SSE_TIMEOUT, 10) : 1800000, - corsAllowOrigin: process.env.CORS_ALLOW_ORIGIN ?? '*', + corsAllowOrigin: process.env.CORS_ALLOW_ORIGIN ?? '', useMultiplexing: process.env.USE_MULTIPLEXING_SSE === 'true', + apiKey: process.env.API_KEY ?? '', + // Rate Limiting Configuration + rateLimitWindowMs: process.env.RATE_LIMIT_WINDOW_MS ? parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) : 15 * 60 * 1000, // 15 minutes + rateLimitMaxRequests: process.env.RATE_LIMIT_MAX_REQUESTS ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) : 100, // 100 requests + rateLimitSseMax: process.env.RATE_LIMIT_SSE_MAX ? parseInt(process.env.RATE_LIMIT_SSE_MAX, 10) : 5, // 5 SSE connections per minute + rateLimitMessagesMax: process.env.RATE_LIMIT_MESSAGES_MAX ? parseInt(process.env.RATE_LIMIT_MESSAGES_MAX, 10) : 30, // 30 messages per minute + defaultUserRateLimit: process.env.DEFAULT_USER_RATE_LIMIT ? parseInt(process.env.DEFAULT_USER_RATE_LIMIT, 10) : 1000, // 1000 requests per hour per user }; diff --git a/src/features/issues/issues.validation.ts b/src/features/issues/issues.validation.ts new file mode 100644 index 0000000..ff71800 --- /dev/null +++ b/src/features/issues/issues.validation.ts @@ -0,0 +1,73 @@ + +import { z } from 'zod'; + +const owner = z.string().min(1, "Owner is required."); +const repo = z.string().min(1, "Repo is required."); +const issueNumber = z.number().int().positive("Issue number must be a positive integer."); +const title = z.string().min(1, "Title is required."); +const comment = z.string().min(1, "Comment is required."); +const query = z.string().min(1, "Query is required."); + +export const getIssueSchema = z.object({ + owner, + repo, + issueNumber, +}); + +export const getIssueCommentsSchema = z.object({ + owner, + repo, + issueNumber, +}); + +export const createIssueSchema = z.object({ + owner, + repo, + title, + body: z.string().optional(), + assignees: z.array(z.string()).optional(), + labels: z.array(z.string()).optional(), + milestone: z.number().int().positive().optional(), +}); + +export const addIssueCommentSchema = z.object({ + owner, + repo, + issueNumber, + comment, +}); + +export const listIssuesSchema = z.object({ + owner, + repo, + direction: z.enum(['asc', 'desc']).optional(), + labels: z.array(z.string()).optional(), + page: z.number().int().positive().optional(), + per_page: z.number().int().positive().optional(), + since: z.string().datetime().optional(), + sort: z.enum(['created', 'updated', 'comments']).optional(), + state: z.enum(['open', 'closed', 'all']).optional(), +}); + +export const updateIssueSchema = z.object({ + owner, + repo, + issueNumber, + title: z.string().optional(), + body: z.string().optional(), + assignees: z.array(z.string()).optional(), + labels: z.array(z.string()).optional(), + milestone: z.number().int().positive().optional(), + state: z.enum(['open', 'closed']).optional(), +}); + +export const searchIssuesSchema = z.object({ + owner, + repo, + q: query, + fields: z.array(z.string()).optional(), + order: z.enum(['asc', 'desc']).optional(), + page: z.number().int().positive().optional(), + per_page: z.number().int().positive().optional(), + sort: z.enum(['created', 'updated', 'comments']).optional(), +}); diff --git a/src/features/pullRequests/pullRequest.validation.ts b/src/features/pullRequests/pullRequest.validation.ts new file mode 100644 index 0000000..30bd7d5 --- /dev/null +++ b/src/features/pullRequests/pullRequest.validation.ts @@ -0,0 +1,115 @@ + +import { z } from 'zod'; + +const owner = z.string().min(1, "Owner is required."); +const repo = z.string().min(1, "Repo is required."); +const pullNumber = z.number().int().positive("Pull number must be a positive integer."); +const title = z.string().min(1, "Title is required."); +const head = z.string().min(1, "Head branch is required."); +const base = z.string().min(1, "Base branch is required."); +const path = z.string().min(1, "Path is required."); +const body = z.string().min(1, "Body is required."); + +export const getPullRequestSchema = z.object({ + owner, + repo, + pullNumber, +}); + +export const listPullRequestsSchema = z.object({ + owner, + repo, + direction: z.enum(['asc', 'desc']).optional(), + fields: z.array(z.string()).optional(), + page: z.number().int().positive().optional(), + perPage: z.number().int().positive().optional(), + sort: z.string().optional(), + state: z.string().optional(), +}); + +export const mergePullRequestSchema = z.object({ + owner, + repo, + pullNumber, + commitMessage: z.string().optional(), + commit_title: z.string().optional(), + merge_method: z.enum(['merge', 'squash', 'rebase']).optional(), +}); + +export const getPullRequestFilesSchema = z.object({ + owner, + repo, + pullNumber, +}); + +export const getPullRequestStatusSchema = z.object({ + owner, + repo, + pullNumber, +}); + +export const updatePullRequestBranchSchema = z.object({ + owner, + repo, + pullNumber, + expectedHeadSha: z.string().optional(), +}); + +export const getPullRequestCommentsSchema = z.object({ + owner, + repo, + pullNumber, +}); + +export const getPullRequestReviewsSchema = z.object({ + owner, + repo, + pullNumber, +}); + +export const createPullRequestReviewSchema = z.object({ + owner, + repo, + pullNumber, + body: z.string().optional(), + comments: z.array(z.string()).optional(), + commitId: z.string().optional(), + event: z.enum(['approve', 'request_changes', 'comment']).optional(), +}); + +export const createPullRequestSchema = z.object({ + owner, + repo, + title, + head, + base, + body: z.string().optional(), + draft: z.boolean().optional(), + maintainer_can_modify: z.boolean().optional(), +}); + +export const addPullRequestReviewCommentSchema = z.object({ + owner, + repo, + pullNumber, + body, + path, + commit_id: z.string().optional(), + in_reply_to: z.number().int().positive().optional(), + line: z.number().int().positive().optional(), + side: z.string().optional(), + start_line: z.number().int().positive().optional(), + start_side: z.string().optional(), + subject_type: z.string().optional(), +}); + +export const updatePullRequestSchema = z.object({ + owner, + repo, + pullNumber, + base: z.string().optional(), + body: z.string().optional(), + maintainer_can_modify: z.boolean().optional(), + state: z.enum(['open', 'closed']).optional(), + title: z.string().optional(), +}); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..b062e93 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,32 @@ +import { Request, Response, NextFunction } from 'express'; +import { config } from '#config/index'; +import { logger } from '#core/logger'; + +export const authenticate = (req: Request, res: Response, next: NextFunction): void => { + const authHeader = req.headers['authorization']; + + if (!authHeader) { + logger.warn('Authentication failed: No Authorization header'); + res.status(401).json({ error: 'Authorization header required' }); + return; + } + + const tokenParts = authHeader.split(' '); + + if (tokenParts.length !== 2 || tokenParts[0].toLowerCase() !== 'bearer') { + logger.warn('Authentication failed: Invalid token format'); + res.status(401).json({ error: 'Invalid token format. Expected "Bearer "' }); + return; + } + + const token = tokenParts[1]; + + if (token !== config.apiKey) { + logger.warn('Authentication failed: Invalid API key'); + res.status(401).json({ error: 'Invalid API key' }); + return; + } + + logger.info('Authentication successful'); + next(); +}; diff --git a/src/server.ts b/src/server.ts index 291aebb..2bf3fb4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,3 @@ -import express from "express"; import http from 'http'; import cors from 'cors'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -14,6 +13,93 @@ import { z } from 'zod'; // Importar configuración y logger centralizados import { config } from '#config/index'; import { logger } from '#core/logger'; +import rateLimit from 'express-rate-limit'; +import express, { Request, Response, NextFunction } from 'express'; +import { authenticate } from './middleware/auth.js'; + +const generalLimiter = rateLimit({ + windowMs: config.rateLimitWindowMs, + max: config.rateLimitMaxRequests, + message: { + error: 'Too many requests from this IP', + retryAfter: `${config.rateLimitWindowMs / 60000} minutes` + }, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + logger.warn(`Rate limit exceeded for IP: ${req.ip} on ${req.method} ${req.url}`); + res.status(429).json({ + error: 'Rate limit exceeded', + retryAfter: Math.ceil((req.rateLimit?.resetTime?.getTime() ?? Date.now()) / 1000) + }); + } +}); + +const sseLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minuto + max: config.rateLimitSseMax, + message: 'Too many SSE connections from this IP' +}); + +const messageLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minuto + max: config.rateLimitMessagesMax, + message: 'Too many messages from this IP', // This will be overridden by handler + handler: (req, res) => { + logger.warn(`Message Rate limit exceeded for IP: ${req.ip}`); + res.status(429).json({ + error: 'Rate limit exceeded', + message: 'Too many messages from this IP' + }); + } +}); + +const createUserLimiter = () => rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hora + max: (req: Request) => { + return req.user?.rateLimits?.requestsPerHour ?? config.defaultUserRateLimit; + }, + message: 'User rate limit exceeded' +}); + +const CRITICAL_TOOLS = [ + 'create_repository', + 'merge_pull_request', + 'push_files', + 'create_fork' +]; + +const isCriticalOperation = (toolName: string): boolean => { + return CRITICAL_TOOLS.includes(toolName); +}; + +const criticalOperationsLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hora + max: 10, // Solo 10 operaciones críticas por hora + message: 'Critical operation rate limit exceeded', // This will be overridden by handler + handler: (req, res) => { + logger.warn(`Critical operation rate limit exceeded for IP: ${req.ip}`); + res.status(429).json({ + error: 'Rate limit exceeded', + message: 'Critical operation rate limit exceeded' + }); + } +}); + +const rateLimitMonitor = (req: Request, res: Response, next: NextFunction) => { + const remaining = req.rateLimit?.remaining ?? 0; + const total = req.rateLimit?.limit ?? 0; + + if (remaining > 0 && remaining < total * 0.1) { + logger.warn(`Rate limit warning for ${req.ip} on ${req.method} ${req.url}: ${remaining}/${total} remaining`); + } + + if (remaining === 0) { + logger.error(`Rate limit exceeded for ${req.ip} on ${req.method} ${req.url}`); + } + + next(); +}; const sseTransports: Record = {}; let multiplexingTransport: MultiplexingSSEServerTransport | null = null; @@ -61,6 +147,10 @@ export function createServer(mcpServer: McpServer, port: number): http.Server { } } })); + + // Aplicar rate limiting general a todas las rutas + app.use(generalLimiter); + app.use(rateLimitMonitor); // Aplicar el monitor después del limiter app.get('/health', (req: express.Request, res: express.Response) => { res.status(200).json({ @@ -94,38 +184,69 @@ export function createServer(mcpServer: McpServer, port: number): http.Server { }); } - app.all('/mcp', (req: express.Request, res: express.Response) => { + app.all('/mcp', authenticate, createUserLimiter(), (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug(`New Streamable HTTP request: ${req.method} ${req.url}`); if (req.method === 'OPTIONS') { res.status(200).end(); return; } - - const requestTimeout = setTimeout(() => { - if (!res.writableEnded) { - logger.error(`Request timeout for ${req.url}`); - res.status(408).json({ - error: 'Request timeout', - message: 'The request took too long to process' - }); - } - }, config.mcpTimeout); - - mcpStreamableTransport.handleRequest(req, res, req.body) - .then(() => clearTimeout(requestTimeout)) - .catch((error) => { - clearTimeout(requestTimeout); - logger.error(`Error handling Streamable HTTP request for ${req.url}:`, error); + + // Check for critical operations + const toolName = req.body?.toolName; // Assuming toolName is in the request body + if (toolName && isCriticalOperation(toolName)) { + criticalOperationsLimiter(req, res, () => { + // Continue with the original /mcp logic after criticalOperationsLimiter + const requestTimeout = setTimeout(() => { + if (!res.writableEnded) { + logger.error(`Request timeout for ${req.url}`); + res.status(408).json({ + error: 'Request timeout', + message: 'The request took too long to process' + }); + } + }, config.mcpTimeout); + + mcpStreamableTransport.handleRequest(req, res, req.body) + .then(() => clearTimeout(requestTimeout)) + .catch((error) => { + clearTimeout(requestTimeout); + logger.error(`Error handling Streamable HTTP request for ${req.url}:`, error); + if (!res.writableEnded) { + res.status(500).json({ + error: 'Error processing Streamable HTTP request', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); + }); + } else { + // Original /mcp logic if not a critical operation + const requestTimeout = setTimeout(() => { if (!res.writableEnded) { - res.status(500).json({ - error: 'Error processing Streamable HTTP request', - message: error instanceof Error ? error.message : 'Unknown error' + logger.error(`Request timeout for ${req.url}`); + res.status(408).json({ + error: 'Request timeout', + message: 'The request took too long to process' }); } - }); + }, config.mcpTimeout); + + mcpStreamableTransport.handleRequest(req, res, req.body) + .then(() => clearTimeout(requestTimeout)) + .catch((error) => { + clearTimeout(requestTimeout); + logger.error(`Error handling Streamable HTTP request for ${req.url}:`, error); + if (!res.writableEnded) { + res.status(500).json({ + error: 'Error processing Streamable HTTP request', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); + } }); - app.get('/sse', (req: express.Request, res: express.Response) => { + app.get('/sse', sseLimiter, (req: express.Request, res: express.Response) => { logger.info('New SSE connection request'); if (config.useMultiplexing && multiplexingTransport) { const clientSessionId = randomUUID(); @@ -192,7 +313,7 @@ export function createServer(mcpServer: McpServer, port: number): http.Server { } }); - app.post('/messages', (req: express.Request, res: express.Response) => { + app.post('/messages', authenticate, messageLimiter, (req: express.Request, res: express.Response) => { try { const sessionIdSchema = z.string().uuid(); const sessionId = sessionIdSchema.parse(req.query.sessionId); @@ -232,9 +353,9 @@ export function createServer(mcpServer: McpServer, port: number): http.Server { res.status(500).json({ error: 'Internal server error' }); } }); - } catch (error) { + } catch (error:any) { logger.error('Invalid sessionId format'); - res.status(400).json({ error: 'Invalid session ID format' }); + res.status(400).json({ error: `Invalid session ID format, ${error.message}` }); } }); diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 0000000..74d67cd --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,15 @@ +declare namespace Express { + export interface Request { + rateLimit?: { + limit: number; + current: number; + remaining: number; + resetTime?: Date; + }; + user?: { + rateLimits?: { + requestsPerHour?: number; + }; + }; + } +} From 20000e2242454c513b2cc6ef0a7b9f2827359575 Mon Sep 17 00:00:00 2001 From: Emanuel Jesus Leiva Navarro Date: Sat, 6 Sep 2025 19:38:52 -0400 Subject: [PATCH 3/4] change: readme.md --- .gemini/settings.json | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .gemini/settings.json diff --git a/.gemini/settings.json b/.gemini/settings.json deleted file mode 100644 index e9e1234..0000000 --- a/.gemini/settings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "mcpServers": { - "GitHubApprecio": { - "command": "npx", - "args": [ - "-y", - "mcp-remote@0.1.15", - "https://mcp-github-323381129796.us-central1.run.app/sse", - "--header", - "Authorization: Bearer c50a4a55-b8c3-4f5e-ad0d-c575f7d52ed5", - "--transport", - "sse-only" - ] - } - } -} \ No newline at end of file From 975f11dde4ae1bcdcf55af53f393446404ca63d8 Mon Sep 17 00:00:00 2001 From: Emanuel Jesus Leiva Navarro Date: Sat, 6 Sep 2025 19:39:41 -0400 Subject: [PATCH 4/4] change: readme.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b60c571..4379e7b 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ To connect to this MCP server with Claude, add the following configuration to yo ```json { "mcpServers": { - "GitHubApprecio": { + "GitHub": { "command": "npx", "args": [ "-y",