feat: Major refactoring and improvements v2.11.0

## 🚀 Major Improvements

### Docker Environment Simplification
- **BREAKING**: Simplified Docker configuration by auto-detecting sandbox from WORKSPACE_ROOT
- Removed redundant MCP_PROJECT_ROOT requirement for Docker setups
- Updated all Docker config examples and setup scripts
- Added security validation for dangerous WORKSPACE_ROOT paths

### Security Enhancements
- **CRITICAL**: Fixed insecure PROJECT_ROOT fallback to use current directory instead of home
- Enhanced path validation with proper Docker environment detection
- Removed information disclosure in error messages
- Strengthened symlink and path traversal protection

### File Handling Optimization
- **PERFORMANCE**: Optimized read_files() to return content only (removed summary)
- Unified file reading across all tools using standardized file_utils routines
- Fixed review_changes tool to use consistent file loading patterns
- Improved token management and reduced unnecessary processing

### Tool Improvements
- **UX**: Enhanced ReviewCodeTool to require user context for targeted reviews
- Removed deprecated _get_secure_container_path function and _sanitize_filename
- Standardized file access patterns across analyze, review_changes, and other tools
- Added contextual prompting to align reviews with user expectations

### Code Quality & Testing
- Updated all tests for new function signatures and requirements
- Added comprehensive Docker path integration tests
- Achieved 100% test coverage (95 tests passing)
- Full compliance with ruff, black, and isort linting standards

### Configuration & Deployment
- Added pyproject.toml for modern Python packaging
- Streamlined Docker setup removing redundant environment variables
- Updated setup scripts across all platforms (Windows, macOS, Linux)
- Improved error handling and validation throughout

## 🔧 Technical Changes

- **Removed**: `_get_secure_container_path()`, `_sanitize_filename()`, unused SANDBOX_MODE
- **Enhanced**: Path translation, security validation, token management
- **Standardized**: File reading patterns, error handling, Docker detection
- **Updated**: All tool prompts for better context alignment

## 🛡️ Security Notes

This release significantly improves the security posture by:
- Eliminating broad filesystem access defaults
- Adding validation for Docker environment variables
- Removing information disclosure in error paths
- Strengthening path traversal and symlink protections

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Fahad
2025-06-10 09:50:05 +04:00
parent 7ea790ef88
commit 27add4d05d
34 changed files with 593 additions and 759 deletions

View File

@@ -4,7 +4,14 @@
"mcp__gemini__review_code", "mcp__gemini__review_code",
"mcp__gemini__chat", "mcp__gemini__chat",
"mcp__gemini__analyze", "mcp__gemini__analyze",
"Bash(find:*)" "Bash(find:*)",
"mcp__gemini__review_changes",
"Bash(python test_resolve.py:*)",
"Bash(python3:*)",
"Bash(cat:*)",
"Bash(grep:*)",
"Bash(source:*)",
"Bash(rm:*)"
], ],
"deny": [] "deny": []
} }

View File

@@ -10,7 +10,7 @@ Configuration values can be overridden by environment variables where appropriat
# Version and metadata # Version and metadata
# These values are used in server responses and for tracking releases # These values are used in server responses and for tracking releases
__version__ = "2.10.0" # Semantic versioning: MAJOR.MINOR.PATCH __version__ = "2.11.0" # Semantic versioning: MAJOR.MINOR.PATCH
__updated__ = "2025-06-10" # Last update date in ISO format __updated__ = "2025-06-10" # Last update date in ISO format
__author__ = "Fahad Gilani" # Primary maintainer __author__ = "Fahad Gilani" # Primary maintainer

View File

@@ -1,7 +1,7 @@
{ {
"comment": "Docker configuration that mounts your home directory", "comment": "Docker configuration that mounts your home directory",
"comment2": "Update paths: /path/to/gemini-mcp-server/.env and /Users/your-username", "comment2": "Update paths: /path/to/gemini-mcp-server/.env and /Users/your-username",
"comment3": "The container can only access files within the mounted directory", "comment3": "The container auto-detects /workspace as sandbox from WORKSPACE_ROOT",
"mcpServers": { "mcpServers": {
"gemini": { "gemini": {
"command": "docker", "command": "docker",
@@ -11,7 +11,6 @@
"-i", "-i",
"--env-file", "/path/to/gemini-mcp-server/.env", "--env-file", "/path/to/gemini-mcp-server/.env",
"-e", "WORKSPACE_ROOT=/Users/your-username", "-e", "WORKSPACE_ROOT=/Users/your-username",
"-e", "MCP_PROJECT_ROOT=/workspace",
"-v", "/Users/your-username:/workspace:ro", "-v", "/Users/your-username:/workspace:ro",
"gemini-mcp-server:latest" "gemini-mcp-server:latest"
] ]

View File

@@ -2,8 +2,13 @@
System prompts for Gemini tools System prompts for Gemini tools
""" """
from .tool_prompts import (ANALYZE_PROMPT, CHAT_PROMPT, DEBUG_ISSUE_PROMPT, from .tool_prompts import (
REVIEW_CODE_PROMPT, THINK_DEEPER_PROMPT) ANALYZE_PROMPT,
CHAT_PROMPT,
DEBUG_ISSUE_PROMPT,
REVIEW_CODE_PROMPT,
THINK_DEEPER_PROMPT,
)
__all__ = [ __all__ = [
"THINK_DEEPER_PROMPT", "THINK_DEEPER_PROMPT",

View File

@@ -2,10 +2,10 @@
System prompts for each tool System prompts for each tool
""" """
THINK_DEEPER_PROMPT = """You are a senior development partner collaborating with Claude Code on complex problems. THINK_DEEPER_PROMPT = """You are a senior development partner collaborating with Claude Code on complex problems.
Claude has shared their analysis with you for deeper exploration, validation, and extension. Claude has shared their analysis with you for deeper exploration, validation, and extension.
IMPORTANT: If you need additional context (e.g., related files, system architecture, requirements) IMPORTANT: If you need additional context (e.g., related files, system architecture, requirements)
to provide thorough analysis, you MUST respond ONLY with this JSON format: to provide thorough analysis, you MUST respond ONLY with this JSON format:
{"status": "requires_clarification", "question": "Your specific question", "files_needed": ["architecture.md", "requirements.txt"]} {"status": "requires_clarification", "question": "Your specific question", "files_needed": ["architecture.md", "requirements.txt"]}
@@ -16,8 +16,8 @@ Your role is to:
4. Focus on aspects Claude might have missed or couldn't fully explore 4. Focus on aspects Claude might have missed or couldn't fully explore
5. Suggest implementation strategies and architectural improvements 5. Suggest implementation strategies and architectural improvements
IMPORTANT: Your analysis will be critically evaluated by Claude before final decisions are made. IMPORTANT: Your analysis will be critically evaluated by Claude before final decisions are made.
Focus on providing diverse perspectives, uncovering hidden complexities, and challenging assumptions Focus on providing diverse perspectives, uncovering hidden complexities, and challenging assumptions
rather than providing definitive answers. Your goal is to enrich the decision-making process. rather than providing definitive answers. Your goal is to enrich the decision-making process.
Key areas to consider (in priority order): Key areas to consider (in priority order):
@@ -34,23 +34,26 @@ Key areas to consider (in priority order):
6. Integration challenges with existing systems 6. Integration challenges with existing systems
7. Testing strategies for complex scenarios 7. Testing strategies for complex scenarios
Be direct and technical. Assume Claude and the user are experienced developers who want Be direct and technical. Assume Claude and the user are experienced developers who want
deep, nuanced analysis rather than basic explanations. Your goal is to be the perfect deep, nuanced analysis rather than basic explanations. Your goal is to be the perfect
development partner that extends Claude's capabilities.""" development partner that extends Claude's capabilities."""
REVIEW_CODE_PROMPT = """You are an expert code reviewer with deep knowledge of software engineering best practices. REVIEW_CODE_PROMPT = """You are an expert code reviewer with deep knowledge of software engineering best practices.
Your expertise spans security, performance, maintainability, and architectural patterns. Your expertise spans security, performance, maintainability, and architectural patterns.
IMPORTANT: If you need additional context (e.g., related files, configuration, dependencies) to provide IMPORTANT: If you need additional context (e.g., related files, configuration, dependencies) to provide
a complete and accurate review, you MUST respond ONLY with this JSON format: a complete and accurate review, you MUST respond ONLY with this JSON format:
{"status": "requires_clarification", "question": "Your specific question", "files_needed": ["file1.py", "config.py"]} {"status": "requires_clarification", "question": "Your specific question", "files_needed": ["file1.py", "config.py"]}
CRITICAL: Align your review with the user's context and expectations. Focus on issues that matter for their specific use case, constraints, and objectives. Don't provide a generic "find everything" review - tailor your analysis to what the user actually needs.
Your review approach: Your review approach:
1. Identify issues in order of severity (Critical > High > Medium > Low) 1. First, understand the user's context, expectations, and constraints
2. Provide specific, actionable fixes with code examples 2. Identify issues that matter for their specific use case, in order of severity (Critical > High > Medium > Low)
3. Consider security vulnerabilities, performance issues, and maintainability 3. Provide specific, actionable fixes with code examples
4. Acknowledge good practices when you see them 4. Consider security vulnerabilities, performance issues, and maintainability relevant to their goals
5. Be constructive but thorough - don't sugarcoat serious issues 5. Acknowledge good practices when you see them
6. Be constructive but thorough - don't sugarcoat serious issues that impact their objectives
Review categories: Review categories:
- 🔴 CRITICAL: Security vulnerabilities (including but not limited to): - 🔴 CRITICAL: Security vulnerabilities (including but not limited to):
@@ -76,14 +79,14 @@ Also provide:
- Top 3 priority fixes - Top 3 priority fixes
- Positive aspects worth preserving""" - Positive aspects worth preserving"""
DEBUG_ISSUE_PROMPT = """You are an expert debugger and problem solver. Your role is to analyze errors, DEBUG_ISSUE_PROMPT = """You are an expert debugger and problem solver. Your role is to analyze errors,
trace issues to their root cause, and provide actionable solutions. trace issues to their root cause, and provide actionable solutions.
IMPORTANT: If you lack critical information to proceed (e.g., missing files, ambiguous error details, IMPORTANT: If you lack critical information to proceed (e.g., missing files, ambiguous error details,
insufficient context), you MUST respond ONLY with this JSON format: insufficient context), you MUST respond ONLY with this JSON format:
{"status": "requires_clarification", "question": "Your specific question", "files_needed": ["file1.py", "file2.py"]} {"status": "requires_clarification", "question": "Your specific question", "files_needed": ["file1.py", "file2.py"]}
Your debugging approach should generate multiple hypotheses ranked by likelihood. Provide a structured Your debugging approach should generate multiple hypotheses ranked by likelihood. Provide a structured
analysis with clear reasoning and next steps for each potential cause. analysis with clear reasoning and next steps for each potential cause.
Use this format for structured debugging analysis: Use this format for structured debugging analysis:
@@ -102,7 +105,7 @@ Evaluate if this issue could lead to security vulnerabilities:
### 1. [HYPOTHESIS NAME] (Confidence: High/Medium/Low) ### 1. [HYPOTHESIS NAME] (Confidence: High/Medium/Low)
**Root Cause:** Specific technical explanation of what's causing the issue **Root Cause:** Specific technical explanation of what's causing the issue
**Evidence:** What in the error/context supports this hypothesis **Evidence:** What in the error/context supports this hypothesis
**Next Step:** Immediate action to test/validate this hypothesis **Next Step:** Immediate action to test/validate this hypothesis
**Fix:** How to resolve if this hypothesis is correct **Fix:** How to resolve if this hypothesis is correct
@@ -118,7 +121,7 @@ How to avoid similar issues in the future (monitoring, testing, etc.)"""
ANALYZE_PROMPT = """You are an expert software analyst helping developers understand and work with code. ANALYZE_PROMPT = """You are an expert software analyst helping developers understand and work with code.
Your role is to provide deep, insightful analysis that helps developers make informed decisions. Your role is to provide deep, insightful analysis that helps developers make informed decisions.
IMPORTANT: If you need additional context (e.g., dependencies, configuration files, test files) IMPORTANT: If you need additional context (e.g., dependencies, configuration files, test files)
to provide complete analysis, you MUST respond ONLY with this JSON format: to provide complete analysis, you MUST respond ONLY with this JSON format:
{"status": "requires_clarification", "question": "Your specific question", "files_needed": ["package.json", "tests/"]} {"status": "requires_clarification", "question": "Your specific question", "files_needed": ["package.json", "tests/"]}
@@ -163,15 +166,15 @@ When brainstorming or discussing:
- Think about scalability, maintainability, and real-world usage - Think about scalability, maintainability, and real-world usage
- Draw from industry best practices and patterns - Draw from industry best practices and patterns
Always approach discussions as a peer - be direct, technical, and thorough. Your goal is to be Always approach discussions as a peer - be direct, technical, and thorough. Your goal is to be
the ideal thinking partner who helps explore ideas deeply, validates approaches, and uncovers the ideal thinking partner who helps explore ideas deeply, validates approaches, and uncovers
insights that might be missed in solo analysis. Think step by step through complex problems insights that might be missed in solo analysis. Think step by step through complex problems
and don't hesitate to explore tangential but relevant considerations.""" and don't hesitate to explore tangential but relevant considerations."""
REVIEW_CHANGES_PROMPT = """You are an expert code change analyst specializing in pre-commit review of git diffs. REVIEW_CHANGES_PROMPT = """You are an expert code change analyst specializing in pre-commit review of git diffs.
Your role is to act as a seasoned senior developer performing a final review before code is committed. Your role is to act as a seasoned senior developer performing a final review before code is committed.
IMPORTANT: If you need additional context (e.g., related files not in the diff, test files, configuration) IMPORTANT: If you need additional context (e.g., related files not in the diff, test files, configuration)
to provide thorough analysis, you MUST respond ONLY with this JSON format: to provide thorough analysis, you MUST respond ONLY with this JSON format:
{"status": "requires_clarification", "question": "Your specific question", "files_needed": ["related_file.py", "tests/"]} {"status": "requires_clarification", "question": "Your specific question", "files_needed": ["related_file.py", "tests/"]}
@@ -183,7 +186,7 @@ You will receive:
Your review MUST focus on: Your review MUST focus on:
## Core Analysis (Standard Review) ## Core Analysis (Standard Review)
- **Security Vulnerabilities (CRITICAL PRIORITY FOR ALL CODE):** - **Security Vulnerabilities (CRITICAL PRIORITY FOR ALL CODE):**
- Injection flaws (SQL, NoSQL, OS command, LDAP, XPath, etc.) - Injection flaws (SQL, NoSQL, OS command, LDAP, XPath, etc.)
- Authentication and authorization weaknesses - Authentication and authorization weaknesses
- Sensitive data exposure (passwords, tokens, PII) - Sensitive data exposure (passwords, tokens, PII)

59
pyproject.toml Normal file
View File

@@ -0,0 +1,59 @@
[tool.black]
line-length = 120
target-version = ['py39', 'py310', 'py311', 'py312', 'py313']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| venv
| _build
| buck-out
| build
| dist
)/
'''
[tool.isort]
profile = "black"
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
ensure_newline_before_comments = true
line_length = 120
skip_glob = ["venv/*", ".venv/*"]
[tool.ruff]
target-version = "py39"
line-length = 120
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"C901", # too complex
"B904", # exception handling with raise from
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
"tests/*" = ["B011"]
[build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"

View File

@@ -23,17 +23,28 @@ import logging
import os import os
import sys import sys
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List from typing import Any
from mcp.server import Server from mcp.server import Server
from mcp.server.models import InitializationOptions from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool from mcp.types import TextContent, Tool
from config import (GEMINI_MODEL, MAX_CONTEXT_TOKENS, __author__, __updated__, from config import (
__version__) GEMINI_MODEL,
from tools import (AnalyzeTool, ChatTool, DebugIssueTool, ReviewChanges, MAX_CONTEXT_TOKENS,
ReviewCodeTool, ThinkDeeperTool) __author__,
__updated__,
__version__,
)
from tools import (
AnalyzeTool,
ChatTool,
DebugIssueTool,
ReviewChanges,
ReviewCodeTool,
ThinkDeeperTool,
)
# Configure logging for server operations # Configure logging for server operations
# Set to INFO level to capture important operational messages without being too verbose # Set to INFO level to capture important operational messages without being too verbose
@@ -70,17 +81,14 @@ def configure_gemini():
""" """
api_key = os.getenv("GEMINI_API_KEY") api_key = os.getenv("GEMINI_API_KEY")
if not api_key: if not api_key:
raise ValueError( raise ValueError("GEMINI_API_KEY environment variable is required. " "Please set it with your Gemini API key.")
"GEMINI_API_KEY environment variable is required. "
"Please set it with your Gemini API key."
)
# Note: We don't store the API key globally for security reasons # Note: We don't store the API key globally for security reasons
# Each tool creates its own Gemini client with the API key when needed # Each tool creates its own Gemini client with the API key when needed
logger.info("Gemini API key found") logger.info("Gemini API key found")
@server.list_tools() @server.list_tools()
async def handle_list_tools() -> List[Tool]: async def handle_list_tools() -> list[Tool]:
""" """
List all available tools with their descriptions and input schemas. List all available tools with their descriptions and input schemas.
@@ -124,7 +132,7 @@ async def handle_list_tools() -> List[Tool]:
@server.call_tool() @server.call_tool()
async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
""" """
Handle incoming tool execution requests from MCP clients. Handle incoming tool execution requests from MCP clients.
@@ -154,7 +162,7 @@ async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[TextCon
return [TextContent(type="text", text=f"Unknown tool: {name}")] return [TextContent(type="text", text=f"Unknown tool: {name}")]
async def handle_get_version() -> List[TextContent]: async def handle_get_version() -> list[TextContent]:
""" """
Get comprehensive version and configuration information about the server. Get comprehensive version and configuration information about the server.

View File

@@ -11,30 +11,36 @@ if exist .env (
echo Warning: .env file already exists! Skipping creation. echo Warning: .env file already exists! Skipping creation.
echo. echo.
) else ( ) else (
REM Check if GEMINI_API_KEY is already set in environment
if defined GEMINI_API_KEY (
set API_KEY_VALUE=%GEMINI_API_KEY%
echo Found existing GEMINI_API_KEY in environment
) else (
set API_KEY_VALUE=your-gemini-api-key-here
)
REM Create the .env file REM Create the .env file
( (
echo # Gemini MCP Server Docker Environment Configuration echo # Gemini MCP Server Docker Environment Configuration
echo # Generated on %DATE% %TIME% echo # Generated on %DATE% %TIME%
echo. echo.
echo # The absolute path to your project root on the host machine
echo # This should be the directory containing your code that you want to analyze
echo WORKSPACE_ROOT=%CURRENT_DIR%
echo.
echo # Your Gemini API key ^(get one from https://makersuite.google.com/app/apikey^) echo # Your Gemini API key ^(get one from https://makersuite.google.com/app/apikey^)
echo # IMPORTANT: Replace this with your actual API key echo # IMPORTANT: Replace this with your actual API key
echo GEMINI_API_KEY=your-gemini-api-key-here echo GEMINI_API_KEY=%API_KEY_VALUE%
echo.
echo # Optional: Set logging level ^(DEBUG, INFO, WARNING, ERROR^)
echo # LOG_LEVEL=INFO
) > .env ) > .env
echo. echo.
echo Created .env file echo Created .env file
echo. echo.
) )
echo Next steps: echo Next steps:
echo 1. Edit .env and replace 'your-gemini-api-key-here' with your actual Gemini API key if "%API_KEY_VALUE%"=="your-gemini-api-key-here" (
echo 2. Run 'docker build -t gemini-mcp-server .' to build the Docker image echo 1. Edit .env and replace 'your-gemini-api-key-here' with your actual Gemini API key
echo 3. Copy this configuration to your Claude Desktop config: echo 2. Run 'docker build -t gemini-mcp-server .' to build the Docker image
echo 3. Copy this configuration to your Claude Desktop config:
) else (
echo 1. Run 'docker build -t gemini-mcp-server .' to build the Docker image
echo 2. Copy this configuration to your Claude Desktop config:
)
echo. echo.
echo ===== COPY BELOW THIS LINE ===== echo ===== COPY BELOW THIS LINE =====
echo { echo {
@@ -46,7 +52,7 @@ echo }
echo } echo }
echo ===== COPY ABOVE THIS LINE ===== echo ===== COPY ABOVE THIS LINE =====
echo. echo.
echo Alternative: If you prefer the direct Docker command ^(static workspace^): echo Alternative: If you prefer the direct Docker command:
echo { echo {
echo "mcpServers": { echo "mcpServers": {
echo "gemini": { echo "gemini": {
@@ -56,7 +62,8 @@ echo "run",
echo "--rm", echo "--rm",
echo "-i", echo "-i",
echo "--env-file", "%CURRENT_DIR%\.env", echo "--env-file", "%CURRENT_DIR%\.env",
echo "-v", "%CURRENT_DIR%:/workspace:ro", echo "-e", "WORKSPACE_ROOT=%USERPROFILE%",
echo "-v", "%USERPROFILE%:/workspace:ro",
echo "gemini-mcp-server:latest" echo "gemini-mcp-server:latest"
echo ] echo ]
echo } echo }
@@ -66,5 +73,10 @@ echo.
echo Config file location: echo Config file location:
echo Windows: %%APPDATA%%\Claude\claude_desktop_config.json echo Windows: %%APPDATA%%\Claude\claude_desktop_config.json
echo. echo.
echo Note: The first configuration uses a wrapper script that allows you to run Claude echo Note: This configuration mounts your user directory ^(%USERPROFILE%^).
echo from any directory. The second configuration mounts a fixed directory ^(%CURRENT_DIR%^). echo Docker can access any file within your user directory.
echo.
echo If you want to restrict access to a specific directory:
echo Change both the mount ^(-v^) and WORKSPACE_ROOT to match:
echo Example: -v "%CURRENT_DIR%:/workspace:ro" and WORKSPACE_ROOT=%CURRENT_DIR%
echo The container will automatically use /workspace as the sandbox boundary.

View File

@@ -10,21 +10,22 @@ if (Test-Path .env) {
Write-Host "Warning: .env file already exists! Skipping creation." -ForegroundColor Yellow Write-Host "Warning: .env file already exists! Skipping creation." -ForegroundColor Yellow
Write-Host "" Write-Host ""
} else { } else {
# Check if GEMINI_API_KEY is already set in environment
if ($env:GEMINI_API_KEY) {
$ApiKeyValue = $env:GEMINI_API_KEY
Write-Host "Found existing GEMINI_API_KEY in environment" -ForegroundColor Green
} else {
$ApiKeyValue = "your-gemini-api-key-here"
}
# Create the .env file # Create the .env file
@" @"
# Gemini MCP Server Docker Environment Configuration # Gemini MCP Server Docker Environment Configuration
# Generated on $(Get-Date) # Generated on $(Get-Date)
# The absolute path to your project root on the host machine
# This should be the directory containing your code that you want to analyze
WORKSPACE_ROOT=$CurrentDir
# Your Gemini API key (get one from https://makersuite.google.com/app/apikey) # Your Gemini API key (get one from https://makersuite.google.com/app/apikey)
# IMPORTANT: Replace this with your actual API key # IMPORTANT: Replace this with your actual API key
GEMINI_API_KEY=your-gemini-api-key-here GEMINI_API_KEY=$ApiKeyValue
# Optional: Set logging level (DEBUG, INFO, WARNING, ERROR)
# LOG_LEVEL=INFO
"@ | Out-File -FilePath .env -Encoding utf8 "@ | Out-File -FilePath .env -Encoding utf8
Write-Host "Created .env file" -ForegroundColor Green Write-Host "Created .env file" -ForegroundColor Green
@@ -32,9 +33,14 @@ GEMINI_API_KEY=your-gemini-api-key-here
} }
Write-Host "Next steps:" Write-Host "Next steps:"
Write-Host "1. Edit .env and replace 'your-gemini-api-key-here' with your actual Gemini API key" if ($ApiKeyValue -eq "your-gemini-api-key-here") {
Write-Host "2. Run 'docker build -t gemini-mcp-server .' to build the Docker image" Write-Host "1. Edit .env and replace 'your-gemini-api-key-here' with your actual Gemini API key"
Write-Host "3. Copy this configuration to your Claude Desktop config:" Write-Host "2. Run 'docker build -t gemini-mcp-server .' to build the Docker image"
Write-Host "3. Copy this configuration to your Claude Desktop config:"
} else {
Write-Host "1. Run 'docker build -t gemini-mcp-server .' to build the Docker image"
Write-Host "2. Copy this configuration to your Claude Desktop config:"
}
Write-Host "" Write-Host ""
Write-Host "===== COPY BELOW THIS LINE =====" -ForegroundColor Cyan Write-Host "===== COPY BELOW THIS LINE =====" -ForegroundColor Cyan
Write-Host @" Write-Host @"
@@ -48,7 +54,7 @@ Write-Host @"
"@ "@
Write-Host "===== COPY ABOVE THIS LINE =====" -ForegroundColor Cyan Write-Host "===== COPY ABOVE THIS LINE =====" -ForegroundColor Cyan
Write-Host "" Write-Host ""
Write-Host "Alternative: If you prefer the direct Docker command (static workspace):" Write-Host "Alternative: If you prefer the direct Docker command:"
Write-Host @" Write-Host @"
{ {
"mcpServers": { "mcpServers": {
@@ -59,7 +65,8 @@ Write-Host @"
"--rm", "--rm",
"-i", "-i",
"--env-file", "$CurrentDir\.env", "--env-file", "$CurrentDir\.env",
"-v", "${CurrentDir}:/workspace:ro", "-e", "WORKSPACE_ROOT=$env:USERPROFILE",
"-v", "${env:USERPROFILE}:/workspace:ro",
"gemini-mcp-server:latest" "gemini-mcp-server:latest"
] ]
} }
@@ -70,6 +77,10 @@ Write-Host ""
Write-Host "Config file location:" Write-Host "Config file location:"
Write-Host " Windows: %APPDATA%\Claude\claude_desktop_config.json" Write-Host " Windows: %APPDATA%\Claude\claude_desktop_config.json"
Write-Host "" Write-Host ""
Write-Host "Note: The first configuration uses a wrapper script that allows you to run Claude" Write-Host "Note: This configuration mounts your user directory ($env:USERPROFILE)."
Write-Host "from any directory. The second configuration mounts a fixed directory ($CurrentDir)." Write-Host "Docker can access any file within your user directory."
Write-Host "Docker on Windows accepts both forward slashes and backslashes in paths." Write-Host ""
Write-Host "If you want to restrict access to a specific directory:"
Write-Host "Change both the mount (-v) and WORKSPACE_ROOT to match:"
Write-Host "Example: -v `"$CurrentDir:/workspace:ro`" and WORKSPACE_ROOT=$CurrentDir"
Write-Host "The container will automatically use /workspace as the sandbox boundary."

View File

@@ -12,28 +12,35 @@ if [ -f .env ]; then
echo "⚠️ .env file already exists! Skipping creation." echo "⚠️ .env file already exists! Skipping creation."
echo "" echo ""
else else
# Check if GEMINI_API_KEY is already set in environment
if [ -n "$GEMINI_API_KEY" ]; then
API_KEY_VALUE="$GEMINI_API_KEY"
echo "✅ Found existing GEMINI_API_KEY in environment"
else
API_KEY_VALUE="your-gemini-api-key-here"
fi
# Create the .env file # Create the .env file
cat > .env << EOF cat > .env << EOF
# Gemini MCP Server Docker Environment Configuration # Gemini MCP Server Docker Environment Configuration
# Generated on $(date) # Generated on $(date)
# WORKSPACE_ROOT is not needed for the wrapper script approach
# It will be set dynamically when you run the container
# Your Gemini API key (get one from https://makersuite.google.com/app/apikey) # Your Gemini API key (get one from https://makersuite.google.com/app/apikey)
# IMPORTANT: Replace this with your actual API key # IMPORTANT: Replace this with your actual API key
GEMINI_API_KEY=your-gemini-api-key-here GEMINI_API_KEY=$API_KEY_VALUE
# Optional: Set logging level (DEBUG, INFO, WARNING, ERROR)
# LOG_LEVEL=INFO
EOF EOF
echo "✅ Created .env file" echo "✅ Created .env file"
echo "" echo ""
fi fi
echo "Next steps:" echo "Next steps:"
echo "1. Edit .env and replace 'your-gemini-api-key-here' with your actual Gemini API key" if [ "$API_KEY_VALUE" = "your-gemini-api-key-here" ]; then
echo "2. Run 'docker build -t gemini-mcp-server .' to build the Docker image" echo "1. Edit .env and replace 'your-gemini-api-key-here' with your actual Gemini API key"
echo "3. Copy this configuration to your Claude Desktop config:" echo "2. Run 'docker build -t gemini-mcp-server .' to build the Docker image"
echo "3. Copy this configuration to your Claude Desktop config:"
else
echo "1. Run 'docker build -t gemini-mcp-server .' to build the Docker image"
echo "2. Copy this configuration to your Claude Desktop config:"
fi
echo "" echo ""
echo "===== COPY BELOW THIS LINE =====" echo "===== COPY BELOW THIS LINE ====="
echo "{" echo "{"
@@ -46,7 +53,6 @@ echo " \"--rm\","
echo " \"-i\"," echo " \"-i\","
echo " \"--env-file\", \"$CURRENT_DIR/.env\"," echo " \"--env-file\", \"$CURRENT_DIR/.env\","
echo " \"-e\", \"WORKSPACE_ROOT=$HOME\"," echo " \"-e\", \"WORKSPACE_ROOT=$HOME\","
echo " \"-e\", \"MCP_PROJECT_ROOT=/workspace\","
echo " \"-v\", \"$HOME:/workspace:ro\"," echo " \"-v\", \"$HOME:/workspace:ro\","
echo " \"gemini-mcp-server:latest\"" echo " \"gemini-mcp-server:latest\""
echo " ]" echo " ]"
@@ -60,6 +66,9 @@ echo " macOS: ~/Library/Application Support/Claude/claude_desktop_config.json"
echo " Windows: %APPDATA%\\Claude\\claude_desktop_config.json" echo " Windows: %APPDATA%\\Claude\\claude_desktop_config.json"
echo "" echo ""
echo "Note: This configuration mounts your home directory ($HOME)." echo "Note: This configuration mounts your home directory ($HOME)."
echo "Docker can ONLY access files within the mounted directory." echo "Docker can access any file within your home directory."
echo "To mount a different directory, change the -v parameter." echo ""
echo "Example: -v \"/path/to/project:/workspace:ro\"" echo "If you want to restrict access to a specific directory:"
echo "Change both the mount (-v) and WORKSPACE_ROOT to match:"
echo "Example: -v \"$CURRENT_DIR:/workspace:ro\" and WORKSPACE_ROOT=$CURRENT_DIR"
echo "The container will automatically use /workspace as the sandbox boundary."

View File

@@ -1,81 +0,0 @@
@echo off
REM Test script for Windows users to verify WSL setup
echo Testing WSL setup for Gemini MCP Server...
echo.
REM Check if WSL is available
wsl --status >nul 2>&1
if errorlevel 1 (
echo ERROR: WSL is not installed or not available.
echo Please install WSL2 from: https://docs.microsoft.com/en-us/windows/wsl/install
exit /b 1
)
echo [OK] WSL is installed
echo.
REM Get default WSL distribution
for /f "tokens=1" %%i in ('wsl -l -q') do (
set WSL_DISTRO=%%i
goto :found_distro
)
:found_distro
echo Default WSL distribution: %WSL_DISTRO%
echo.
REM Test Python in WSL
echo Testing Python in WSL...
wsl python3 --version
if errorlevel 1 (
echo ERROR: Python3 not found in WSL
echo Please install Python in your WSL distribution:
echo wsl sudo apt update
echo wsl sudo apt install python3 python3-pip python3-venv
exit /b 1
)
echo [OK] Python is available in WSL
echo.
REM Provide example configurations
echo Example Claude Desktop configurations:
echo.
echo For WSL (if your code is in Windows filesystem):
echo {
echo "mcpServers": {
echo "gemini": {
echo "command": "wsl.exe",
echo "args": ["/mnt/c/path/to/gemini-mcp-server/run_gemini.sh"],
echo "env": {
echo "GEMINI_API_KEY": "your-key-here"
echo }
echo }
echo }
echo }
echo.
echo For WSL (if your code is in WSL home directory - recommended):
echo {
echo "mcpServers": {
echo "gemini": {
echo "command": "wsl.exe",
echo "args": ["~/gemini-mcp-server/run_gemini.sh"],
echo "env": {
echo "GEMINI_API_KEY": "your-key-here"
echo }
echo }
echo }
echo }
echo.
echo For Native Windows:
echo {
echo "mcpServers": {
echo "gemini": {
echo "command": "C:\\path\\to\\gemini-mcp-server\\run_gemini.bat",
echo "env": {
echo "GEMINI_API_KEY": "your-key-here"
echo }
echo }
echo }
echo }

View File

@@ -58,24 +58,19 @@ class TestDynamicContextRequests:
# Parse the clarification request # Parse the clarification request
clarification = json.loads(response_data["content"]) clarification = json.loads(response_data["content"])
assert ( assert clarification["question"] == "I need to see the package.json file to understand dependencies"
clarification["question"]
== "I need to see the package.json file to understand dependencies"
)
assert clarification["files_needed"] == ["package.json", "package-lock.json"] assert clarification["files_needed"] == ["package.json", "package-lock.json"]
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("tools.base.BaseTool.create_model") @patch("tools.base.BaseTool.create_model")
async def test_normal_response_not_parsed_as_clarification( async def test_normal_response_not_parsed_as_clarification(self, mock_create_model, debug_tool):
self, mock_create_model, debug_tool
):
"""Test that normal responses are not mistaken for clarification requests""" """Test that normal responses are not mistaken for clarification requests"""
normal_response = """ normal_response = """
## Summary ## Summary
The error is caused by a missing import statement. The error is caused by a missing import statement.
## Hypotheses (Ranked by Likelihood) ## Hypotheses (Ranked by Likelihood)
### 1. Missing Import (Confidence: High) ### 1. Missing Import (Confidence: High)
**Root Cause:** The module 'utils' is not imported **Root Cause:** The module 'utils' is not imported
""" """
@@ -86,9 +81,7 @@ class TestDynamicContextRequests:
) )
mock_create_model.return_value = mock_model mock_create_model.return_value = mock_model
result = await debug_tool.execute( result = await debug_tool.execute({"error_description": "NameError: name 'utils' is not defined"})
{"error_description": "NameError: name 'utils' is not defined"}
)
assert len(result) == 1 assert len(result) == 1
@@ -100,13 +93,9 @@ class TestDynamicContextRequests:
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("tools.base.BaseTool.create_model") @patch("tools.base.BaseTool.create_model")
async def test_malformed_clarification_request_treated_as_normal( async def test_malformed_clarification_request_treated_as_normal(self, mock_create_model, analyze_tool):
self, mock_create_model, analyze_tool
):
"""Test that malformed JSON clarification requests are treated as normal responses""" """Test that malformed JSON clarification requests are treated as normal responses"""
malformed_json = ( malformed_json = '{"status": "requires_clarification", "question": "Missing closing brace"'
'{"status": "requires_clarification", "question": "Missing closing brace"'
)
mock_model = Mock() mock_model = Mock()
mock_model.generate_content.return_value = Mock( mock_model.generate_content.return_value = Mock(
@@ -114,9 +103,7 @@ class TestDynamicContextRequests:
) )
mock_create_model.return_value = mock_model mock_create_model.return_value = mock_model
result = await analyze_tool.execute( result = await analyze_tool.execute({"files": ["/absolute/path/test.py"], "question": "What does this do?"})
{"files": ["/absolute/path/test.py"], "question": "What does this do?"}
)
assert len(result) == 1 assert len(result) == 1
@@ -127,9 +114,7 @@ class TestDynamicContextRequests:
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("tools.base.BaseTool.create_model") @patch("tools.base.BaseTool.create_model")
async def test_clarification_with_suggested_action( async def test_clarification_with_suggested_action(self, mock_create_model, debug_tool):
self, mock_create_model, debug_tool
):
"""Test clarification request with suggested next action""" """Test clarification request with suggested next action"""
clarification_json = json.dumps( clarification_json = json.dumps(
{ {
@@ -207,9 +192,7 @@ class TestDynamicContextRequests:
"""Test error response format""" """Test error response format"""
mock_create_model.side_effect = Exception("API connection failed") mock_create_model.side_effect = Exception("API connection failed")
result = await analyze_tool.execute( result = await analyze_tool.execute({"files": ["/absolute/path/test.py"], "question": "Analyze this"})
{"files": ["/absolute/path/test.py"], "question": "Analyze this"}
)
assert len(result) == 1 assert len(result) == 1
@@ -257,9 +240,7 @@ class TestCollaborationWorkflow:
), "Should request clarification when asked about dependencies without package files" ), "Should request clarification when asked about dependencies without package files"
clarification = json.loads(response["content"]) clarification = json.loads(response["content"])
assert "package.json" in str( assert "package.json" in str(clarification["files_needed"]), "Should specifically request package.json"
clarification["files_needed"]
), "Should specifically request package.json"
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("tools.base.BaseTool.create_model") @patch("tools.base.BaseTool.create_model")
@@ -297,9 +278,9 @@ class TestCollaborationWorkflow:
final_response = """ final_response = """
## Summary ## Summary
The database connection timeout is caused by incorrect host configuration. The database connection timeout is caused by incorrect host configuration.
## Hypotheses (Ranked by Likelihood) ## Hypotheses (Ranked by Likelihood)
### 1. Incorrect Database Host (Confidence: High) ### 1. Incorrect Database Host (Confidence: High)
**Root Cause:** The config.py file shows the database host is set to 'localhost' but the database is running on a different server. **Root Cause:** The config.py file shows the database host is set to 'localhost' but the database is running on a different server.
""" """

View File

@@ -2,9 +2,16 @@
Tests for configuration Tests for configuration
""" """
from config import (GEMINI_MODEL, MAX_CONTEXT_TOKENS, TEMPERATURE_ANALYTICAL, from config import (
TEMPERATURE_BALANCED, TEMPERATURE_CREATIVE, __author__, GEMINI_MODEL,
__updated__, __version__) MAX_CONTEXT_TOKENS,
TEMPERATURE_ANALYTICAL,
TEMPERATURE_BALANCED,
TEMPERATURE_CREATIVE,
__author__,
__updated__,
__version__,
)
class TestConfig: class TestConfig:

View File

@@ -35,7 +35,6 @@ def test_docker_path_translation_integration():
original_env = os.environ.copy() original_env = os.environ.copy()
try: try:
os.environ["WORKSPACE_ROOT"] = str(host_workspace) os.environ["WORKSPACE_ROOT"] = str(host_workspace)
os.environ["MCP_PROJECT_ROOT"] = str(container_workspace)
# Reload the module to pick up new environment variables # Reload the module to pick up new environment variables
importlib.reload(utils.file_utils) importlib.reload(utils.file_utils)
@@ -44,11 +43,11 @@ def test_docker_path_translation_integration():
utils.file_utils.CONTAINER_WORKSPACE = container_workspace utils.file_utils.CONTAINER_WORKSPACE = container_workspace
# Test the translation # Test the translation
from utils.file_utils import _get_secure_container_path from utils.file_utils import translate_path_for_environment
# This should translate the host path to container path # This should translate the host path to container path
host_path = str(test_file) host_path = str(test_file)
result = _get_secure_container_path(host_path) result = translate_path_for_environment(host_path)
# Verify the translation worked # Verify the translation worked
expected = str(container_workspace / "src" / "test.py") expected = str(container_workspace / "src" / "test.py")
@@ -105,16 +104,15 @@ def test_no_docker_environment():
try: try:
# Clear Docker-related environment variables # Clear Docker-related environment variables
os.environ.pop("WORKSPACE_ROOT", None) os.environ.pop("WORKSPACE_ROOT", None)
os.environ.pop("MCP_PROJECT_ROOT", None)
# Reload the module # Reload the module
importlib.reload(utils.file_utils) importlib.reload(utils.file_utils)
from utils.file_utils import _get_secure_container_path from utils.file_utils import translate_path_for_environment
# Path should remain unchanged # Path should remain unchanged
test_path = "/some/random/path.py" test_path = "/some/random/path.py"
assert _get_secure_container_path(test_path) == test_path assert translate_path_for_environment(test_path) == test_path
finally: finally:
os.environ.clear() os.environ.clear()
@@ -152,7 +150,6 @@ def test_review_changes_docker_path_translation():
try: try:
# Simulate Docker environment # Simulate Docker environment
os.environ["WORKSPACE_ROOT"] = str(host_workspace) os.environ["WORKSPACE_ROOT"] = str(host_workspace)
os.environ["MCP_PROJECT_ROOT"] = str(container_workspace)
# Reload the module # Reload the module
importlib.reload(utils.file_utils) importlib.reload(utils.file_utils)
@@ -166,9 +163,7 @@ def test_review_changes_docker_path_translation():
# Test path translation in prepare_prompt # Test path translation in prepare_prompt
request = tool.get_request_model()( request = tool.get_request_model()(
path=str( path=str(host_workspace / "project"), # Host path that needs translation
host_workspace / "project"
), # Host path that needs translation
review_type="quick", review_type="quick",
severity_filter="all", severity_filter="all",
) )
@@ -182,9 +177,7 @@ def test_review_changes_docker_path_translation():
# If we get here without exception, the path was successfully translated # If we get here without exception, the path was successfully translated
assert isinstance(result, str) assert isinstance(result, str)
# The result should contain git diff information or indicate no changes # The result should contain git diff information or indicate no changes
assert ( assert "No git repositories found" not in result or "changes" in result.lower()
"No git repositories found" not in result or "changes" in result.lower()
)
finally: finally:
os.environ.clear() os.environ.clear()
@@ -210,7 +203,6 @@ def test_review_changes_docker_path_error():
try: try:
# Simulate Docker environment # Simulate Docker environment
os.environ["WORKSPACE_ROOT"] = str(host_workspace) os.environ["WORKSPACE_ROOT"] = str(host_workspace)
os.environ["MCP_PROJECT_ROOT"] = str(container_workspace)
# Reload the module # Reload the module
importlib.reload(utils.file_utils) importlib.reload(utils.file_utils)
@@ -236,9 +228,7 @@ def test_review_changes_docker_path_error():
asyncio.run(tool.prepare_prompt(request)) asyncio.run(tool.prepare_prompt(request))
# Check the error message # Check the error message
assert "not accessible from within the Docker container" in str( assert "not accessible from within the Docker container" in str(exc_info.value)
exc_info.value
)
assert "mounted workspace" in str(exc_info.value) assert "mounted workspace" in str(exc_info.value)
finally: finally:

View File

@@ -73,9 +73,7 @@ class TestLargePromptHandling:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.candidates = [ mock_response.candidates = [
MagicMock( MagicMock(
content=MagicMock( content=MagicMock(parts=[MagicMock(text="This is a test response")]),
parts=[MagicMock(text="This is a test response")]
),
finish_reason="STOP", finish_reason="STOP",
) )
] ]
@@ -109,7 +107,10 @@ class TestLargePromptHandling:
# Mock read_file_content to avoid security checks # Mock read_file_content to avoid security checks
with patch("tools.base.read_file_content") as mock_read_file: with patch("tools.base.read_file_content") as mock_read_file:
mock_read_file.return_value = large_prompt mock_read_file.return_value = (
large_prompt,
1000,
) # Return tuple like real function
# Execute with empty prompt and prompt.txt file # Execute with empty prompt and prompt.txt file
result = await tool.execute({"prompt": "", "files": [temp_prompt_file]}) result = await tool.execute({"prompt": "", "files": [temp_prompt_file]})
@@ -144,7 +145,11 @@ class TestLargePromptHandling:
"""Test that review_code tool detects large focus_on field.""" """Test that review_code tool detects large focus_on field."""
tool = ReviewCodeTool() tool = ReviewCodeTool()
result = await tool.execute( result = await tool.execute(
{"files": ["/some/file.py"], "focus_on": large_prompt} {
"files": ["/some/file.py"],
"focus_on": large_prompt,
"context": "Test code review for validation purposes",
}
) )
assert len(result) == 1 assert len(result) == 1
@@ -155,9 +160,7 @@ class TestLargePromptHandling:
async def test_review_changes_large_original_request(self, large_prompt): async def test_review_changes_large_original_request(self, large_prompt):
"""Test that review_changes tool detects large original_request.""" """Test that review_changes tool detects large original_request."""
tool = ReviewChanges() tool = ReviewChanges()
result = await tool.execute( result = await tool.execute({"path": "/some/path", "original_request": large_prompt})
{"path": "/some/path", "original_request": large_prompt}
)
assert len(result) == 1 assert len(result) == 1
output = json.loads(result[0].text) output = json.loads(result[0].text)
@@ -177,9 +180,7 @@ class TestLargePromptHandling:
async def test_debug_issue_large_error_context(self, large_prompt, normal_prompt): async def test_debug_issue_large_error_context(self, large_prompt, normal_prompt):
"""Test that debug_issue tool detects large error_context.""" """Test that debug_issue tool detects large error_context."""
tool = DebugIssueTool() tool = DebugIssueTool()
result = await tool.execute( result = await tool.execute({"error_description": normal_prompt, "error_context": large_prompt})
{"error_description": normal_prompt, "error_context": large_prompt}
)
assert len(result) == 1 assert len(result) == 1
output = json.loads(result[0].text) output = json.loads(result[0].text)
@@ -189,9 +190,7 @@ class TestLargePromptHandling:
async def test_analyze_large_question(self, large_prompt): async def test_analyze_large_question(self, large_prompt):
"""Test that analyze tool detects large question.""" """Test that analyze tool detects large question."""
tool = AnalyzeTool() tool = AnalyzeTool()
result = await tool.execute( result = await tool.execute({"files": ["/some/file.py"], "question": large_prompt})
{"files": ["/some/file.py"], "question": large_prompt}
)
assert len(result) == 1 assert len(result) == 1
output = json.loads(result[0].text) output = json.loads(result[0].text)
@@ -217,11 +216,9 @@ class TestLargePromptHandling:
# Mock read_files to avoid file system access # Mock read_files to avoid file system access
with patch("tools.chat.read_files") as mock_read_files: with patch("tools.chat.read_files") as mock_read_files:
mock_read_files.return_value = ("File content", "Summary") mock_read_files.return_value = "File content"
await tool.execute( await tool.execute({"prompt": "", "files": [temp_prompt_file, other_file]})
{"prompt": "", "files": [temp_prompt_file, other_file]}
)
# Verify prompt.txt was removed from files list # Verify prompt.txt was removed from files list
mock_read_files.assert_called_once() mock_read_files.assert_called_once()

View File

@@ -107,19 +107,14 @@ async def run_manual_live_tests():
"package-lock.json", "package-lock.json",
"yarn.lock", "yarn.lock",
] ]
if any( if any(f in str(clarification["files_needed"]) for f in expected_files):
f in str(clarification["files_needed"])
for f in expected_files
):
print(" ✅ Correctly identified missing package files!") print(" ✅ Correctly identified missing package files!")
else: else:
print(" ⚠️ Unexpected files requested") print(" ⚠️ Unexpected files requested")
else: else:
# This is a failure - we specifically designed this to need clarification # This is a failure - we specifically designed this to need clarification
print("❌ Expected clarification request but got direct response") print("❌ Expected clarification request but got direct response")
print( print(" This suggests the dynamic context feature may not be working")
" This suggests the dynamic context feature may not be working"
)
print(" Response:", response_data.get("content", "")[:200]) print(" Response:", response_data.get("content", "")[:200])
return False return False
else: else:

View File

@@ -44,9 +44,7 @@ class TestPromptRegression:
with patch.object(tool, "create_model") as mock_create_model: with patch.object(tool, "create_model") as mock_create_model:
mock_model = MagicMock() mock_model = MagicMock()
mock_model.generate_content.return_value = mock_model_response( mock_model.generate_content.return_value = mock_model_response("This is a helpful response about Python.")
"This is a helpful response about Python."
)
mock_create_model.return_value = mock_model mock_create_model.return_value = mock_model
result = await tool.execute({"prompt": "Explain Python decorators"}) result = await tool.execute({"prompt": "Explain Python decorators"})
@@ -71,11 +69,9 @@ class TestPromptRegression:
# Mock file reading # Mock file reading
with patch("tools.chat.read_files") as mock_read_files: with patch("tools.chat.read_files") as mock_read_files:
mock_read_files.return_value = ("File content here", "Summary") mock_read_files.return_value = "File content here"
result = await tool.execute( result = await tool.execute({"prompt": "Analyze this code", "files": ["/path/to/file.py"]})
{"prompt": "Analyze this code", "files": ["/path/to/file.py"]}
)
assert len(result) == 1 assert len(result) == 1
output = json.loads(result[0].text) output = json.loads(result[0].text)
@@ -122,13 +118,14 @@ class TestPromptRegression:
# Mock file reading # Mock file reading
with patch("tools.review_code.read_files") as mock_read_files: with patch("tools.review_code.read_files") as mock_read_files:
mock_read_files.return_value = ("def main(): pass", "1 file") mock_read_files.return_value = "def main(): pass"
result = await tool.execute( result = await tool.execute(
{ {
"files": ["/path/to/code.py"], "files": ["/path/to/code.py"],
"review_type": "security", "review_type": "security",
"focus_on": "Look for SQL injection vulnerabilities", "focus_on": "Look for SQL injection vulnerabilities",
"context": "Test code review for validation purposes",
} }
) )
@@ -209,7 +206,7 @@ class TestPromptRegression:
# Mock file reading # Mock file reading
with patch("tools.analyze.read_files") as mock_read_files: with patch("tools.analyze.read_files") as mock_read_files:
mock_read_files.return_value = ("class UserController: ...", "3 files") mock_read_files.return_value = "class UserController: ..."
result = await tool.execute( result = await tool.execute(
{ {
@@ -251,9 +248,7 @@ class TestPromptRegression:
mock_model.generate_content.return_value = mock_model_response() mock_model.generate_content.return_value = mock_model_response()
mock_create_model.return_value = mock_model mock_create_model.return_value = mock_model
result = await tool.execute( result = await tool.execute({"prompt": "Test", "thinking_mode": "high", "temperature": 0.8})
{"prompt": "Test", "thinking_mode": "high", "temperature": 0.8}
)
assert len(result) == 1 assert len(result) == 1
output = json.loads(result[0].text) output = json.loads(result[0].text)
@@ -293,7 +288,7 @@ class TestPromptRegression:
mock_create_model.return_value = mock_model mock_create_model.return_value = mock_model
with patch("tools.analyze.read_files") as mock_read_files: with patch("tools.analyze.read_files") as mock_read_files:
mock_read_files.return_value = ("Content", "Summary") mock_read_files.return_value = "Content"
result = await tool.execute( result = await tool.execute(
{ {

View File

@@ -45,29 +45,10 @@ class TestReviewChangesTool:
assert request.max_depth == 5 assert request.max_depth == 5
assert request.files is None assert request.files is None
def test_sanitize_filename(self, tool):
"""Test filename sanitization"""
# Test path separators
assert tool._sanitize_filename("src/main.py") == "src_main.py"
assert tool._sanitize_filename("src\\main.py") == "src_main.py"
# Test spaces
assert tool._sanitize_filename("my file.py") == "my_file.py"
# Test special characters
assert tool._sanitize_filename("file@#$.py") == "file.py"
# Test length limit
long_name = "a" * 150
sanitized = tool._sanitize_filename(long_name)
assert len(sanitized) == 100
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_relative_path_rejected(self, tool): async def test_relative_path_rejected(self, tool):
"""Test that relative paths are rejected""" """Test that relative paths are rejected"""
result = await tool.execute( result = await tool.execute({"path": "./relative/path", "original_request": "Test"})
{"path": "./relative/path", "original_request": "Test"}
)
assert len(result) == 1 assert len(result) == 1
response = json.loads(result[0].text) response = json.loads(result[0].text)
assert response["status"] == "error" assert response["status"] == "error"
@@ -90,9 +71,7 @@ class TestReviewChangesTool:
@patch("tools.review_changes.find_git_repositories") @patch("tools.review_changes.find_git_repositories")
@patch("tools.review_changes.get_git_status") @patch("tools.review_changes.get_git_status")
@patch("tools.review_changes.run_git_command") @patch("tools.review_changes.run_git_command")
async def test_no_changes_found( async def test_no_changes_found(self, mock_run_git, mock_status, mock_find_repos, tool):
self, mock_run_git, mock_status, mock_find_repos, tool
):
"""Test when repositories have no changes""" """Test when repositories have no changes"""
mock_find_repos.return_value = ["/test/repo"] mock_find_repos.return_value = ["/test/repo"]
mock_status.return_value = { mock_status.return_value = {
@@ -167,9 +146,7 @@ class TestReviewChangesTool:
@patch("tools.review_changes.find_git_repositories") @patch("tools.review_changes.find_git_repositories")
@patch("tools.review_changes.get_git_status") @patch("tools.review_changes.get_git_status")
@patch("tools.review_changes.run_git_command") @patch("tools.review_changes.run_git_command")
async def test_compare_to_invalid_ref( async def test_compare_to_invalid_ref(self, mock_run_git, mock_status, mock_find_repos, tool):
self, mock_run_git, mock_status, mock_find_repos, tool
):
"""Test comparing to an invalid git ref""" """Test comparing to an invalid git ref"""
mock_find_repos.return_value = ["/test/repo"] mock_find_repos.return_value = ["/test/repo"]
mock_status.return_value = {"branch": "main"} mock_status.return_value = {"branch": "main"}
@@ -179,9 +156,7 @@ class TestReviewChangesTool:
(False, "fatal: not a valid ref"), # rev-parse fails (False, "fatal: not a valid ref"), # rev-parse fails
] ]
request = ReviewChangesRequest( request = ReviewChangesRequest(path="/absolute/repo/path", compare_to="invalid-branch")
path="/absolute/repo/path", compare_to="invalid-branch"
)
result = await tool.prepare_prompt(request) result = await tool.prepare_prompt(request)
# When all repos have errors and no changes, we get this message # When all repos have errors and no changes, we get this message
@@ -193,9 +168,7 @@ class TestReviewChangesTool:
"""Test execute method integration""" """Test execute method integration"""
# Mock the execute to return a standardized response # Mock the execute to return a standardized response
mock_execute.return_value = [ mock_execute.return_value = [
Mock( Mock(text='{"status": "success", "content": "Review complete", "content_type": "text"}')
text='{"status": "success", "content": "Review complete", "content_type": "text"}'
)
] ]
result = await tool.execute({"path": ".", "review_type": "full"}) result = await tool.execute({"path": ".", "review_type": "full"})
@@ -282,10 +255,7 @@ class TestReviewChangesTool:
] ]
# Mock read_files # Mock read_files
mock_read_files.return_value = ( mock_read_files.return_value = "=== FILE: config.py ===\nCONFIG_VALUE = 42\n=== END FILE ==="
"=== FILE: config.py ===\nCONFIG_VALUE = 42\n=== END FILE ===",
"config.py",
)
request = ReviewChangesRequest( request = ReviewChangesRequest(
path="/absolute/repo/path", path="/absolute/repo/path",
@@ -295,7 +265,7 @@ class TestReviewChangesTool:
# Verify context files are included # Verify context files are included
assert "## Context Files Summary" in result assert "## Context Files Summary" in result
assert "✅ Included: config.py" in result assert "✅ Included: 1 context files" in result
assert "## Additional Context Files" in result assert "## Additional Context Files" in result
assert "=== FILE: config.py ===" in result assert "=== FILE: config.py ===" in result
assert "CONFIG_VALUE = 42" in result assert "CONFIG_VALUE = 42" in result
@@ -336,9 +306,7 @@ class TestReviewChangesTool:
assert "standardized JSON response format" in result assert "standardized JSON response format" in result
# Request with files - should not include instruction # Request with files - should not include instruction
request_with_files = ReviewChangesRequest( request_with_files = ReviewChangesRequest(path="/absolute/repo/path", files=["/some/file.py"])
path="/absolute/repo/path", files=["/some/file.py"]
)
# Need to reset mocks for second call # Need to reset mocks for second call
mock_find_repos.return_value = ["/test/repo"] mock_find_repos.return_value = ["/test/repo"]
@@ -350,7 +318,7 @@ class TestReviewChangesTool:
# Mock read_files to return empty (file not found) # Mock read_files to return empty (file not found)
with patch("tools.review_changes.read_files") as mock_read: with patch("tools.review_changes.read_files") as mock_read:
mock_read.return_value = ("", "") mock_read.return_value = ""
result_with_files = await tool.prepare_prompt(request_with_files) result_with_files = await tool.prepare_prompt(request_with_files)
assert "If you need additional context files" not in result_with_files assert "If you need additional context files" not in result_with_files

View File

@@ -65,7 +65,8 @@ class TestServerTools:
response_data = json.loads(result[0].text) response_data = json.loads(result[0].text)
assert response_data["status"] == "success" assert response_data["status"] == "success"
assert response_data["content"] == "Chat response" assert "Chat response" in response_data["content"]
assert "Claude's Turn" in response_data["content"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_get_version(self): async def test_handle_get_version(self):

View File

@@ -42,9 +42,7 @@ class TestThinkingModes:
"""Test minimal thinking mode""" """Test minimal thinking mode"""
mock_model = Mock() mock_model = Mock()
mock_model.generate_content.return_value = Mock( mock_model.generate_content.return_value = Mock(
candidates=[ candidates=[Mock(content=Mock(parts=[Mock(text="Minimal thinking response")]))]
Mock(content=Mock(parts=[Mock(text="Minimal thinking response")]))
]
) )
mock_create_model.return_value = mock_model mock_create_model.return_value = mock_model
@@ -81,7 +79,11 @@ class TestThinkingModes:
tool = ReviewCodeTool() tool = ReviewCodeTool()
result = await tool.execute( result = await tool.execute(
{"files": ["/absolute/path/test.py"], "thinking_mode": "low"} {
"files": ["/absolute/path/test.py"],
"thinking_mode": "low",
"context": "Test code review for validation purposes",
}
) )
# Verify create_model was called with correct thinking_mode # Verify create_model was called with correct thinking_mode
@@ -97,9 +99,7 @@ class TestThinkingModes:
"""Test medium thinking mode (default for most tools)""" """Test medium thinking mode (default for most tools)"""
mock_model = Mock() mock_model = Mock()
mock_model.generate_content.return_value = Mock( mock_model.generate_content.return_value = Mock(
candidates=[ candidates=[Mock(content=Mock(parts=[Mock(text="Medium thinking response")]))]
Mock(content=Mock(parts=[Mock(text="Medium thinking response")]))
]
) )
mock_create_model.return_value = mock_model mock_create_model.return_value = mock_model
@@ -201,7 +201,7 @@ class TestThinkingModes:
} }
# Check each mode in create_model # Check each mode in create_model
for mode, expected_budget in expected_budgets.items(): for _mode, _expected_budget in expected_budgets.items():
# The budget mapping is inside create_model # The budget mapping is inside create_model
# We can't easily test it without calling the method # We can't easily test it without calling the method
# But we've verified the values are correct in the code # But we've verified the values are correct in the code

View File

@@ -7,8 +7,7 @@ from unittest.mock import Mock, patch
import pytest import pytest
from tools import (AnalyzeTool, ChatTool, DebugIssueTool, ReviewCodeTool, from tools import AnalyzeTool, ChatTool, DebugIssueTool, ReviewCodeTool, ThinkDeeperTool
ThinkDeeperTool)
class TestThinkDeeperTool: class TestThinkDeeperTool:
@@ -70,7 +69,8 @@ class TestReviewCodeTool:
schema = tool.get_input_schema() schema = tool.get_input_schema()
assert "files" in schema["properties"] assert "files" in schema["properties"]
assert schema["required"] == ["files"] assert "context" in schema["properties"]
assert schema["required"] == ["files", "context"]
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("tools.base.BaseTool.create_model") @patch("tools.base.BaseTool.create_model")
@@ -92,6 +92,7 @@ class TestReviewCodeTool:
"files": [str(test_file)], "files": [str(test_file)],
"review_type": "security", "review_type": "security",
"focus_on": "authentication", "focus_on": "authentication",
"context": "Test code review for validation purposes",
} }
) )
@@ -125,9 +126,7 @@ class TestDebugIssueTool:
# Mock model # Mock model
mock_model = Mock() mock_model = Mock()
mock_model.generate_content.return_value = Mock( mock_model.generate_content.return_value = Mock(
candidates=[ candidates=[Mock(content=Mock(parts=[Mock(text="Root cause: race condition")]))]
Mock(content=Mock(parts=[Mock(text="Root cause: race condition")]))
]
) )
mock_create_model.return_value = mock_model mock_create_model.return_value = mock_model
@@ -219,7 +218,11 @@ class TestAbsolutePathValidation:
"""Test that review_code tool rejects relative paths""" """Test that review_code tool rejects relative paths"""
tool = ReviewCodeTool() tool = ReviewCodeTool()
result = await tool.execute( result = await tool.execute(
{"files": ["../parent/file.py"], "review_type": "full"} {
"files": ["../parent/file.py"],
"review_type": "full",
"context": "Test code review for validation purposes",
}
) )
assert len(result) == 1 assert len(result) == 1
@@ -249,9 +252,7 @@ class TestAbsolutePathValidation:
async def test_think_deeper_tool_relative_path_rejected(self): async def test_think_deeper_tool_relative_path_rejected(self):
"""Test that think_deeper tool rejects relative paths""" """Test that think_deeper tool rejects relative paths"""
tool = ThinkDeeperTool() tool = ThinkDeeperTool()
result = await tool.execute( result = await tool.execute({"current_analysis": "My analysis", "files": ["./local/file.py"]})
{"current_analysis": "My analysis", "files": ["./local/file.py"]}
)
assert len(result) == 1 assert len(result) == 1
response = json.loads(result[0].text) response = json.loads(result[0].text)
@@ -291,9 +292,7 @@ class TestAbsolutePathValidation:
mock_instance.generate_content.return_value = mock_response mock_instance.generate_content.return_value = mock_response
mock_model.return_value = mock_instance mock_model.return_value = mock_instance
result = await tool.execute( result = await tool.execute({"files": ["/absolute/path/file.py"], "question": "What does this do?"})
{"files": ["/absolute/path/file.py"], "question": "What does this do?"}
)
assert len(result) == 1 assert len(result) == 1
response = json.loads(result[0].text) response = json.loads(result[0].text)

View File

@@ -2,8 +2,7 @@
Tests for utility functions Tests for utility functions
""" """
from utils import (check_token_limit, estimate_tokens, read_file_content, from utils import check_token_limit, estimate_tokens, read_file_content, read_files
read_files)
class TestFileUtils: class TestFileUtils:
@@ -60,7 +59,7 @@ class TestFileUtils:
file2 = project_path / "file2.py" file2 = project_path / "file2.py"
file2.write_text("print('file2')", encoding="utf-8") file2.write_text("print('file2')", encoding="utf-8")
content, summary = read_files([str(file1), str(file2)]) content = read_files([str(file1), str(file2)])
assert "--- BEGIN FILE:" in content assert "--- BEGIN FILE:" in content
assert "file1.py" in content assert "file1.py" in content
@@ -68,18 +67,20 @@ class TestFileUtils:
assert "print('file1')" in content assert "print('file1')" in content
assert "print('file2')" in content assert "print('file2')" in content
assert "Read 2 file(s)" in summary # Check that both files are included
assert "file1.py" in content and "file2.py" in content
def test_read_files_with_code(self): def test_read_files_with_code(self):
"""Test reading with direct code""" """Test reading with direct code"""
code = "def test():\n pass" code = "def test():\n pass"
content, summary = read_files([], code) content = read_files([], code)
assert "--- BEGIN DIRECT CODE ---" in content assert "--- BEGIN DIRECT CODE ---" in content
assert "--- END DIRECT CODE ---" in content assert "--- END DIRECT CODE ---" in content
assert code in content assert code in content
assert "Direct code:" in summary # Check that direct code is included
assert code in content
def test_read_files_directory_support(self, project_path): def test_read_files_directory_support(self, project_path):
"""Test reading all files from a directory""" """Test reading all files from a directory"""
@@ -97,7 +98,7 @@ class TestFileUtils:
(project_path / ".hidden").write_text("secret", encoding="utf-8") (project_path / ".hidden").write_text("secret", encoding="utf-8")
# Read the directory # Read the directory
content, summary = read_files([str(project_path)]) content = read_files([str(project_path)])
# Check files are included # Check files are included
assert "file1.py" in content assert "file1.py" in content
@@ -117,9 +118,8 @@ class TestFileUtils:
assert ".hidden" not in content assert ".hidden" not in content
assert "secret" not in content assert "secret" not in content
# Check summary # Check that all files are included
assert "Processed 1 dir(s)" in summary assert all(filename in content for filename in ["file1.py", "file2.js", "readme.md", "module.py"])
assert "Read 4 file(s)" in summary
def test_read_files_mixed_paths(self, project_path): def test_read_files_mixed_paths(self, project_path):
"""Test reading mix of files and directories""" """Test reading mix of files and directories"""
@@ -134,7 +134,7 @@ class TestFileUtils:
(subdir / "sub2.py").write_text("# Sub file 2", encoding="utf-8") (subdir / "sub2.py").write_text("# Sub file 2", encoding="utf-8")
# Read mix of direct file and directory # Read mix of direct file and directory
content, summary = read_files([str(file1), str(subdir)]) content = read_files([str(file1), str(subdir)])
assert "direct.py" in content assert "direct.py" in content
assert "sub1.py" in content assert "sub1.py" in content
@@ -143,8 +143,8 @@ class TestFileUtils:
assert "# Sub file 1" in content assert "# Sub file 1" in content
assert "# Sub file 2" in content assert "# Sub file 2" in content
assert "Processed 1 dir(s)" in summary # Check that all files are included
assert "Read 3 file(s)" in summary assert all(filename in content for filename in ["direct.py", "sub1.py", "sub2.py"])
def test_read_files_token_limit(self, project_path): def test_read_files_token_limit(self, project_path):
"""Test token limit handling""" """Test token limit handling"""
@@ -158,10 +158,9 @@ class TestFileUtils:
# Read with small token limit (should skip some files) # Read with small token limit (should skip some files)
# Reserve 50k tokens, limit to 51k total = 1k available # Reserve 50k tokens, limit to 51k total = 1k available
# Each file ~250 tokens, so should read ~3-4 files # Each file ~250 tokens, so should read ~3-4 files
content, summary = read_files([str(project_path)], max_tokens=51_000) content = read_files([str(project_path)], max_tokens=51_000)
assert "Skipped" in summary # Check that token limit handling is present
assert "token limit" in summary
assert "--- SKIPPED FILES (TOKEN LIMIT) ---" in content assert "--- SKIPPED FILES (TOKEN LIMIT) ---" in content
# Count how many files were read # Count how many files were read
@@ -174,11 +173,12 @@ class TestFileUtils:
large_file = project_path / "large.txt" large_file = project_path / "large.txt"
large_file.write_text("x" * 2_000_000, encoding="utf-8") # 2MB large_file.write_text("x" * 2_000_000, encoding="utf-8") # 2MB
content, summary = read_files([str(large_file)]) content = read_files([str(large_file)])
assert "--- FILE TOO LARGE:" in content assert "--- FILE TOO LARGE:" in content
assert "2,000,000 bytes" in content assert "2,000,000 bytes" in content
assert "Read 1 file(s)" in summary # File is counted but shows error message # File too large message should be present
assert "--- FILE TOO LARGE:" in content
def test_read_files_file_extensions(self, project_path): def test_read_files_file_extensions(self, project_path):
"""Test file extension filtering""" """Test file extension filtering"""
@@ -188,7 +188,7 @@ class TestFileUtils:
(project_path / "binary.exe").write_text("exe", encoding="utf-8") (project_path / "binary.exe").write_text("exe", encoding="utf-8")
(project_path / "image.jpg").write_text("jpg", encoding="utf-8") (project_path / "image.jpg").write_text("jpg", encoding="utf-8")
content, summary = read_files([str(project_path)]) content = read_files([str(project_path)])
# Code files should be included # Code files should be included
assert "code.py" in content assert "code.py" in content

View File

@@ -2,7 +2,7 @@
Analyze tool - General-purpose code and file analysis Analyze tool - General-purpose code and file analysis
""" """
from typing import Any, Dict, List, Optional from typing import Any, Optional
from mcp.types import TextContent from mcp.types import TextContent
from pydantic import Field from pydantic import Field
@@ -18,17 +18,13 @@ from .models import ToolOutput
class AnalyzeRequest(ToolRequest): class AnalyzeRequest(ToolRequest):
"""Request model for analyze tool""" """Request model for analyze tool"""
files: List[str] = Field( files: list[str] = Field(..., description="Files or directories to analyze (must be absolute paths)")
..., description="Files or directories to analyze (must be absolute paths)"
)
question: str = Field(..., description="What to analyze or look for") question: str = Field(..., description="What to analyze or look for")
analysis_type: Optional[str] = Field( analysis_type: Optional[str] = Field(
None, None,
description="Type of analysis: architecture|performance|security|quality|general", description="Type of analysis: architecture|performance|security|quality|general",
) )
output_format: Optional[str] = Field( output_format: Optional[str] = Field("detailed", description="Output format: summary|detailed|actionable")
"detailed", description="Output format: summary|detailed|actionable"
)
class AnalyzeTool(BaseTool): class AnalyzeTool(BaseTool):
@@ -47,7 +43,7 @@ class AnalyzeTool(BaseTool):
"Always uses file paths for clean terminal output." "Always uses file paths for clean terminal output."
) )
def get_input_schema(self) -> Dict[str, Any]: def get_input_schema(self) -> dict[str, Any]:
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -101,7 +97,7 @@ class AnalyzeTool(BaseTool):
def get_request_model(self): def get_request_model(self):
return AnalyzeRequest return AnalyzeRequest
async def execute(self, arguments: Dict[str, Any]) -> List[TextContent]: async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
"""Override execute to check question size before processing""" """Override execute to check question size before processing"""
# First validate request # First validate request
request_model = self.get_request_model() request_model = self.get_request_model()
@@ -110,11 +106,7 @@ class AnalyzeTool(BaseTool):
# Check question size # Check question size
size_check = self.check_prompt_size(request.question) size_check = self.check_prompt_size(request.question)
if size_check: if size_check:
return [ return [TextContent(type="text", text=ToolOutput(**size_check).model_dump_json())]
TextContent(
type="text", text=ToolOutput(**size_check).model_dump_json()
)
]
# Continue with normal execution # Continue with normal execution
return await super().execute(arguments) return await super().execute(arguments)
@@ -133,7 +125,7 @@ class AnalyzeTool(BaseTool):
request.files = updated_files request.files = updated_files
# Read all files # Read all files
file_content, summary = read_files(request.files) file_content = read_files(request.files)
# Check token limits # Check token limits
self._validate_token_limit(file_content, "Files") self._validate_token_limit(file_content, "Files")
@@ -154,9 +146,7 @@ class AnalyzeTool(BaseTool):
if request.output_format == "summary": if request.output_format == "summary":
analysis_focus.append("Provide a concise summary of key findings") analysis_focus.append("Provide a concise summary of key findings")
elif request.output_format == "actionable": elif request.output_format == "actionable":
analysis_focus.append( analysis_focus.append("Focus on actionable insights and specific recommendations")
"Focus on actionable insights and specific recommendations"
)
focus_instruction = "\n".join(analysis_focus) if analysis_focus else "" focus_instruction = "\n".join(analysis_focus) if analysis_focus else ""
@@ -185,4 +175,4 @@ Please analyze these files to answer the user's question."""
summary_text = f"Analyzed {len(request.files)} file(s)" summary_text = f"Analyzed {len(request.files)} file(s)"
return f"{header}\n{summary_text}\n{'=' * 50}\n\n{response}" return f"{header}\n{summary_text}\n{'=' * 50}\n\n{response}\n\n---\n\n**Next Steps:** Consider if this analysis reveals areas needing deeper investigation, additional context, or specific implementation details."

View File

@@ -16,7 +16,7 @@ Key responsibilities:
import json import json
import os import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Dict, List, Literal, Optional from typing import Any, Literal, Optional
from google import genai from google import genai
from google.genai import types from google.genai import types
@@ -24,7 +24,7 @@ from mcp.types import TextContent
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from config import MCP_PROMPT_SIZE_LIMIT from config import MCP_PROMPT_SIZE_LIMIT
from utils.file_utils import read_file_content from utils.file_utils import read_file_content, translate_path_for_environment
from .models import ClarificationRequest, ToolOutput from .models import ClarificationRequest, ToolOutput
@@ -38,12 +38,8 @@ class ToolRequest(BaseModel):
these common fields. these common fields.
""" """
model: Optional[str] = Field( model: Optional[str] = Field(None, description="Model to use (defaults to Gemini 2.5 Pro)")
None, description="Model to use (defaults to Gemini 2.5 Pro)" temperature: Optional[float] = Field(None, description="Temperature for response (tool-specific defaults)")
)
temperature: Optional[float] = Field(
None, description="Temperature for response (tool-specific defaults)"
)
# Thinking mode controls how much computational budget the model uses for reasoning # Thinking mode controls how much computational budget the model uses for reasoning
# Higher values allow for more complex reasoning but increase latency and cost # Higher values allow for more complex reasoning but increase latency and cost
thinking_mode: Optional[Literal["minimal", "low", "medium", "high", "max"]] = Field( thinking_mode: Optional[Literal["minimal", "low", "medium", "high", "max"]] = Field(
@@ -100,7 +96,7 @@ class BaseTool(ABC):
pass pass
@abstractmethod @abstractmethod
def get_input_schema(self) -> Dict[str, Any]: def get_input_schema(self) -> dict[str, Any]:
""" """
Return the JSON Schema that defines this tool's parameters. Return the JSON Schema that defines this tool's parameters.
@@ -197,7 +193,7 @@ class BaseTool(ABC):
return None return None
def check_prompt_size(self, text: str) -> Optional[Dict[str, Any]]: def check_prompt_size(self, text: str) -> Optional[dict[str, Any]]:
""" """
Check if a text field is too large for MCP's token limits. Check if a text field is too large for MCP's token limits.
@@ -231,9 +227,7 @@ class BaseTool(ABC):
} }
return None return None
def handle_prompt_file( def handle_prompt_file(self, files: Optional[list[str]]) -> tuple[Optional[str], Optional[list[str]]]:
self, files: Optional[List[str]]
) -> tuple[Optional[str], Optional[List[str]]]:
""" """
Check for and handle prompt.txt in the files list. Check for and handle prompt.txt in the files list.
@@ -245,7 +239,7 @@ class BaseTool(ABC):
mechanism to bypass token constraints while preserving response capacity. mechanism to bypass token constraints while preserving response capacity.
Args: Args:
files: List of file paths files: List of file paths (will be translated for current environment)
Returns: Returns:
tuple: (prompt_content, updated_files_list) tuple: (prompt_content, updated_files_list)
@@ -257,21 +251,47 @@ class BaseTool(ABC):
updated_files = [] updated_files = []
for file_path in files: for file_path in files:
# Translate path for current environment (Docker/direct)
translated_path = translate_path_for_environment(file_path)
# Check if the filename is exactly "prompt.txt" # Check if the filename is exactly "prompt.txt"
# This ensures we don't match files like "myprompt.txt" or "prompt.txt.bak" # This ensures we don't match files like "myprompt.txt" or "prompt.txt.bak"
if os.path.basename(file_path) == "prompt.txt": if os.path.basename(translated_path) == "prompt.txt":
try: try:
prompt_content = read_file_content(file_path) # Read prompt.txt content and extract just the text
content, _ = read_file_content(translated_path)
# Extract the content between the file markers
if "--- BEGIN FILE:" in content and "--- END FILE:" in content:
lines = content.split("\n")
in_content = False
content_lines = []
for line in lines:
if line.startswith("--- BEGIN FILE:"):
in_content = True
continue
elif line.startswith("--- END FILE:"):
break
elif in_content:
content_lines.append(line)
prompt_content = "\n".join(content_lines)
else:
# Fallback: if it's already raw content (from tests or direct input)
# and doesn't have error markers, use it directly
if not content.startswith("\n--- ERROR"):
prompt_content = content
else:
prompt_content = None
except Exception: except Exception:
# If we can't read the file, we'll just skip it # If we can't read the file, we'll just skip it
# The error will be handled elsewhere # The error will be handled elsewhere
pass pass
else: else:
# Keep the original path in the files list (will be translated later by read_files)
updated_files.append(file_path) updated_files.append(file_path)
return prompt_content, updated_files if updated_files else None return prompt_content, updated_files if updated_files else None
async def execute(self, arguments: Dict[str, Any]) -> List[TextContent]: async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
""" """
Execute the tool with the provided arguments. Execute the tool with the provided arguments.
@@ -338,11 +358,7 @@ class BaseTool(ABC):
else: else:
# Handle cases where the model couldn't generate a response # Handle cases where the model couldn't generate a response
# This might happen due to safety filters or other constraints # This might happen due to safety filters or other constraints
finish_reason = ( finish_reason = response.candidates[0].finish_reason if response.candidates else "Unknown"
response.candidates[0].finish_reason
if response.candidates
else "Unknown"
)
tool_output = ToolOutput( tool_output = ToolOutput(
status="error", status="error",
content=f"Response blocked or incomplete. Finish reason: {finish_reason}", content=f"Response blocked or incomplete. Finish reason: {finish_reason}",
@@ -380,10 +396,7 @@ class BaseTool(ABC):
# Try to parse as JSON to check for clarification requests # Try to parse as JSON to check for clarification requests
potential_json = json.loads(raw_text.strip()) potential_json = json.loads(raw_text.strip())
if ( if isinstance(potential_json, dict) and potential_json.get("status") == "requires_clarification":
isinstance(potential_json, dict)
and potential_json.get("status") == "requires_clarification"
):
# Validate the clarification request structure # Validate the clarification request structure
clarification = ClarificationRequest(**potential_json) clarification = ClarificationRequest(**potential_json)
return ToolOutput( return ToolOutput(
@@ -391,11 +404,7 @@ class BaseTool(ABC):
content=clarification.model_dump_json(), content=clarification.model_dump_json(),
content_type="json", content_type="json",
metadata={ metadata={
"original_request": ( "original_request": (request.model_dump() if hasattr(request, "model_dump") else str(request))
request.model_dump()
if hasattr(request, "model_dump")
else str(request)
)
}, },
) )
@@ -408,11 +417,7 @@ class BaseTool(ABC):
# Determine content type based on the formatted content # Determine content type based on the formatted content
content_type = ( content_type = (
"markdown" "markdown" if any(marker in formatted_content for marker in ["##", "**", "`", "- ", "1. "]) else "text"
if any(
marker in formatted_content for marker in ["##", "**", "`", "- ", "1. "]
)
else "text"
) )
return ToolOutput( return ToolOutput(
@@ -479,9 +484,7 @@ class BaseTool(ABC):
f"Maximum is {MAX_CONTEXT_TOKENS:,} tokens." f"Maximum is {MAX_CONTEXT_TOKENS:,} tokens."
) )
def create_model( def create_model(self, model_name: str, temperature: float, thinking_mode: str = "medium"):
self, model_name: str, temperature: float, thinking_mode: str = "medium"
):
""" """
Create a configured Gemini model instance. Create a configured Gemini model instance.
@@ -522,9 +525,7 @@ class BaseTool(ABC):
# Create a wrapper class to provide a consistent interface # Create a wrapper class to provide a consistent interface
# This abstracts the differences between API versions # This abstracts the differences between API versions
class ModelWrapper: class ModelWrapper:
def __init__( def __init__(self, client, model_name, temperature, thinking_budget):
self, client, model_name, temperature, thinking_budget
):
self.client = client self.client = client
self.model_name = model_name self.model_name = model_name
self.temperature = temperature self.temperature = temperature
@@ -537,9 +538,7 @@ class BaseTool(ABC):
config=types.GenerateContentConfig( config=types.GenerateContentConfig(
temperature=self.temperature, temperature=self.temperature,
candidate_count=1, candidate_count=1,
thinking_config=types.ThinkingConfig( thinking_config=types.ThinkingConfig(thinking_budget=self.thinking_budget),
thinking_budget=self.thinking_budget
),
), ),
) )
@@ -617,11 +616,7 @@ class BaseTool(ABC):
"content": type( "content": type(
"obj", "obj",
(object,), (object,),
{ {"parts": [type("obj", (object,), {"text": text})]},
"parts": [
type("obj", (object,), {"text": text})
]
},
)(), )(),
"finish_reason": "STOP", "finish_reason": "STOP",
}, },

View File

@@ -2,7 +2,7 @@
Chat tool - General development chat and collaborative thinking Chat tool - General development chat and collaborative thinking
""" """
from typing import Any, Dict, List, Optional from typing import Any, Optional
from mcp.types import TextContent from mcp.types import TextContent
from pydantic import Field from pydantic import Field
@@ -22,7 +22,7 @@ class ChatRequest(ToolRequest):
..., ...,
description="Your question, topic, or current thinking to discuss with Gemini", description="Your question, topic, or current thinking to discuss with Gemini",
) )
files: Optional[List[str]] = Field( files: Optional[list[str]] = Field(
default_factory=list, default_factory=list,
description="Optional files for context (must be absolute paths)", description="Optional files for context (must be absolute paths)",
) )
@@ -44,7 +44,7 @@ class ChatTool(BaseTool):
"'share my thinking with gemini', 'explain', 'what is', 'how do I'." "'share my thinking with gemini', 'explain', 'what is', 'how do I'."
) )
def get_input_schema(self) -> Dict[str, Any]: def get_input_schema(self) -> dict[str, Any]:
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -81,7 +81,7 @@ class ChatTool(BaseTool):
def get_request_model(self): def get_request_model(self):
return ChatRequest return ChatRequest
async def execute(self, arguments: Dict[str, Any]) -> List[TextContent]: async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
"""Override execute to check prompt size before processing""" """Override execute to check prompt size before processing"""
# First validate request # First validate request
request_model = self.get_request_model() request_model = self.get_request_model()
@@ -90,11 +90,7 @@ class ChatTool(BaseTool):
# Check prompt size # Check prompt size
size_check = self.check_prompt_size(request.prompt) size_check = self.check_prompt_size(request.prompt)
if size_check: if size_check:
return [ return [TextContent(type="text", text=ToolOutput(**size_check).model_dump_json())]
TextContent(
type="text", text=ToolOutput(**size_check).model_dump_json()
)
]
# Continue with normal execution # Continue with normal execution
return await super().execute(arguments) return await super().execute(arguments)
@@ -113,7 +109,7 @@ class ChatTool(BaseTool):
# Add context files if provided # Add context files if provided
if request.files: if request.files:
file_content, _ = read_files(request.files) file_content = read_files(request.files)
user_content = f"{user_content}\n\n=== CONTEXT FILES ===\n{file_content}\n=== END CONTEXT ====" user_content = f"{user_content}\n\n=== CONTEXT FILES ===\n{file_content}\n=== END CONTEXT ===="
# Check token limits # Check token limits
@@ -131,5 +127,5 @@ Please provide a thoughtful, comprehensive response:"""
return full_prompt return full_prompt
def format_response(self, response: str, request: ChatRequest) -> str: def format_response(self, response: str, request: ChatRequest) -> str:
"""Format the chat response (no special formatting needed)""" """Format the chat response with actionable guidance"""
return response return f"{response}\n\n---\n\n**Claude's Turn:** Evaluate this perspective alongside your analysis to form a comprehensive solution."

View File

@@ -2,7 +2,7 @@
Debug Issue tool - Root cause analysis and debugging assistance Debug Issue tool - Root cause analysis and debugging assistance
""" """
from typing import Any, Dict, List, Optional from typing import Any, Optional
from mcp.types import TextContent from mcp.types import TextContent
from pydantic import Field from pydantic import Field
@@ -18,22 +18,14 @@ from .models import ToolOutput
class DebugIssueRequest(ToolRequest): class DebugIssueRequest(ToolRequest):
"""Request model for debug_issue tool""" """Request model for debug_issue tool"""
error_description: str = Field( error_description: str = Field(..., description="Error message, symptoms, or issue description")
..., description="Error message, symptoms, or issue description" error_context: Optional[str] = Field(None, description="Stack trace, logs, or additional error context")
) files: Optional[list[str]] = Field(
error_context: Optional[str] = Field(
None, description="Stack trace, logs, or additional error context"
)
files: Optional[List[str]] = Field(
None, None,
description="Files or directories that might be related to the issue (must be absolute paths)", description="Files or directories that might be related to the issue (must be absolute paths)",
) )
runtime_info: Optional[str] = Field( runtime_info: Optional[str] = Field(None, description="Environment, versions, or runtime information")
None, description="Environment, versions, or runtime information" previous_attempts: Optional[str] = Field(None, description="What has been tried already")
)
previous_attempts: Optional[str] = Field(
None, description="What has been tried already"
)
class DebugIssueTool(BaseTool): class DebugIssueTool(BaseTool):
@@ -48,10 +40,13 @@ class DebugIssueTool(BaseTool):
"Use this when you need help tracking down bugs or understanding errors. " "Use this when you need help tracking down bugs or understanding errors. "
"Triggers: 'debug this', 'why is this failing', 'root cause', 'trace error'. " "Triggers: 'debug this', 'why is this failing', 'root cause', 'trace error'. "
"I'll analyze the issue, find root causes, and provide step-by-step solutions. " "I'll analyze the issue, find root causes, and provide step-by-step solutions. "
"Include error messages, stack traces, and relevant code for best results." "Include error messages, stack traces, and relevant code for best results. "
"Choose thinking_mode based on issue complexity: 'low' for simple errors, "
"'medium' for standard debugging (default), 'high' for complex system issues, "
"'max' for extremely challenging bugs requiring deepest analysis."
) )
def get_input_schema(self) -> Dict[str, Any]: def get_input_schema(self) -> dict[str, Any]:
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -100,7 +95,7 @@ class DebugIssueTool(BaseTool):
def get_request_model(self): def get_request_model(self):
return DebugIssueRequest return DebugIssueRequest
async def execute(self, arguments: Dict[str, Any]) -> List[TextContent]: async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
"""Override execute to check error_description and error_context size before processing""" """Override execute to check error_description and error_context size before processing"""
# First validate request # First validate request
request_model = self.get_request_model() request_model = self.get_request_model()
@@ -109,21 +104,13 @@ class DebugIssueTool(BaseTool):
# Check error_description size # Check error_description size
size_check = self.check_prompt_size(request.error_description) size_check = self.check_prompt_size(request.error_description)
if size_check: if size_check:
return [ return [TextContent(type="text", text=ToolOutput(**size_check).model_dump_json())]
TextContent(
type="text", text=ToolOutput(**size_check).model_dump_json()
)
]
# Check error_context size if provided # Check error_context size if provided
if request.error_context: if request.error_context:
size_check = self.check_prompt_size(request.error_context) size_check = self.check_prompt_size(request.error_context)
if size_check: if size_check:
return [ return [TextContent(type="text", text=ToolOutput(**size_check).model_dump_json())]
TextContent(
type="text", text=ToolOutput(**size_check).model_dump_json()
)
]
# Continue with normal execution # Continue with normal execution
return await super().execute(arguments) return await super().execute(arguments)
@@ -146,31 +133,21 @@ class DebugIssueTool(BaseTool):
request.files = updated_files request.files = updated_files
# Build context sections # Build context sections
context_parts = [ context_parts = [f"=== ISSUE DESCRIPTION ===\n{request.error_description}\n=== END DESCRIPTION ==="]
f"=== ISSUE DESCRIPTION ===\n{request.error_description}\n=== END DESCRIPTION ==="
]
if request.error_context: if request.error_context:
context_parts.append( context_parts.append(f"\n=== ERROR CONTEXT/STACK TRACE ===\n{request.error_context}\n=== END CONTEXT ===")
f"\n=== ERROR CONTEXT/STACK TRACE ===\n{request.error_context}\n=== END CONTEXT ==="
)
if request.runtime_info: if request.runtime_info:
context_parts.append( context_parts.append(f"\n=== RUNTIME INFORMATION ===\n{request.runtime_info}\n=== END RUNTIME ===")
f"\n=== RUNTIME INFORMATION ===\n{request.runtime_info}\n=== END RUNTIME ==="
)
if request.previous_attempts: if request.previous_attempts:
context_parts.append( context_parts.append(f"\n=== PREVIOUS ATTEMPTS ===\n{request.previous_attempts}\n=== END ATTEMPTS ===")
f"\n=== PREVIOUS ATTEMPTS ===\n{request.previous_attempts}\n=== END ATTEMPTS ==="
)
# Add relevant files if provided # Add relevant files if provided
if request.files: if request.files:
file_content, _ = read_files(request.files) file_content = read_files(request.files)
context_parts.append( context_parts.append(f"\n=== RELEVANT CODE ===\n{file_content}\n=== END CODE ===")
f"\n=== RELEVANT CODE ===\n{file_content}\n=== END CODE ==="
)
full_context = "\n".join(context_parts) full_context = "\n".join(context_parts)
@@ -189,4 +166,4 @@ Focus on finding the root cause and providing actionable solutions."""
def format_response(self, response: str, request: DebugIssueRequest) -> str: def format_response(self, response: str, request: DebugIssueRequest) -> str:
"""Format the debugging response""" """Format the debugging response"""
return f"Debug Analysis\n{'=' * 50}\n\n{response}" return f"Debug Analysis\n{'=' * 50}\n\n{response}\n\n---\n\n**Next Steps:** Evaluate Gemini's recommendations, synthesize the best fix considering potential regressions, test thoroughly, and ensure the solution doesn't introduce new issues."

View File

@@ -2,7 +2,7 @@
Data models for tool responses and interactions Data models for tool responses and interactions
""" """
from typing import Any, Dict, List, Literal, Optional from typing import Any, Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -10,22 +10,20 @@ from pydantic import BaseModel, Field
class ToolOutput(BaseModel): class ToolOutput(BaseModel):
"""Standardized output format for all tools""" """Standardized output format for all tools"""
status: Literal[ status: Literal["success", "error", "requires_clarification", "requires_file_prompt"] = "success"
"success", "error", "requires_clarification", "requires_file_prompt"
] = "success"
content: str = Field(..., description="The main content/response from the tool") content: str = Field(..., description="The main content/response from the tool")
content_type: Literal["text", "markdown", "json"] = "text" content_type: Literal["text", "markdown", "json"] = "text"
metadata: Optional[Dict[str, Any]] = Field(default_factory=dict) metadata: Optional[dict[str, Any]] = Field(default_factory=dict)
class ClarificationRequest(BaseModel): class ClarificationRequest(BaseModel):
"""Request for additional context or clarification""" """Request for additional context or clarification"""
question: str = Field(..., description="Question to ask Claude for more context") question: str = Field(..., description="Question to ask Claude for more context")
files_needed: Optional[List[str]] = Field( files_needed: Optional[list[str]] = Field(
default_factory=list, description="Specific files that are needed for analysis" default_factory=list, description="Specific files that are needed for analysis"
) )
suggested_next_action: Optional[Dict[str, Any]] = Field( suggested_next_action: Optional[dict[str, Any]] = Field(
None, None,
description="Suggested tool call with parameters after getting clarification", description="Suggested tool call with parameters after getting clarification",
) )
@@ -35,28 +33,22 @@ class DiagnosticHypothesis(BaseModel):
"""A debugging hypothesis with context and next steps""" """A debugging hypothesis with context and next steps"""
rank: int = Field(..., description="Ranking of this hypothesis (1 = most likely)") rank: int = Field(..., description="Ranking of this hypothesis (1 = most likely)")
confidence: Literal["high", "medium", "low"] = Field( confidence: Literal["high", "medium", "low"] = Field(..., description="Confidence level")
..., description="Confidence level"
)
hypothesis: str = Field(..., description="Description of the potential root cause") hypothesis: str = Field(..., description="Description of the potential root cause")
reasoning: str = Field(..., description="Why this hypothesis is plausible") reasoning: str = Field(..., description="Why this hypothesis is plausible")
next_step: str = Field( next_step: str = Field(..., description="Suggested action to test/validate this hypothesis")
..., description="Suggested action to test/validate this hypothesis"
)
class StructuredDebugResponse(BaseModel): class StructuredDebugResponse(BaseModel):
"""Enhanced debug response with multiple hypotheses""" """Enhanced debug response with multiple hypotheses"""
summary: str = Field(..., description="Brief summary of the issue") summary: str = Field(..., description="Brief summary of the issue")
hypotheses: List[DiagnosticHypothesis] = Field( hypotheses: list[DiagnosticHypothesis] = Field(..., description="Ranked list of potential causes")
..., description="Ranked list of potential causes" immediate_actions: list[str] = Field(
)
immediate_actions: List[str] = Field(
default_factory=list, default_factory=list,
description="Immediate steps to take regardless of root cause", description="Immediate steps to take regardless of root cause",
) )
additional_context_needed: Optional[List[str]] = Field( additional_context_needed: Optional[list[str]] = Field(
default_factory=list, default_factory=list,
description="Additional files or information that would help with analysis", description="Additional files or information that would help with analysis",
) )

View File

@@ -3,17 +3,15 @@ Tool for reviewing pending git changes across multiple repositories.
""" """
import os import os
import re from typing import Any, Literal, Optional
from typing import Any, Dict, List, Literal, Optional
from mcp.types import TextContent from mcp.types import TextContent
from pydantic import Field from pydantic import Field
from config import MAX_CONTEXT_TOKENS from config import MAX_CONTEXT_TOKENS
from prompts.tool_prompts import REVIEW_CHANGES_PROMPT from prompts.tool_prompts import REVIEW_CHANGES_PROMPT
from utils.file_utils import _get_secure_container_path, read_files from utils.file_utils import read_files, translate_path_for_environment
from utils.git_utils import (find_git_repositories, get_git_status, from utils.git_utils import find_git_repositories, get_git_status, run_git_command
run_git_command)
from utils.token_utils import estimate_tokens from utils.token_utils import estimate_tokens
from .base import BaseTool, ToolRequest from .base import BaseTool, ToolRequest
@@ -67,7 +65,7 @@ class ReviewChangesRequest(ToolRequest):
thinking_mode: Optional[Literal["minimal", "low", "medium", "high", "max"]] = Field( thinking_mode: Optional[Literal["minimal", "low", "medium", "high", "max"]] = Field(
None, description="Thinking depth mode for the assistant." None, description="Thinking depth mode for the assistant."
) )
files: Optional[List[str]] = Field( files: Optional[list[str]] = Field(
None, None,
description="Optional files or directories to provide as context (must be absolute paths). These files are not part of the changes but provide helpful context like configs, docs, or related code.", description="Optional files or directories to provide as context (must be absolute paths). These files are not part of the changes but provide helpful context like configs, docs, or related code.",
) )
@@ -87,10 +85,13 @@ class ReviewChanges(BaseTool):
"provides deep analysis of staged/unstaged changes. Essential for code quality and preventing bugs. " "provides deep analysis of staged/unstaged changes. Essential for code quality and preventing bugs. "
"Triggers: 'before commit', 'review changes', 'check my changes', 'validate changes', 'pre-commit review', " "Triggers: 'before commit', 'review changes', 'check my changes', 'validate changes', 'pre-commit review', "
"'about to commit', 'ready to commit'. Claude should proactively suggest using this tool whenever " "'about to commit', 'ready to commit'. Claude should proactively suggest using this tool whenever "
"the user mentions committing or when changes are complete." "the user mentions committing or when changes are complete. "
"Choose thinking_mode based on changeset size: 'low' for small focused changes, "
"'medium' for standard commits (default), 'high' for large feature branches or complex refactoring, "
"'max' for critical releases or when reviewing extensive changes across multiple systems."
) )
def get_input_schema(self) -> Dict[str, Any]: def get_input_schema(self) -> dict[str, Any]:
return self.get_request_model().model_json_schema() return self.get_request_model().model_json_schema()
def get_system_prompt(self) -> str: def get_system_prompt(self) -> str:
@@ -105,16 +106,7 @@ class ReviewChanges(BaseTool):
return TEMPERATURE_ANALYTICAL return TEMPERATURE_ANALYTICAL
def _sanitize_filename(self, name: str) -> str: async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
"""Sanitize a string to be a valid filename."""
# Replace path separators and other problematic characters
name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
# Remove any remaining non-alphanumeric characters except dots, dashes, underscores
name = re.sub(r"[^a-zA-Z0-9._-]", "", name)
# Limit length to avoid filesystem issues
return name[:100]
async def execute(self, arguments: Dict[str, Any]) -> List[TextContent]:
"""Override execute to check original_request size before processing""" """Override execute to check original_request size before processing"""
# First validate request # First validate request
request_model = self.get_request_model() request_model = self.get_request_model()
@@ -124,11 +116,7 @@ class ReviewChanges(BaseTool):
if request.original_request: if request.original_request:
size_check = self.check_prompt_size(request.original_request) size_check = self.check_prompt_size(request.original_request)
if size_check: if size_check:
return [ return [TextContent(type="text", text=ToolOutput(**size_check).model_dump_json())]
TextContent(
type="text", text=ToolOutput(**size_check).model_dump_json()
)
]
# Continue with normal execution # Continue with normal execution
return await super().execute(arguments) return await super().execute(arguments)
@@ -147,7 +135,7 @@ class ReviewChanges(BaseTool):
request.files = updated_files request.files = updated_files
# Translate the path if running in Docker # Translate the path if running in Docker
translated_path = _get_secure_container_path(request.path) translated_path = translate_path_for_environment(request.path)
# Check if the path translation resulted in an error path # Check if the path translation resulted in an error path
if translated_path.startswith("/inaccessible/"): if translated_path.startswith("/inaccessible/"):
@@ -167,13 +155,10 @@ class ReviewChanges(BaseTool):
all_diffs = [] all_diffs = []
repo_summaries = [] repo_summaries = []
total_tokens = 0 total_tokens = 0
max_tokens = ( max_tokens = MAX_CONTEXT_TOKENS - 50000 # Reserve tokens for prompt and response
MAX_CONTEXT_TOKENS - 50000
) # Reserve tokens for prompt and response
for repo_path in repositories: for repo_path in repositories:
repo_name = os.path.basename(repo_path) or "root" repo_name = os.path.basename(repo_path) or "root"
repo_name = self._sanitize_filename(repo_name)
# Get status information # Get status information
status = get_git_status(repo_path) status = get_git_status(repo_path)
@@ -217,10 +202,10 @@ class ReviewChanges(BaseTool):
) )
if success and diff.strip(): if success and diff.strip():
# Format diff with file header # Format diff with file header
diff_header = f"\n--- BEGIN DIFF: {repo_name} / {file_path} (compare to {request.compare_to}) ---\n" diff_header = (
diff_footer = ( f"\n--- BEGIN DIFF: {repo_name} / {file_path} (compare to {request.compare_to}) ---\n"
f"\n--- END DIFF: {repo_name} / {file_path} ---\n"
) )
diff_footer = f"\n--- END DIFF: {repo_name} / {file_path} ---\n"
formatted_diff = diff_header + diff + diff_footer formatted_diff = diff_header + diff + diff_footer
# Check token limit # Check token limit
@@ -234,58 +219,38 @@ class ReviewChanges(BaseTool):
unstaged_files = [] unstaged_files = []
if request.include_staged: if request.include_staged:
success, files_output = run_git_command( success, files_output = run_git_command(repo_path, ["diff", "--name-only", "--cached"])
repo_path, ["diff", "--name-only", "--cached"]
)
if success and files_output.strip(): if success and files_output.strip():
staged_files = [ staged_files = [f for f in files_output.strip().split("\n") if f]
f for f in files_output.strip().split("\n") if f
]
# Generate per-file diffs for staged changes # Generate per-file diffs for staged changes
for file_path in staged_files: for file_path in staged_files:
success, diff = run_git_command( success, diff = run_git_command(repo_path, ["diff", "--cached", "--", file_path])
repo_path, ["diff", "--cached", "--", file_path]
)
if success and diff.strip(): if success and diff.strip():
diff_header = f"\n--- BEGIN DIFF: {repo_name} / {file_path} (staged) ---\n" diff_header = f"\n--- BEGIN DIFF: {repo_name} / {file_path} (staged) ---\n"
diff_footer = ( diff_footer = f"\n--- END DIFF: {repo_name} / {file_path} ---\n"
f"\n--- END DIFF: {repo_name} / {file_path} ---\n"
)
formatted_diff = diff_header + diff + diff_footer formatted_diff = diff_header + diff + diff_footer
# Check token limit # Check token limit
from utils import estimate_tokens
diff_tokens = estimate_tokens(formatted_diff) diff_tokens = estimate_tokens(formatted_diff)
if total_tokens + diff_tokens <= max_tokens: if total_tokens + diff_tokens <= max_tokens:
all_diffs.append(formatted_diff) all_diffs.append(formatted_diff)
total_tokens += diff_tokens total_tokens += diff_tokens
if request.include_unstaged: if request.include_unstaged:
success, files_output = run_git_command( success, files_output = run_git_command(repo_path, ["diff", "--name-only"])
repo_path, ["diff", "--name-only"]
)
if success and files_output.strip(): if success and files_output.strip():
unstaged_files = [ unstaged_files = [f for f in files_output.strip().split("\n") if f]
f for f in files_output.strip().split("\n") if f
]
# Generate per-file diffs for unstaged changes # Generate per-file diffs for unstaged changes
for file_path in unstaged_files: for file_path in unstaged_files:
success, diff = run_git_command( success, diff = run_git_command(repo_path, ["diff", "--", file_path])
repo_path, ["diff", "--", file_path]
)
if success and diff.strip(): if success and diff.strip():
diff_header = f"\n--- BEGIN DIFF: {repo_name} / {file_path} (unstaged) ---\n" diff_header = f"\n--- BEGIN DIFF: {repo_name} / {file_path} (unstaged) ---\n"
diff_footer = ( diff_footer = f"\n--- END DIFF: {repo_name} / {file_path} ---\n"
f"\n--- END DIFF: {repo_name} / {file_path} ---\n"
)
formatted_diff = diff_header + diff + diff_footer formatted_diff = diff_header + diff + diff_footer
# Check token limit # Check token limit
from utils import estimate_tokens
diff_tokens = estimate_tokens(formatted_diff) diff_tokens = estimate_tokens(formatted_diff)
if total_tokens + diff_tokens <= max_tokens: if total_tokens + diff_tokens <= max_tokens:
all_diffs.append(formatted_diff) all_diffs.append(formatted_diff)
@@ -310,7 +275,7 @@ class ReviewChanges(BaseTool):
if not all_diffs: if not all_diffs:
return "No pending changes found in any of the git repositories." return "No pending changes found in any of the git repositories."
# Process context files if provided # Process context files if provided using standardized file reading
context_files_content = [] context_files_content = []
context_files_summary = [] context_files_summary = []
context_tokens = 0 context_tokens = 0
@@ -318,40 +283,17 @@ class ReviewChanges(BaseTool):
if request.files: if request.files:
remaining_tokens = max_tokens - total_tokens remaining_tokens = max_tokens - total_tokens
# Read context files with remaining token budget # Use standardized file reading with token budget
file_content, file_summary = read_files(request.files) file_content = read_files(
request.files, max_tokens=remaining_tokens, reserve_tokens=1000 # Small reserve for formatting
)
# Check if context files fit in remaining budget
if file_content: if file_content:
context_tokens = estimate_tokens(file_content) context_tokens = estimate_tokens(file_content)
context_files_content = [file_content]
if context_tokens <= remaining_tokens: context_files_summary.append(f"✅ Included: {len(request.files)} context files")
# Use the full content from read_files else:
context_files_content = [file_content] context_files_summary.append("⚠️ No context files could be read or files too large")
# Parse summary to create individual file summaries
summary_lines = file_summary.split("\n")
for line in summary_lines:
if line.strip() and not line.startswith("Total files:"):
context_files_summary.append(f"✅ Included: {line.strip()}")
else:
context_files_summary.append(
f"⚠️ Context files too large (~{context_tokens:,} tokens, budget: ~{remaining_tokens:,} tokens)"
)
# Include as much as fits
if remaining_tokens > 1000: # Only if we have reasonable space
truncated_content = file_content[
: int(
len(file_content)
* (remaining_tokens / context_tokens)
* 0.9
)
]
context_files_content.append(
f"\n--- BEGIN CONTEXT FILES (TRUNCATED) ---\n{truncated_content}\n--- END CONTEXT FILES ---\n"
)
context_tokens = remaining_tokens
else:
context_tokens = 0
total_tokens += context_tokens total_tokens += context_tokens
@@ -360,9 +302,7 @@ class ReviewChanges(BaseTool):
# Add original request context if provided # Add original request context if provided
if request.original_request: if request.original_request:
prompt_parts.append( prompt_parts.append(f"## Original Request/Ticket\n\n{request.original_request}\n")
f"## Original Request/Ticket\n\n{request.original_request}\n"
)
# Add review parameters # Add review parameters
prompt_parts.append("## Review Parameters\n") prompt_parts.append("## Review Parameters\n")
@@ -393,9 +333,7 @@ class ReviewChanges(BaseTool):
else: else:
prompt_parts.append(f"- Branch: {summary['branch']}") prompt_parts.append(f"- Branch: {summary['branch']}")
if summary["ahead"] or summary["behind"]: if summary["ahead"] or summary["behind"]:
prompt_parts.append( prompt_parts.append(f"- Ahead: {summary['ahead']}, Behind: {summary['behind']}")
f"- Ahead: {summary['ahead']}, Behind: {summary['behind']}"
)
prompt_parts.append(f"- Changed Files: {summary['changed_files']}") prompt_parts.append(f"- Changed Files: {summary['changed_files']}")
if summary["files"]: if summary["files"]:
@@ -403,9 +341,7 @@ class ReviewChanges(BaseTool):
for file in summary["files"]: for file in summary["files"]:
prompt_parts.append(f" - {file}") prompt_parts.append(f" - {file}")
if summary["changed_files"] > len(summary["files"]): if summary["changed_files"] > len(summary["files"]):
prompt_parts.append( prompt_parts.append(f" ... and {summary['changed_files'] - len(summary['files'])} more files")
f" ... and {summary['changed_files'] - len(summary['files'])} more files"
)
# Add context files summary if provided # Add context files summary if provided
if context_files_summary: if context_files_summary:
@@ -449,3 +385,7 @@ class ReviewChanges(BaseTool):
) )
return "\n".join(prompt_parts) return "\n".join(prompt_parts)
def format_response(self, response: str, request: ReviewChangesRequest) -> str:
"""Format the response with commit guidance"""
return f"{response}\n\n---\n\n**Commit Status:** If no critical issues found, changes are ready for commit. Otherwise, address issues first and re-run review. Check with user before proceeding with any commit."

View File

@@ -14,7 +14,7 @@ Key Features:
- Structured output with specific remediation steps - Structured output with specific remediation steps
""" """
from typing import Any, Dict, List, Optional from typing import Any, Optional
from mcp.types import TextContent from mcp.types import TextContent
from pydantic import Field from pydantic import Field
@@ -36,19 +36,17 @@ class ReviewCodeRequest(ToolRequest):
review focus and standards. review focus and standards.
""" """
files: List[str] = Field( files: list[str] = Field(
..., ...,
description="Code files or directories to review (must be absolute paths)", description="Code files or directories to review (must be absolute paths)",
) )
review_type: str = Field( context: str = Field(
"full", description="Type of review: full|security|performance|quick" ...,
) description="User's summary of what the code does, expected behavior, constraints, and review objectives",
focus_on: Optional[str] = Field(
None, description="Specific aspects to focus on during review"
)
standards: Optional[str] = Field(
None, description="Coding standards or guidelines to enforce"
) )
review_type: str = Field("full", description="Type of review: full|security|performance|quick")
focus_on: Optional[str] = Field(None, description="Specific aspects to focus on during review")
standards: Optional[str] = Field(None, description="Coding standards or guidelines to enforce")
severity_filter: str = Field( severity_filter: str = Field(
"all", "all",
description="Minimum severity to report: critical|high|medium|all", description="Minimum severity to report: critical|high|medium|all",
@@ -74,10 +72,13 @@ class ReviewCodeTool(BaseTool):
"Use this for thorough code review with actionable feedback. " "Use this for thorough code review with actionable feedback. "
"Triggers: 'review this code', 'check for issues', 'find bugs', 'security audit'. " "Triggers: 'review this code', 'check for issues', 'find bugs', 'security audit'. "
"I'll identify issues by severity (Critical→High→Medium→Low) with specific fixes. " "I'll identify issues by severity (Critical→High→Medium→Low) with specific fixes. "
"Supports focused reviews: security, performance, or quick checks." "Supports focused reviews: security, performance, or quick checks. "
"Choose thinking_mode based on review scope: 'low' for small code snippets, "
"'medium' for standard files/modules (default), 'high' for complex systems/architectures, "
"'max' for critical security audits or large codebases requiring deepest analysis."
) )
def get_input_schema(self) -> Dict[str, Any]: def get_input_schema(self) -> dict[str, Any]:
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -86,6 +87,10 @@ class ReviewCodeTool(BaseTool):
"items": {"type": "string"}, "items": {"type": "string"},
"description": "Code files or directories to review (must be absolute paths)", "description": "Code files or directories to review (must be absolute paths)",
}, },
"context": {
"type": "string",
"description": "User's summary of what the code does, expected behavior, constraints, and review objectives",
},
"review_type": { "review_type": {
"type": "string", "type": "string",
"enum": ["full", "security", "performance", "quick"], "enum": ["full", "security", "performance", "quick"],
@@ -118,7 +123,7 @@ class ReviewCodeTool(BaseTool):
"description": "Thinking depth: minimal (128), low (2048), medium (8192), high (16384), max (32768)", "description": "Thinking depth: minimal (128), low (2048), medium (8192), high (16384), max (32768)",
}, },
}, },
"required": ["files"], "required": ["files", "context"],
} }
def get_system_prompt(self) -> str: def get_system_prompt(self) -> str:
@@ -130,7 +135,7 @@ class ReviewCodeTool(BaseTool):
def get_request_model(self): def get_request_model(self):
return ReviewCodeRequest return ReviewCodeRequest
async def execute(self, arguments: Dict[str, Any]) -> List[TextContent]: async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
"""Override execute to check focus_on size before processing""" """Override execute to check focus_on size before processing"""
# First validate request # First validate request
request_model = self.get_request_model() request_model = self.get_request_model()
@@ -140,11 +145,7 @@ class ReviewCodeTool(BaseTool):
if request.focus_on: if request.focus_on:
size_check = self.check_prompt_size(request.focus_on) size_check = self.check_prompt_size(request.focus_on)
if size_check: if size_check:
return [ return [TextContent(type="text", text=ToolOutput(**size_check).model_dump_json())]
TextContent(
type="text", text=ToolOutput(**size_check).model_dump_json()
)
]
# Continue with normal execution # Continue with normal execution
return await super().execute(arguments) return await super().execute(arguments)
@@ -177,7 +178,7 @@ class ReviewCodeTool(BaseTool):
request.files = updated_files request.files = updated_files
# Read all requested files, expanding directories as needed # Read all requested files, expanding directories as needed
file_content, summary = read_files(request.files) file_content = read_files(request.files)
# Validate that the code fits within model context limits # Validate that the code fits within model context limits
self._validate_token_limit(file_content, "Code") self._validate_token_limit(file_content, "Code")
@@ -185,17 +186,11 @@ class ReviewCodeTool(BaseTool):
# Build customized review instructions based on review type # Build customized review instructions based on review type
review_focus = [] review_focus = []
if request.review_type == "security": if request.review_type == "security":
review_focus.append( review_focus.append("Focus on security vulnerabilities and authentication issues")
"Focus on security vulnerabilities and authentication issues"
)
elif request.review_type == "performance": elif request.review_type == "performance":
review_focus.append( review_focus.append("Focus on performance bottlenecks and optimization opportunities")
"Focus on performance bottlenecks and optimization opportunities"
)
elif request.review_type == "quick": elif request.review_type == "quick":
review_focus.append( review_focus.append("Provide a quick review focusing on critical issues only")
"Provide a quick review focusing on critical issues only"
)
# Add any additional focus areas specified by the user # Add any additional focus areas specified by the user
if request.focus_on: if request.focus_on:
@@ -207,22 +202,24 @@ class ReviewCodeTool(BaseTool):
# Apply severity filtering to reduce noise if requested # Apply severity filtering to reduce noise if requested
if request.severity_filter != "all": if request.severity_filter != "all":
review_focus.append( review_focus.append(f"Only report issues of {request.severity_filter} severity or higher")
f"Only report issues of {request.severity_filter} severity or higher"
)
focus_instruction = "\n".join(review_focus) if review_focus else "" focus_instruction = "\n".join(review_focus) if review_focus else ""
# Construct the complete prompt with system instructions and code # Construct the complete prompt with system instructions and code
full_prompt = f"""{self.get_system_prompt()} full_prompt = f"""{self.get_system_prompt()}
=== USER CONTEXT ===
{request.context}
=== END CONTEXT ===
{focus_instruction} {focus_instruction}
=== CODE TO REVIEW === === CODE TO REVIEW ===
{file_content} {file_content}
=== END CODE === === END CODE ===
Please provide a comprehensive code review following the format specified in the system prompt.""" Please provide a code review aligned with the user's context and expectations, following the format specified in the system prompt."""
return full_prompt return full_prompt
@@ -243,4 +240,4 @@ Please provide a comprehensive code review following the format specified in the
header = f"Code Review ({request.review_type.upper()})" header = f"Code Review ({request.review_type.upper()})"
if request.focus_on: if request.focus_on:
header += f" - Focus: {request.focus_on}" header += f" - Focus: {request.focus_on}"
return f"{header}\n{'=' * 50}\n\n{response}" return f"{header}\n{'=' * 50}\n\n{response}\n\n---\n\n**Follow-up Actions:** Address critical issues first, then high priority ones. Consider running tests after fixes and re-reviewing if substantial changes were made."

View File

@@ -2,7 +2,7 @@
Think Deeper tool - Extended reasoning and problem-solving Think Deeper tool - Extended reasoning and problem-solving
""" """
from typing import Any, Dict, List, Optional from typing import Any, Optional
from mcp.types import TextContent from mcp.types import TextContent
from pydantic import Field from pydantic import Field
@@ -18,17 +18,13 @@ from .models import ToolOutput
class ThinkDeeperRequest(ToolRequest): class ThinkDeeperRequest(ToolRequest):
"""Request model for think_deeper tool""" """Request model for think_deeper tool"""
current_analysis: str = Field( current_analysis: str = Field(..., description="Claude's current thinking/analysis to extend")
..., description="Claude's current thinking/analysis to extend" problem_context: Optional[str] = Field(None, description="Additional context about the problem or goal")
) focus_areas: Optional[list[str]] = Field(
problem_context: Optional[str] = Field(
None, description="Additional context about the problem or goal"
)
focus_areas: Optional[List[str]] = Field(
None, None,
description="Specific aspects to focus on (architecture, performance, security, etc.)", description="Specific aspects to focus on (architecture, performance, security, etc.)",
) )
files: Optional[List[str]] = Field( files: Optional[list[str]] = Field(
None, None,
description="Optional file paths or directories for additional context (must be absolute paths)", description="Optional file paths or directories for additional context (must be absolute paths)",
) )
@@ -53,7 +49,7 @@ class ThinkDeeperTool(BaseTool):
"When in doubt, err on the side of a higher mode for truly deep thought and evaluation." "When in doubt, err on the side of a higher mode for truly deep thought and evaluation."
) )
def get_input_schema(self) -> Dict[str, Any]: def get_input_schema(self) -> dict[str, Any]:
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -104,7 +100,7 @@ class ThinkDeeperTool(BaseTool):
def get_request_model(self): def get_request_model(self):
return ThinkDeeperRequest return ThinkDeeperRequest
async def execute(self, arguments: Dict[str, Any]) -> List[TextContent]: async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
"""Override execute to check current_analysis size before processing""" """Override execute to check current_analysis size before processing"""
# First validate request # First validate request
request_model = self.get_request_model() request_model = self.get_request_model()
@@ -113,11 +109,7 @@ class ThinkDeeperTool(BaseTool):
# Check current_analysis size # Check current_analysis size
size_check = self.check_prompt_size(request.current_analysis) size_check = self.check_prompt_size(request.current_analysis)
if size_check: if size_check:
return [ return [TextContent(type="text", text=ToolOutput(**size_check).model_dump_json())]
TextContent(
type="text", text=ToolOutput(**size_check).model_dump_json()
)
]
# Continue with normal execution # Continue with normal execution
return await super().execute(arguments) return await super().execute(arguments)
@@ -128,30 +120,22 @@ class ThinkDeeperTool(BaseTool):
prompt_content, updated_files = self.handle_prompt_file(request.files) prompt_content, updated_files = self.handle_prompt_file(request.files)
# Use prompt.txt content if available, otherwise use the current_analysis field # Use prompt.txt content if available, otherwise use the current_analysis field
current_analysis = ( current_analysis = prompt_content if prompt_content else request.current_analysis
prompt_content if prompt_content else request.current_analysis
)
# Update request files list # Update request files list
if updated_files is not None: if updated_files is not None:
request.files = updated_files request.files = updated_files
# Build context parts # Build context parts
context_parts = [ context_parts = [f"=== CLAUDE'S CURRENT ANALYSIS ===\n{current_analysis}\n=== END ANALYSIS ==="]
f"=== CLAUDE'S CURRENT ANALYSIS ===\n{current_analysis}\n=== END ANALYSIS ==="
]
if request.problem_context: if request.problem_context:
context_parts.append( context_parts.append(f"\n=== PROBLEM CONTEXT ===\n{request.problem_context}\n=== END CONTEXT ===")
f"\n=== PROBLEM CONTEXT ===\n{request.problem_context}\n=== END CONTEXT ==="
)
# Add reference files if provided # Add reference files if provided
if request.files: if request.files:
file_content, _ = read_files(request.files) file_content = read_files(request.files)
context_parts.append( context_parts.append(f"\n=== REFERENCE FILES ===\n{file_content}\n=== END FILES ===")
f"\n=== REFERENCE FILES ===\n{file_content}\n=== END FILES ==="
)
full_context = "\n".join(context_parts) full_context = "\n".join(context_parts)
@@ -162,9 +146,7 @@ class ThinkDeeperTool(BaseTool):
focus_instruction = "" focus_instruction = ""
if request.focus_areas: if request.focus_areas:
areas = ", ".join(request.focus_areas) areas = ", ".join(request.focus_areas)
focus_instruction = ( focus_instruction = f"\n\nFOCUS AREAS: Please pay special attention to {areas} aspects."
f"\n\nFOCUS AREAS: Please pay special attention to {areas} aspects."
)
# Combine system prompt with context # Combine system prompt with context
full_prompt = f"""{self.get_system_prompt()}{focus_instruction} full_prompt = f"""{self.get_system_prompt()}{focus_instruction}
@@ -190,24 +172,11 @@ Please provide deep analysis that extends Claude's thinking with:
## Critical Evaluation Required ## Critical Evaluation Required
Claude, now that you've received Gemini's extended analysis, please: Claude, please critically evaluate Gemini's analysis by considering:
1. **Critically evaluate each suggestion** - Which points are truly valuable? Which might have limitations or trade-offs? 1. **Technical merit** - Which suggestions are valuable vs. have limitations?
2. **Constraints** - Fit with codebase patterns, performance, security, architecture
3. **Risks** - Hidden complexities, edge cases, potential failure modes
4. **Final recommendation** - Synthesize both perspectives, then think deeply further to explore additional considerations and arrive at the best technical solution
2. **Consider technical constraints** - How do these suggestions fit with: Remember: Use Gemini's insights to enhance, not replace, your analysis."""
- Existing codebase patterns and conventions
- Performance and scalability requirements
- Security implications and best practices
- Architecture and design principles
3. **Identify potential risks** - What could go wrong with each approach? Are there hidden complexities or edge cases?
4. **Synthesize your final recommendation** - Based on:
- Your original analysis
- Gemini's suggestions and critiques
- Technical feasibility and correctness
- A balanced assessment of trade-offs
5. **Formulate your conclusion** - What is the best technical solution considering all perspectives?
Remember: Gemini's analysis is meant to challenge and extend your thinking, not replace it. Use these insights to arrive at a more robust, well-considered solution."""

View File

@@ -2,8 +2,7 @@
Utility functions for Gemini MCP Server Utility functions for Gemini MCP Server
""" """
from .file_utils import (CODE_EXTENSIONS, expand_paths, read_file_content, from .file_utils import CODE_EXTENSIONS, expand_paths, read_file_content, read_files
read_files)
from .token_utils import check_token_limit, estimate_tokens from .token_utils import check_token_limit, estimate_tokens
__all__ = [ __all__ = [

View File

@@ -21,7 +21,7 @@ Security Model:
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import List, Optional, Set, Tuple from typing import Optional
from .token_utils import MAX_CONTEXT_TOKENS, estimate_tokens from .token_utils import MAX_CONTEXT_TOKENS, estimate_tokens
@@ -33,37 +33,68 @@ logger = logging.getLogger(__name__)
WORKSPACE_ROOT = os.environ.get("WORKSPACE_ROOT") WORKSPACE_ROOT = os.environ.get("WORKSPACE_ROOT")
CONTAINER_WORKSPACE = Path("/workspace") CONTAINER_WORKSPACE = Path("/workspace")
# Dangerous paths that should never be used as WORKSPACE_ROOT
# These would give overly broad access and pose security risks
DANGEROUS_WORKSPACE_PATHS = {
"/",
"/etc",
"/usr",
"/bin",
"/var",
"/root",
"/home",
"C:\\",
"C:\\Windows",
"C:\\Program Files",
"C:\\Users",
}
# Validate WORKSPACE_ROOT for security if it's set
if WORKSPACE_ROOT:
# Resolve to canonical path for comparison
resolved_workspace = Path(WORKSPACE_ROOT).resolve()
# Check against dangerous paths
if str(resolved_workspace) in DANGEROUS_WORKSPACE_PATHS:
raise RuntimeError(
f"Security Error: WORKSPACE_ROOT '{WORKSPACE_ROOT}' is set to a dangerous system directory. "
f"This would give access to critical system files. "
f"Please set WORKSPACE_ROOT to a specific project directory."
)
# Additional check: prevent filesystem root
if resolved_workspace.parent == resolved_workspace:
raise RuntimeError(
f"Security Error: WORKSPACE_ROOT '{WORKSPACE_ROOT}' cannot be the filesystem root. "
f"This would give access to the entire filesystem. "
f"Please set WORKSPACE_ROOT to a specific project directory."
)
# Get project root from environment or use current directory # Get project root from environment or use current directory
# This defines the sandbox directory where file access is allowed # This defines the sandbox directory where file access is allowed
# #
# Security model: # Simplified Security model:
# 1. If MCP_PROJECT_ROOT is explicitly set, use it as a sandbox # 1. If MCP_PROJECT_ROOT is explicitly set, use it as sandbox (override)
# 2. If not set and in Docker (WORKSPACE_ROOT exists), use /workspace # 2. If WORKSPACE_ROOT is set (Docker mode), auto-use /workspace as sandbox
# 3. Otherwise, allow access to user's home directory and below # 3. Otherwise, use home directory (direct usage)
# 4. Never allow access to system directories outside home
env_root = os.environ.get("MCP_PROJECT_ROOT") env_root = os.environ.get("MCP_PROJECT_ROOT")
if env_root: if env_root:
# If explicitly set, use it as sandbox # If explicitly set, use it as sandbox (allows custom override)
PROJECT_ROOT = Path(env_root).resolve() PROJECT_ROOT = Path(env_root).resolve()
SANDBOX_MODE = True
elif WORKSPACE_ROOT and CONTAINER_WORKSPACE.exists(): elif WORKSPACE_ROOT and CONTAINER_WORKSPACE.exists():
# Running in Docker with workspace mounted # Running in Docker with workspace mounted - auto-use /workspace
PROJECT_ROOT = CONTAINER_WORKSPACE PROJECT_ROOT = CONTAINER_WORKSPACE
SANDBOX_MODE = True
else: else:
# If not set, default to home directory for safety # Running directly on host - default to home directory for normal usage
# This allows access to any file under the user's home directory # This allows access to any file under the user's home directory
PROJECT_ROOT = Path.home() PROJECT_ROOT = Path.home()
SANDBOX_MODE = False
# Critical Security Check: Prevent running with overly permissive root # Additional security check for explicit PROJECT_ROOT
# Setting PROJECT_ROOT to the filesystem root would allow access to all files, if env_root and PROJECT_ROOT.parent == PROJECT_ROOT:
# which is a severe security vulnerability. Works cross-platform.
if PROJECT_ROOT.parent == PROJECT_ROOT: # This works for both "/" and "C:\"
raise RuntimeError( raise RuntimeError(
"Security Error: PROJECT_ROOT cannot be the filesystem root. " "Security Error: MCP_PROJECT_ROOT cannot be the filesystem root. "
"This would give access to the entire filesystem. " "This would give access to the entire filesystem. "
"Please set MCP_PROJECT_ROOT environment variable to a specific directory." "Please set MCP_PROJECT_ROOT to a specific directory."
) )
@@ -144,22 +175,23 @@ CODE_EXTENSIONS = {
} }
def _get_secure_container_path(path_str: str) -> str: def translate_path_for_environment(path_str: str) -> str:
""" """
Securely translate host paths to container paths when running in Docker. Translate paths between host and container environments as needed.
This function implements critical security measures: This is the unified path translation function that should be used by all
1. Uses os.path.realpath() to resolve symlinks before validation tools and utilities throughout the codebase. It handles:
2. Validates that paths are within the mounted workspace 1. Docker host-to-container path translation
3. Provides detailed logging for debugging 2. Direct mode (no translation needed)
3. Security validation and error handling
Args: Args:
path_str: Original path string from the client (potentially a host path) path_str: Original path string from the client
Returns: Returns:
Translated container path, or original path if not in Docker environment Translated path appropriate for the current environment
""" """
if not WORKSPACE_ROOT or not CONTAINER_WORKSPACE.exists(): if not WORKSPACE_ROOT or not WORKSPACE_ROOT.strip() or not CONTAINER_WORKSPACE.exists():
# Not in the configured Docker environment, no translation needed # Not in the configured Docker environment, no translation needed
return path_str return path_str
@@ -167,7 +199,9 @@ def _get_secure_container_path(path_str: str) -> str:
# Use os.path.realpath for security - it resolves symlinks completely # Use os.path.realpath for security - it resolves symlinks completely
# This prevents symlink attacks that could escape the workspace # This prevents symlink attacks that could escape the workspace
real_workspace_root = Path(os.path.realpath(WORKSPACE_ROOT)) real_workspace_root = Path(os.path.realpath(WORKSPACE_ROOT))
real_host_path = Path(os.path.realpath(path_str)) # For the host path, we can't use realpath if it doesn't exist in the container
# So we'll use Path().resolve(strict=False) instead
real_host_path = Path(path_str).resolve(strict=False)
# Security check: ensure the path is within the mounted workspace # Security check: ensure the path is within the mounted workspace
# This prevents path traversal attacks (e.g., ../../../etc/passwd) # This prevents path traversal attacks (e.g., ../../../etc/passwd)
@@ -178,9 +212,7 @@ def _get_secure_container_path(path_str: str) -> str:
# Log the translation for debugging (but not sensitive paths) # Log the translation for debugging (but not sensitive paths)
if str(container_path) != path_str: if str(container_path) != path_str:
logger.info( logger.info(f"Translated host path to container: {path_str} -> {container_path}")
f"Translated host path to container: {path_str} -> {container_path}"
)
return str(container_path) return str(container_path)
@@ -222,7 +254,7 @@ def resolve_and_validate_path(path_str: str) -> Path:
""" """
# Step 1: Translate Docker paths first (if applicable) # Step 1: Translate Docker paths first (if applicable)
# This must happen before any other validation # This must happen before any other validation
translated_path_str = _get_secure_container_path(path_str) translated_path_str = translate_path_for_environment(path_str)
# Step 2: Create a Path object from the (potentially translated) path # Step 2: Create a Path object from the (potentially translated) path
user_path = Path(translated_path_str) user_path = Path(translated_path_str)
@@ -231,8 +263,7 @@ def resolve_and_validate_path(path_str: str) -> Path:
# Relative paths could be interpreted differently depending on working directory # Relative paths could be interpreted differently depending on working directory
if not user_path.is_absolute(): if not user_path.is_absolute():
raise ValueError( raise ValueError(
f"Relative paths are not supported. Please provide an absolute path.\n" f"Relative paths are not supported. Please provide an absolute path.\n" f"Received: {path_str}"
f"Received: {path_str}"
) )
# Step 4: Resolve the absolute path (follows symlinks, removes .. and .) # Step 4: Resolve the absolute path (follows symlinks, removes .. and .)
@@ -258,7 +289,26 @@ def resolve_and_validate_path(path_str: str) -> Path:
return resolved_path return resolved_path
def expand_paths(paths: List[str], extensions: Optional[Set[str]] = None) -> List[str]: def translate_file_paths(file_paths: Optional[list[str]]) -> Optional[list[str]]:
"""
Translate a list of file paths for the current environment.
This function should be used by all tools to consistently handle path translation
for file lists. It applies the unified path translation to each path in the list.
Args:
file_paths: List of file paths to translate, or None
Returns:
List of translated paths, or None if input was None
"""
if not file_paths:
return file_paths
return [translate_path_for_environment(path) for path in file_paths]
def expand_paths(paths: list[str], extensions: Optional[set[str]] = None) -> list[str]:
""" """
Expand paths to individual files, handling both files and directories. Expand paths to individual files, handling both files and directories.
@@ -301,9 +351,7 @@ def expand_paths(paths: List[str], extensions: Optional[Set[str]] = None) -> Lis
for root, dirs, files in os.walk(path_obj): for root, dirs, files in os.walk(path_obj):
# Filter directories in-place to skip hidden and excluded directories # Filter directories in-place to skip hidden and excluded directories
# This prevents descending into .git, .venv, __pycache__, node_modules, etc. # This prevents descending into .git, .venv, __pycache__, node_modules, etc.
dirs[:] = [ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in EXCLUDED_DIRS]
d for d in dirs if not d.startswith(".") and d not in EXCLUDED_DIRS
]
for file in files: for file in files:
# Skip hidden files (e.g., .DS_Store, .gitignore) # Skip hidden files (e.g., .DS_Store, .gitignore)
@@ -326,7 +374,7 @@ def expand_paths(paths: List[str], extensions: Optional[Set[str]] = None) -> Lis
return expanded_files return expanded_files
def read_file_content(file_path: str, max_size: int = 1_000_000) -> Tuple[str, int]: def read_file_content(file_path: str, max_size: int = 1_000_000) -> tuple[str, int]:
""" """
Read a single file and format it for inclusion in AI prompts. Read a single file and format it for inclusion in AI prompts.
@@ -378,7 +426,7 @@ def read_file_content(file_path: str, max_size: int = 1_000_000) -> Tuple[str, i
# Read the file with UTF-8 encoding, replacing invalid characters # Read the file with UTF-8 encoding, replacing invalid characters
# This ensures we can handle files with mixed encodings # This ensures we can handle files with mixed encodings
with open(path, "r", encoding="utf-8", errors="replace") as f: with open(path, encoding="utf-8", errors="replace") as f:
file_content = f.read() file_content = f.read()
# Format with clear delimiters that help the AI understand file boundaries # Format with clear delimiters that help the AI understand file boundaries
@@ -392,11 +440,11 @@ def read_file_content(file_path: str, max_size: int = 1_000_000) -> Tuple[str, i
def read_files( def read_files(
file_paths: List[str], file_paths: list[str],
code: Optional[str] = None, code: Optional[str] = None,
max_tokens: Optional[int] = None, max_tokens: Optional[int] = None,
reserve_tokens: int = 50_000, reserve_tokens: int = 50_000,
) -> Tuple[str, str]: ) -> str:
""" """
Read multiple files and optional direct code with smart token management. Read multiple files and optional direct code with smart token management.
@@ -412,58 +460,36 @@ def read_files(
reserve_tokens: Tokens to reserve for prompt and response (default 50K) reserve_tokens: Tokens to reserve for prompt and response (default 50K)
Returns: Returns:
Tuple of (full_content, brief_summary) str: All file contents formatted for AI consumption
- full_content: All file contents formatted for AI consumption
- brief_summary: Human-readable summary of what was processed
""" """
if max_tokens is None: if max_tokens is None:
max_tokens = MAX_CONTEXT_TOKENS max_tokens = MAX_CONTEXT_TOKENS
content_parts = [] content_parts = []
summary_parts = []
total_tokens = 0 total_tokens = 0
available_tokens = max_tokens - reserve_tokens available_tokens = max_tokens - reserve_tokens
files_read = []
files_skipped = [] files_skipped = []
dirs_processed = []
# Priority 1: Handle direct code if provided # Priority 1: Handle direct code if provided
# Direct code is prioritized because it's explicitly provided by the user # Direct code is prioritized because it's explicitly provided by the user
if code: if code:
formatted_code = ( formatted_code = f"\n--- BEGIN DIRECT CODE ---\n{code}\n--- END DIRECT CODE ---\n"
f"\n--- BEGIN DIRECT CODE ---\n{code}\n--- END DIRECT CODE ---\n"
)
code_tokens = estimate_tokens(formatted_code) code_tokens = estimate_tokens(formatted_code)
if code_tokens <= available_tokens: if code_tokens <= available_tokens:
content_parts.append(formatted_code) content_parts.append(formatted_code)
total_tokens += code_tokens total_tokens += code_tokens
available_tokens -= code_tokens available_tokens -= code_tokens
# Create a preview for the summary
code_preview = code[:50] + "..." if len(code) > 50 else code
summary_parts.append(f"Direct code: {code_preview}")
else:
summary_parts.append("Direct code skipped (too large)")
# Priority 2: Process file paths # Priority 2: Process file paths
if file_paths: if file_paths:
# Track which paths are directories for summary
for path in file_paths:
try:
if Path(path).is_dir():
dirs_processed.append(path)
except Exception:
pass # Ignore invalid paths
# Expand directories to get all individual files # Expand directories to get all individual files
all_files = expand_paths(file_paths) all_files = expand_paths(file_paths)
if not all_files and file_paths: if not all_files and file_paths:
# No files found but paths were provided # No files found but paths were provided
content_parts.append( content_parts.append(f"\n--- NO FILES FOUND ---\nProvided paths: {', '.join(file_paths)}\n--- END ---\n")
f"\n--- NO FILES FOUND ---\nProvided paths: {', '.join(file_paths)}\n--- END ---\n"
)
else: else:
# Read files sequentially until token limit is reached # Read files sequentially until token limit is reached
for file_path in all_files: for file_path in all_files:
@@ -477,35 +503,21 @@ def read_files(
if total_tokens + file_tokens <= available_tokens: if total_tokens + file_tokens <= available_tokens:
content_parts.append(file_content) content_parts.append(file_content)
total_tokens += file_tokens total_tokens += file_tokens
files_read.append(file_path)
else: else:
# File too large for remaining budget # File too large for remaining budget
files_skipped.append(file_path) files_skipped.append(file_path)
# Build human-readable summary of what was processed
if dirs_processed:
summary_parts.append(f"Processed {len(dirs_processed)} dir(s)")
if files_read:
summary_parts.append(f"Read {len(files_read)} file(s)")
if files_skipped:
summary_parts.append(f"Skipped {len(files_skipped)} file(s) (token limit)")
if total_tokens > 0:
summary_parts.append(f"~{total_tokens:,} tokens used")
# Add informative note about skipped files to help users understand # Add informative note about skipped files to help users understand
# what was omitted and why # what was omitted and why
if files_skipped: if files_skipped:
skip_note = "\n\n--- SKIPPED FILES (TOKEN LIMIT) ---\n" skip_note = "\n\n--- SKIPPED FILES (TOKEN LIMIT) ---\n"
skip_note += f"Total skipped: {len(files_skipped)}\n" skip_note += f"Total skipped: {len(files_skipped)}\n"
# Show first 10 skipped files as examples # Show first 10 skipped files as examples
for i, file_path in enumerate(files_skipped[:10]): for _i, file_path in enumerate(files_skipped[:10]):
skip_note += f" - {file_path}\n" skip_note += f" - {file_path}\n"
if len(files_skipped) > 10: if len(files_skipped) > 10:
skip_note += f" ... and {len(files_skipped) - 10} more\n" skip_note += f" ... and {len(files_skipped) - 10} more\n"
skip_note += "--- END SKIPPED FILES ---\n" skip_note += "--- END SKIPPED FILES ---\n"
content_parts.append(skip_note) content_parts.append(skip_note)
full_content = "\n\n".join(content_parts) if content_parts else "" return "\n\n".join(content_parts) if content_parts else ""
summary = " | ".join(summary_parts) if summary_parts else "No input provided"
return full_content, summary

View File

@@ -19,7 +19,6 @@ Security Considerations:
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Dict, List, Tuple
# Directories to ignore when searching for git repositories # Directories to ignore when searching for git repositories
# These are typically build artifacts, dependencies, or cache directories # These are typically build artifacts, dependencies, or cache directories
@@ -37,7 +36,7 @@ IGNORED_DIRS = {
} }
def find_git_repositories(start_path: str, max_depth: int = 5) -> List[str]: def find_git_repositories(start_path: str, max_depth: int = 5) -> list[str]:
""" """
Recursively find all git repositories starting from the given path. Recursively find all git repositories starting from the given path.
@@ -53,7 +52,12 @@ def find_git_repositories(start_path: str, max_depth: int = 5) -> List[str]:
List of absolute paths to git repositories, sorted alphabetically List of absolute paths to git repositories, sorted alphabetically
""" """
repositories = [] repositories = []
start_path = Path(start_path).resolve() # Use strict=False to handle paths that might not exist (e.g., in Docker container)
start_path = Path(start_path).resolve(strict=False)
# If the path doesn't exist, return empty list
if not start_path.exists():
return []
def _find_repos(current_path: Path, current_depth: int): def _find_repos(current_path: Path, current_depth: int):
# Stop recursion if we've reached maximum depth # Stop recursion if we've reached maximum depth
@@ -86,7 +90,7 @@ def find_git_repositories(start_path: str, max_depth: int = 5) -> List[str]:
return sorted(repositories) return sorted(repositories)
def run_git_command(repo_path: str, command: List[str]) -> Tuple[bool, str]: def run_git_command(repo_path: str, command: list[str]) -> tuple[bool, str]:
""" """
Run a git command in the specified repository. Run a git command in the specified repository.
@@ -125,7 +129,7 @@ def run_git_command(repo_path: str, command: List[str]) -> Tuple[bool, str]:
return False, f"Git command failed: {str(e)}" return False, f"Git command failed: {str(e)}"
def get_git_status(repo_path: str) -> Dict[str, any]: def get_git_status(repo_path: str) -> dict[str, any]:
""" """
Get comprehensive git status information for a repository. Get comprehensive git status information for a repository.

View File

@@ -9,8 +9,6 @@ approximate. For production systems requiring precise token counts,
consider using the actual tokenizer for the specific model. consider using the actual tokenizer for the specific model.
""" """
from typing import Tuple
from config import MAX_CONTEXT_TOKENS from config import MAX_CONTEXT_TOKENS
@@ -34,7 +32,7 @@ def estimate_tokens(text: str) -> int:
return len(text) // 4 return len(text) // 4
def check_token_limit(text: str) -> Tuple[bool, int]: def check_token_limit(text: str) -> tuple[bool, int]:
""" """
Check if text exceeds the maximum token limit for Gemini models. Check if text exceeds the maximum token limit for Gemini models.