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
This commit is contained in:
cyanheads
2025-01-25 18:58:47 -08:00
commit 8968a96a98
14 changed files with 1367 additions and 0 deletions

10
.env.example Normal file
View File

@@ -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

171
.gitignore vendored Normal file
View File

@@ -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/

329
package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

24
package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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<ApiErrorResponse>): Promise<never> {
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<T>(
operation: () => Promise<T>,
maxRetries: number = config.api.maxRetries,
baseDelay: number = 1000
): Promise<T> {
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<LLMResponse> {
// 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<DeepseekResponse>('/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();

27
src/config.ts Normal file
View File

@@ -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,
};

2
src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
import './server.js';

183
src/server.ts Normal file
View File

@@ -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<void> {
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<void> {
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);
});

View File

@@ -0,0 +1 @@
export { definition, handler } from './second-opinion.js';

View File

@@ -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: `<internal_thoughts>\n${response.text}\n</internal_thoughts>`,
},
],
};
} catch (error) {
console.error('Second opinion tool error:', error);
return {
content: [
{
type: 'text',
text: `Error processing request: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}

57
src/types/index.ts Normal file
View File

@@ -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<string, any>;
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<string, unknown> | undefined, required: string[]): boolean {
if (!args) return false;
return required.every(key => key in args && args[key] !== undefined);
}

88
src/utils/file.ts Normal file
View File

@@ -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<string> {
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;
}
}

113
src/utils/prompt.ts Normal file
View File

@@ -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<string, string | number | boolean>;
/**
* 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) + '...';
}

16
tsconfig.json Normal file
View File

@@ -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"]
}