From 8968a96a986994c544a8d8fa6ba4d26a9c905d10 Mon Sep 17 00:00:00 2001 From: cyanheads Date: Sat, 25 Jan 2025 18:58:47 -0800 Subject: [PATCH] feat: Initial implementation of mentor-mcp-server Implements a Model Context Protocol (MCP) server that provides AI-powered mentorship and feedback tools. Core Features: - TypeScript implementation with ES modules - MCP server setup with stdio transport - Deepseek API integration with rate limiting and retry logic - Secure environment configuration management - Comprehensive utility functions for file and prompt handling Tools: - second-opinion: Provides critical analysis of user requests - Input validation - Rate limiting - Error handling - Sanitized inputs - Structured prompt templates Infrastructure: - Atomic design directory structure - Type-safe implementation - Proper error handling - Security measures (input sanitization, rate limiting) - Development scripts and configuration Security Features: - API key protection - Input sanitization - Rate limiting - Error message sanitization - Secure file path validation This implementation follows MCP best practices and provides a foundation for adding additional tools: - code-review - design-critique - writing-feedback - brainstorm-enhancements --- .env.example | 10 + .gitignore | 171 +++++++++++ package-lock.json | 329 +++++++++++++++++++++ package.json | 24 ++ src/api/deepseek/deepseek.ts | 233 +++++++++++++++ src/config.ts | 27 ++ src/index.ts | 2 + src/server.ts | 183 ++++++++++++ src/tools/second-opinion/index.ts | 1 + src/tools/second-opinion/second-opinion.ts | 113 +++++++ src/types/index.ts | 57 ++++ src/utils/file.ts | 88 ++++++ src/utils/prompt.ts | 113 +++++++ tsconfig.json | 16 + 14 files changed, 1367 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/api/deepseek/deepseek.ts create mode 100644 src/config.ts create mode 100644 src/index.ts create mode 100644 src/server.ts create mode 100644 src/tools/second-opinion/index.ts create mode 100644 src/tools/second-opinion/second-opinion.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/file.ts create mode 100644 src/utils/prompt.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4c30a1c --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Deepseek API Configuration +DEEPSEEK_API_KEY=your_deepseek_api_key_here +DEEPSEEK_API_BASE_URL=https://api.deepseek.com/v1 +DEEPSEEK_MODEL=deepseek-coder +DEEPSEEK_MAX_RETRIES=3 +DEEPSEEK_TIMEOUT=30000 + +# Server Configuration +SERVER_NAME=mentor-mcp-server +SERVER_VERSION=1.0.0 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf0ade4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,171 @@ +# Operating System Files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE and Editor Files +.idea/ +.vscode/ +*.swp +*.swo +*~ +*.sublime-workspace +*.sublime-project + +# TypeScript +*.tsbuildinfo +.tscache/ +*.js.map +*.tgz +.npm +.eslintcache +.rollup.cache +*.mjs.map +*.cjs.map +!*.d.ts.template +yarn.lock +.pnp.js +.pnp.cjs +.pnp.mjs +.pnp.json +.pnp.ts + +# Demo and Example Directories +demo/ +demos/ +example/ +examples/ +samples/ +.sample-env +sample.* +!sample.template.* + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.venv +venv/ +ENV/ + +# Java +*.class +*.log +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* +target/ +.gradle/ +build/ + +# Ruby +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/spec/examples.txt +/test/tmp/ +/test/version_tmp/ +/tmp/ +.byebug_history + +# Compiled Files +*.com +*.class +*.dll +*.exe +*.o +*.so + +# Package Files +*.7z +*.dmg +*.gz +*.iso +*.rar +*.tar +*.zip + +# Logs and Databases +*.log +*.sql +*.sqlite +*.sqlite3 + +# Build and Distribution +dist/ +build/ +out/ + +# Documentation +docs/_build/ +doc/api/ + +# Dependency Directories +jspm_packages/ +bower_components/ + +# Testing +coverage/ +.nyc_output/ + +# Cache +.cache/ +.parcel-cache/ + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +*.bak +*.swp +*.swo +*~ +.history/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..016b47a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,329 @@ +{ + "name": "mentor-mcp-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mentor-mcp-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.4.1", + "@types/node": "^22.10.10", + "axios": "^1.7.9", + "dotenv": "^16.4.7", + "typescript": "^5.7.3" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.4.1.tgz", + "integrity": "sha512-wS6YC4lkUZ9QpP+/7NBTlVNiEvsnyl0xF7rRusLF+RsG0xDPc/zWR7fEEyhKnnNutGsDAZh59l/AeoWGwIb1+g==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "eventsource": "^3.0.2", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.10.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz", + "integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/eventsource": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.2.tgz", + "integrity": "sha512-YolzkJNxsTL3tCJMWFxpxtG2sCjbZ4LQUBUrkdaJK0ub0p6lmJt+2+1SwhKjLc652lpH9L/79Ptez972H9tphw==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", + "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.1.tgz", + "integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8dee2d9 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "mentor-mcp-server", + "version": "1.0.0", + "description": "MCP server that provides insightful feedback and guidance on various types of user requests", + "type": "module", + "main": "build/index.js", + "scripts": { + "build": "tsc && chmod +x build/index.js", + "start": "node build/index.js", + "dev": "tsc -w", + "clean": "rm -rf build", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": ["mcp", "mentor", "feedback", "llm"], + "author": "", + "license": "ISC", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.4.1", + "@types/node": "^22.10.10", + "axios": "^1.7.9", + "dotenv": "^16.4.7", + "typescript": "^5.7.3" + } +} diff --git a/src/api/deepseek/deepseek.ts b/src/api/deepseek/deepseek.ts new file mode 100644 index 0000000..387433a --- /dev/null +++ b/src/api/deepseek/deepseek.ts @@ -0,0 +1,233 @@ +import axios, { AxiosError, AxiosInstance } from 'axios'; +import type { LLMResponse } from '../../types/index.js'; +import { config } from '../../config.js'; +import { sanitizeInput } from '../../utils/prompt.js'; + +/** + * Interface for Deepseek API message format + */ +interface DeepseekMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +/** + * Interface for Deepseek API response format + */ +interface DeepseekResponse { + choices: Array<{ + message: { + content: string; + }; + finish_reason?: string; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +/** + * Interface for API error response + */ +interface ApiErrorResponse { + message?: string; + error?: string; +} + +/** + * Rate limiter implementation using token bucket algorithm + */ +class RateLimiter { + private tokens: number; + private readonly maxTokens: number; + private readonly refillRate: number; + private lastRefill: number; + + constructor(maxTokens: number = 50, refillRate: number = 10) { + this.tokens = maxTokens; + this.maxTokens = maxTokens; + this.refillRate = refillRate; // tokens per second + this.lastRefill = Date.now(); + } + + private refillTokens(): void { + const now = Date.now(); + const timePassed = (now - this.lastRefill) / 1000; // convert to seconds + const tokensToAdd = Math.floor(timePassed * this.refillRate); + + this.tokens = Math.min( + this.maxTokens, + this.tokens + tokensToAdd + ); + this.lastRefill = now; + } + + public tryConsume(): boolean { + this.refillTokens(); + if (this.tokens >= 1) { + this.tokens -= 1; + return true; + } + return false; + } +} + +/** + * Deepseek API client class + */ +class DeepseekClient { + private readonly axiosInstance: AxiosInstance; + private readonly rateLimiter: RateLimiter; + + constructor() { + this.axiosInstance = axios.create({ + baseURL: config.api.baseUrl, + timeout: config.api.timeout, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.api.apiKey}`, + }, + }); + + this.rateLimiter = new RateLimiter( + 50, // max 50 requests + 10 // refill 10 tokens per second + ); + + // Add response interceptor for error handling + this.axiosInstance.interceptors.response.use( + response => response, + this.handleApiError.bind(this) + ); + } + + /** + * Handles API errors and transforms them into appropriate responses + */ + private async handleApiError(error: AxiosError): Promise { + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + const status = error.response.status; + const message = error.response.data?.message || error.response.data?.error || error.message; + + switch (status) { + case 401: + throw new Error('Authentication failed: Invalid API key'); + case 429: + throw new Error('Rate limit exceeded. Please try again later.'); + case 500: + throw new Error('Deepseek API server error. Please try again later.'); + default: + throw new Error(`API error: ${message}`); + } + } else if (error.request) { + // The request was made but no response was received + throw new Error('No response received from Deepseek API'); + } else { + // Something happened in setting up the request + throw new Error(`Error setting up request: ${error.message}`); + } + } + + /** + * Makes a call to the Deepseek API with exponential backoff retry + */ + private async retryWithExponentialBackoff( + operation: () => Promise, + maxRetries: number = config.api.maxRetries, + baseDelay: number = 1000 + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error as Error; + if (error instanceof AxiosError && error.response?.status === 429) { + const delay = baseDelay * Math.pow(2, attempt); + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + throw error; + } + } + + throw lastError || new Error('Max retries exceeded'); + } + + /** + * Makes a call to the Deepseek API + */ + public async makeApiCall( + prompt: string, + systemPrompt: string = 'You are a helpful AI assistant.' + ): Promise { + // Check rate limit + if (!this.rateLimiter.tryConsume()) { + return { + text: '', + isError: true, + errorMessage: 'Rate limit exceeded. Please try again later.' + }; + } + + try { + // Sanitize inputs + const sanitizedPrompt = sanitizeInput(prompt); + const sanitizedSystemPrompt = sanitizeInput(systemPrompt); + + const messages: DeepseekMessage[] = [ + { + role: 'system', + content: sanitizedSystemPrompt, + }, + { + role: 'user', + content: sanitizedPrompt, + }, + ]; + + const response = await this.retryWithExponentialBackoff(async () => { + const result = await this.axiosInstance.post('/chat/completions', { + model: config.api.model, + messages, + temperature: 0.7, + max_tokens: 2048, + }); + return result; + }); + + return { + text: response.data.choices[0].message.content, + isError: false, + }; + } catch (error) { + console.error('Deepseek API error:', error); + return { + text: '', + isError: true, + errorMessage: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Checks if a request can be made (for external rate limit checking) + */ + public checkRateLimit(): boolean { + return this.rateLimiter.tryConsume(); + } +} + +// Export a singleton instance +export const deepseekClient = new DeepseekClient(); + +// Export the main interface functions +export const makeDeepseekAPICall = (prompt: string, systemPrompt?: string) => + deepseekClient.makeApiCall(prompt, systemPrompt); + +export const checkRateLimit = () => deepseekClient.checkRateLimit(); \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..9ad7764 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,27 @@ +import dotenv from 'dotenv'; +import type { ServerConfig, APIConfig } from './types/index.js'; + +// Load environment variables +dotenv.config(); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +const apiConfig: APIConfig = { + apiKey: requireEnv('DEEPSEEK_API_KEY'), + baseUrl: process.env.DEEPSEEK_API_BASE_URL || 'https://api.deepseek.com/v1', + model: process.env.DEEPSEEK_MODEL || 'deepseek-coder', + maxRetries: parseInt(process.env.DEEPSEEK_MAX_RETRIES || '3', 10), + timeout: parseInt(process.env.DEEPSEEK_TIMEOUT || '30000', 10), +}; + +export const config: ServerConfig = { + serverName: process.env.SERVER_NAME || 'mentor-mcp-server', + serverVersion: process.env.SERVER_VERSION || '1.0.0', + api: apiConfig, +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a6e42df --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import './server.js'; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..1c53252 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,183 @@ +#!/usr/bin/env node +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + ErrorCode, + McpError, +} from "@modelcontextprotocol/sdk/types.js"; +import { config } from "./config.js"; +import * as secondOpinion from "./tools/second-opinion/index.js"; +import { isValidToolArgs, SecondOpinionArgs } from "./types/index.js"; + +/** + * MentorServer class implements an MCP server that provides mentorship and feedback tools. + * It uses the Deepseek API to generate insightful responses for various types of requests. + */ +class MentorServer { + private server: Server; + private isShuttingDown: boolean = false; + + constructor() { + // Initialize the MCP server with basic configuration + this.server = new Server( + { + name: config.serverName, + version: config.serverVersion, + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupRequestHandlers(); + this.setupErrorHandler(); + this.setupSignalHandlers(); + } + + /** + * Sets up request handlers for the MCP server + */ + private setupRequestHandlers(): void { + // Register tool listing handler + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [secondOpinion.definition], + })); + + // Register tool execution handler + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + // Check if server is shutting down + if (this.isShuttingDown) { + throw new McpError( + ErrorCode.InternalError, + "Server is shutting down" + ); + } + + const { name, arguments: args } = request.params; + + try { + switch (name) { + case "second_opinion": { + if (!args || !isValidToolArgs(args, ["user_request"])) { + throw new McpError( + ErrorCode.InvalidParams, + "Missing required parameter: user_request" + ); + } + + const userRequest = args.user_request; + if (typeof userRequest !== 'string') { + throw new McpError( + ErrorCode.InvalidParams, + "Parameter 'user_request' must be a string" + ); + } + + const toolArgs: SecondOpinionArgs = { + user_request: userRequest + }; + + return await secondOpinion.handler(toolArgs); + } + default: + throw new McpError( + ErrorCode.MethodNotFound, + `Tool not found: ${name}` + ); + } + } catch (error) { + // Handle errors that aren't already McpErrors + if (!(error instanceof McpError)) { + console.error(`Error executing tool ${name}:`, error); + throw new McpError( + ErrorCode.InternalError, + `Internal server error while executing tool ${name}` + ); + } + throw error; + } + }); + } + + /** + * Sets up the error handler for the MCP server + */ + private setupErrorHandler(): void { + this.server.onerror = (error) => { + console.error("[MCP Error]", error); + }; + } + + /** + * Sets up signal handlers for graceful shutdown + */ + private setupSignalHandlers(): void { + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; + + signals.forEach(signal => { + process.on(signal, async () => { + await this.stop(); + process.exit(0); + }); + }); + + // Handle uncaught exceptions + process.on('uncaughtException', async (error) => { + console.error('Uncaught exception:', error); + await this.stop(); + process.exit(1); + }); + + // Handle unhandled promise rejections + process.on('unhandledRejection', async (reason) => { + console.error('Unhandled rejection:', reason); + await this.stop(); + process.exit(1); + }); + } + + /** + * Starts the MCP server + */ + async start(): Promise { + try { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error("Mentor MCP server running on stdio"); + } catch (error) { + console.error("Failed to start server:", error); + process.exit(1); + } + } + + /** + * Stops the MCP server gracefully + */ + async stop(): Promise { + if (this.isShuttingDown) { + return; + } + + this.isShuttingDown = true; + console.error("Shutting down server..."); + + try { + await this.server.close(); + console.error("Server stopped"); + } catch (error) { + console.error("Error during shutdown:", error); + process.exit(1); + } + } +} + +// Create and start the server +const server = new MentorServer(); +server.start().catch((error) => { + console.error("Server startup error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/tools/second-opinion/index.ts b/src/tools/second-opinion/index.ts new file mode 100644 index 0000000..fa3dcb1 --- /dev/null +++ b/src/tools/second-opinion/index.ts @@ -0,0 +1 @@ +export { definition, handler } from './second-opinion.js'; \ No newline at end of file diff --git a/src/tools/second-opinion/second-opinion.ts b/src/tools/second-opinion/second-opinion.ts new file mode 100644 index 0000000..eb96dcb --- /dev/null +++ b/src/tools/second-opinion/second-opinion.ts @@ -0,0 +1,113 @@ +import type { ToolDefinition, SecondOpinionArgs } from '../../types/index.js'; +import { makeDeepseekAPICall, checkRateLimit } from '../../api/deepseek/deepseek.js'; +import { createPrompt, PromptTemplate } from '../../utils/prompt.js'; + +/** + * System prompt for the second opinion tool + */ +const SYSTEM_PROMPT = `You are an expert mentor providing second opinions on user requests. +Your role is to analyze requests and identify critical considerations that might be overlooked. +Focus on modern practices, potential pitfalls, and important factors for success. + +Format your response as a clear, non-numbered list of points, focusing on what's most relevant +to the specific request. Each point should be concise but informative.`; + +/** + * Prompt template for generating second opinions + */ +const PROMPT_TEMPLATE: PromptTemplate = { + template: `User Request: {user_request} + +Task: List the critical considerations for this user request: +- Core problem/concept to address +- Common pitfalls or edge cases +- Security/performance implications (if applicable) +- Prerequisites or dependencies +- Resource constraints and requirements to consider +- Advanced topics that could add value +- Maintenance/scalability factors + +Reminder: You are not fulfilling the user request, only generating a plain text, non-numbered list of non-obvious points of consideration. + +Format: Brief, clear points in plain text. Focus on what's most relevant to the specific request.`, + systemPrompt: SYSTEM_PROMPT +}; + +/** + * Tool definition for the second opinion tool + */ +export const definition: ToolDefinition = { + name: 'second_opinion', + description: 'Provides a second opinion on a user\'s request by analyzing it with an LLM and listing critical considerations.', + inputSchema: { + type: 'object', + properties: { + user_request: { + type: 'string', + description: 'The user\'s original request (e.g., \'Explain Python to me\' or \'Build a login system\')', + }, + }, + required: ['user_request'], + }, +}; + +/** + * Handles the execution of the second opinion tool + * + * @param args - Tool arguments containing the user request + * @returns Tool response containing the generated second opinion + */ +export async function handler(args: SecondOpinionArgs) { + // Check rate limit first + if (!checkRateLimit()) { + return { + content: [ + { + type: 'text', + text: 'Rate limit exceeded. Please try again later.', + }, + ], + }; + } + + try { + // Create the complete prompt using the template + const prompt = createPrompt(PROMPT_TEMPLATE, { + user_request: args.user_request + }); + + // Make the API call + const response = await makeDeepseekAPICall(prompt, SYSTEM_PROMPT); + + if (response.isError) { + return { + content: [ + { + type: 'text', + text: `Error generating second opinion: ${response.errorMessage || 'Unknown error'}`, + }, + ], + }; + } + + // Format the response + return { + content: [ + { + type: 'text', + text: `\n${response.text}\n`, + }, + ], + }; + } catch (error) { + console.error('Second opinion tool error:', error); + return { + content: [ + { + type: 'text', + text: `Error processing request: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + ], + }; + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..21ba9e0 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,57 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +export interface ToolDefinition extends Tool { + name: string; + description: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + oneOf?: Array<{ required: string[] }>; + }; +} + +export interface ToolContent { + type: string; + text: string; +} + +export interface ToolResponse { + content: ToolContent[]; + isError?: boolean; +} + +export interface LLMResponse { + text: string; + isError: boolean; + errorMessage?: string; +} + +export interface FileValidationResult { + isValid: boolean; + error?: string; +} + +export interface APIConfig { + apiKey: string; + baseUrl: string; + model: string; + maxRetries: number; + timeout: number; +} + +export interface ServerConfig { + serverName: string; + serverVersion: string; + api: APIConfig; +} + +export interface SecondOpinionArgs { + user_request: string; +} + +// Type guard for tool arguments +export function isValidToolArgs(args: Record | undefined, required: string[]): boolean { + if (!args) return false; + return required.every(key => key in args && args[key] !== undefined); +} \ No newline at end of file diff --git a/src/utils/file.ts b/src/utils/file.ts new file mode 100644 index 0000000..6c98195 --- /dev/null +++ b/src/utils/file.ts @@ -0,0 +1,88 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { FileValidationResult } from '../types/index.js'; + +/** + * Validates a file path to ensure it's safe to access. + * Prevents directory traversal attacks and ensures the path is within allowed boundaries. + * + * @param filePath - The file path to validate + * @returns FileValidationResult indicating if the path is valid and any error message + */ +export function validateFilePath(filePath: string): FileValidationResult { + try { + // Resolve the absolute path + const resolvedPath = path.resolve(filePath); + + // Get the server's root directory (two levels up from utils) + const serverRoot = path.resolve(__dirname, '../../'); + + // Check if the path is within the server root + if (!resolvedPath.startsWith(serverRoot)) { + return { + isValid: false, + error: 'Access denied: Path is outside the server root directory' + }; + } + + // Check if the file exists + if (!fs.existsSync(resolvedPath)) { + return { + isValid: false, + error: 'File not found' + }; + } + + // Check if we have read permissions + try { + fs.accessSync(resolvedPath, fs.constants.R_OK); + } catch { + return { + isValid: false, + error: 'Permission denied: Cannot read file' + }; + } + + return { isValid: true }; + } catch (error) { + return { + isValid: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +/** + * Safely reads the content of a file after validation. + * + * @param filePath - The path to the file to read + * @returns Promise resolving to the file content + * @throws Error if file validation fails or reading fails + */ +export async function readFileContent(filePath: string): Promise { + const validation = validateFilePath(filePath); + if (!validation.isValid) { + throw new Error(validation.error); + } + + try { + return await fs.promises.readFile(filePath, 'utf-8'); + } catch (error) { + throw new Error(`Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Checks if a file exists and is accessible. + * + * @param filePath - The path to check + * @returns boolean indicating if the file exists and is accessible + */ +export function fileExists(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK); + return true; + } catch { + return false; + } +} \ No newline at end of file diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts new file mode 100644 index 0000000..7acadfd --- /dev/null +++ b/src/utils/prompt.ts @@ -0,0 +1,113 @@ +/** + * Represents a prompt template with placeholders for dynamic values. + */ +export interface PromptTemplate { + template: string; + systemPrompt?: string; +} + +/** + * Represents variables that can be used in a prompt template. + */ +export type PromptVariables = Record; + +/** + * Fills a prompt template with provided variables. + * + * @param template - The prompt template containing placeholders + * @param variables - Object containing values for the placeholders + * @returns The filled prompt string + * @throws Error if required variables are missing + */ +export function fillPromptTemplate(template: string, variables: PromptVariables): string { + // Find all placeholders in the template + const placeholders = template.match(/\{([^}]+)\}/g) || []; + + // Create a map of required variables + const requiredVars = new Set( + placeholders.map(p => p.slice(1, -1)) // Remove { and } + ); + + // Check if all required variables are provided + const missingVars = Array.from(requiredVars).filter(v => !(v in variables)); + if (missingVars.length > 0) { + throw new Error(`Missing required variables: ${missingVars.join(', ')}`); + } + + // Replace all placeholders with their values + return template.replace(/\{([^}]+)\}/g, (_, key) => { + const value = variables[key]; + return String(value); + }); +} + +/** + * Sanitizes user input to prevent prompt injection attacks. + * + * @param input - The user input to sanitize + * @returns Sanitized input string + */ +export function sanitizeInput(input: string): string { + // Remove any attempt to break out of the current context + return input + .replace(/```/g, '\\`\\`\\`') // Escape code blocks + .replace(/\{/g, '\\{') // Escape template literals + .replace(/\}/g, '\\}') + .trim(); +} + +/** + * Creates a complete prompt by combining system prompt, template, and variables. + * + * @param promptTemplate - The prompt template object + * @param variables - Variables to fill in the template + * @returns Complete prompt string + */ +export function createPrompt( + promptTemplate: PromptTemplate, + variables: PromptVariables +): string { + const filledTemplate = fillPromptTemplate(promptTemplate.template, variables); + + if (promptTemplate.systemPrompt) { + return `${promptTemplate.systemPrompt}\n\n${filledTemplate}`; + } + + return filledTemplate; +} + +/** + * Validates a prompt to ensure it doesn't exceed maximum length. + * + * @param prompt - The prompt to validate + * @param maxLength - Maximum allowed length (default: 4000) + * @returns boolean indicating if the prompt is valid + */ +export function validatePrompt(prompt: string, maxLength: number = 4000): boolean { + return prompt.length <= maxLength; +} + +/** + * Truncates a prompt to fit within maximum length while preserving meaning. + * + * @param prompt - The prompt to truncate + * @param maxLength - Maximum allowed length + * @returns Truncated prompt string + */ +export function truncatePrompt(prompt: string, maxLength: number = 4000): string { + if (prompt.length <= maxLength) { + return prompt; + } + + // Try to truncate at a sentence boundary + const truncated = prompt.slice(0, maxLength); + const lastSentence = truncated.match(/.*[.!?]/); + + if (lastSentence) { + return lastSentence[0]; + } + + // If no sentence boundary found, truncate at last complete word + const lastSpace = truncated.lastIndexOf(' '); + return truncated.slice(0, lastSpace) + '...'; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..909fe5f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build"] +}