Deskaid
by ezyang
- codemcp
#!/usr/bin/env python3
import logging
import os
from mcp.server.fastmcp import Context, FastMCP
from .tools.edit_file import edit_file_content
from .tools.glob import MAX_RESULTS, glob_files
from .tools.grep import grep_files
from .tools.init_project import init_project
from .tools.ls import ls_directory
from .tools.read_file import read_file_content
from .tools.run_command import run_command
from .tools.user_prompt import user_prompt as user_prompt_tool
from .tools.write_file import write_file_content
# Initialize FastMCP server
mcp = FastMCP("codemcp")
# NB: If you edit this, also edit codemcp/tools/init_project.py
@mcp.tool()
async def codemcp(
ctx: Context,
subtool: str,
*,
path: str | None = None,
content: str | None = None,
old_string: str | None = None,
new_string: str | None = None,
offset: int | None = None,
limit: int | None = None,
description: str | None = None,
pattern: str | None = None,
include: str | None = None,
command: str | None = None,
arguments: list[str] | str | None = None,
old_str: str | None = None, # Added for backward compatibility
new_str: str | None = None, # Added for backward compatibility
chat_id: str | None = None, # Added for chat identification
user_prompt: str | None = None, # Added for InitProject commit message
subject_line: str | None = None, # Added for InitProject commit message
reuse_head_chat_id: bool
| None = None, # Whether to reuse the chat ID from the HEAD commit
) -> str:
"""If and only if the user explicitly asks you to initialize codemcp with
path, you should invoke this tool. This will return instructions which you should
IMMEDIATELY follow before continuing.
If the user indicates they want to "amend" or "continue working" on a PR,
you should set reuse_head_chat_id=True to continue using the same chat ID.
In each response after the first one, you must call the UserPrompt tool
with the user's verbatim message text.
Arguments:
subtool: The subtool to run (InitProject, UserPrompt, ...)
path: The path to the file or directory to operate on
chat_id: A unique ID to identify the chat session (provided by InitProject and required for all tools EXCEPT InitProject)
user_prompt: The user's original prompt verbatim, starting AFTER instructions to initialize codemcp (e.g., you should exclude "Initialize codemcp for PATH")
subject_line: A short subject line in Git conventional commit format (for InitProject)
reuse_head_chat_id: If True, reuse the chat ID from the HEAD commit instead of generating a new one (for InitProject)
... (there are other arguments which are documented later)
"""
try:
# Define expected parameters for each subtool
expected_params = {
"ReadFile": {"path", "offset", "limit", "chat_id"},
"WriteFile": {"path", "content", "description", "chat_id"},
"EditFile": {
"path",
"old_string",
"new_string",
"description",
"old_str",
"new_str",
"chat_id",
},
"LS": {"path", "chat_id"},
"InitProject": {
"path",
"user_prompt",
"subject_line",
"reuse_head_chat_id",
}, # chat_id is not expected for InitProject as it's generated there
"UserPrompt": {"user_prompt", "chat_id"},
"RunCommand": {"path", "command", "arguments", "chat_id"},
"Grep": {"pattern", "path", "include", "chat_id"},
"Glob": {"pattern", "path", "limit", "offset", "chat_id"},
}
# Check if subtool exists
if subtool not in expected_params:
raise ValueError(
f"Unknown subtool: {subtool}. Available subtools: {', '.join(expected_params.keys())}"
)
# Handle string arguments - convert to a list with one element
if isinstance(arguments, str):
arguments = [arguments]
# Get all provided non-None parameters
provided_params = {
param: value
for param, value in {
"path": path,
"content": content,
"old_string": old_string,
"new_string": new_string,
"offset": offset,
"limit": limit,
"description": description,
"pattern": pattern,
"include": include,
"command": command,
"arguments": arguments,
# Include backward compatibility parameters
"old_str": old_str,
"new_str": new_str,
# Chat ID for session identification
"chat_id": chat_id,
# InitProject commit message parameters
"user_prompt": user_prompt,
"subject_line": subject_line,
# Whether to reuse the chat ID from the HEAD commit
"reuse_head_chat_id": reuse_head_chat_id,
}.items()
if value is not None
}
# Check for unexpected parameters
unexpected_params = set(provided_params.keys()) - expected_params[subtool]
if unexpected_params:
raise ValueError(
f"Unexpected parameters for {subtool} subtool: {', '.join(unexpected_params)}"
)
# Check for required chat_id for all tools except InitProject
if subtool != "InitProject" and chat_id is None:
raise ValueError(f"chat_id is required for {subtool} subtool")
# Now handle each subtool with its expected parameters
if subtool == "ReadFile":
if path is None:
raise ValueError("path is required for ReadFile subtool")
return await read_file_content(path, offset, limit, chat_id)
if subtool == "WriteFile":
if path is None:
raise ValueError("path is required for WriteFile subtool")
if description is None:
raise ValueError("description is required for WriteFile subtool")
content_str = content or ""
return await write_file_content(path, content_str, description, chat_id)
if subtool == "EditFile":
if path is None:
raise ValueError("path is required for EditFile subtool")
if description is None:
raise ValueError("description is required for EditFile subtool")
if old_string is None and old_str is None:
# TODO: I want telemetry to tell me when this occurs.
raise ValueError(
"Either old_string or old_str is required for EditFile subtool (use empty string for new file creation)"
)
# Accept either old_string or old_str (prefer old_string if both are provided)
old_content = old_string or old_str or ""
# Accept either new_string or new_str (prefer new_string if both are provided)
new_content = new_string or new_str or ""
return await edit_file_content(
path, old_content, new_content, None, description, chat_id
)
if subtool == "LS":
if path is None:
raise ValueError("path is required for LS subtool")
return await ls_directory(path, chat_id)
if subtool == "InitProject":
if path is None:
raise ValueError("path is required for InitProject subtool")
if user_prompt is None:
raise ValueError("user_prompt is required for InitProject subtool")
if subject_line is None:
raise ValueError("subject_line is required for InitProject subtool")
if reuse_head_chat_id is None:
reuse_head_chat_id = (
False # Default value in main.py only, not in the implementation
)
return await init_project(
path, user_prompt, subject_line, reuse_head_chat_id
)
if subtool == "RunCommand":
# When is something a command as opposed to a subtool? They are
# basically the same thing, but commands are always USER defined.
# This means we shove them all in RunCommand so they are guaranteed
# not to conflict with codemcp's subtools.
if path is None:
raise ValueError("path is required for RunCommand subtool")
if command is None:
raise ValueError("command is required for RunCommand subtool")
return await run_command(path, command, arguments, chat_id)
if subtool == "Grep":
if pattern is None:
raise ValueError("pattern is required for Grep subtool")
if path is None:
raise ValueError("path is required for Grep subtool")
try:
result = await grep_files(pattern, path, include, chat_id)
return result.get(
"resultForAssistant",
f"Found {result.get('numFiles', 0)} file(s)",
)
except Exception as e:
logging.warning(
f"Exception suppressed in grep subtool: {e!s}", exc_info=True
)
return f"Error executing grep: {e!s}"
if subtool == "Glob":
if pattern is None:
raise ValueError("pattern is required for Glob subtool")
if path is None:
raise ValueError("path is required for Glob subtool")
try:
result = await glob_files(
pattern,
path,
limit=limit if limit is not None else MAX_RESULTS,
offset=offset if offset is not None else 0,
chat_id=chat_id,
)
return result.get(
"resultForAssistant",
f"Found {result.get('numFiles', 0)} file(s)",
)
except Exception as e:
logging.warning(
f"Exception suppressed in glob subtool: {e!s}", exc_info=True
)
return f"Error executing glob: {e!s}"
if subtool == "UserPrompt":
if user_prompt is None:
raise ValueError("user_prompt is required for UserPrompt subtool")
return await user_prompt_tool(user_prompt, chat_id)
except Exception:
logging.error("Exception", exc_info=True)
raise
def configure_logging(log_file="codemcp.log"):
"""Configure logging to write to both a file and the console.
The log level is determined from the configuration file ~/.codemcprc.
It can be overridden by setting the DESKAID_DEBUG environment variable.
Example: DESKAID_DEBUG=1 python -m codemcp
By default, logs from the 'mcp' module are filtered out unless in debug mode.
"""
from .config import get_logger_verbosity
log_dir = os.path.join(os.path.expanduser("~"), ".codemcp")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, log_file)
# Get log level from config, with environment variable override
log_level_str = os.environ.get("DESKAID_DEBUG_LEVEL") or get_logger_verbosity()
# Map string log level to logging constants
log_level_map = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
# Convert string to logging level, default to INFO if invalid
log_level = log_level_map.get(log_level_str.upper(), logging.INFO)
# Force DEBUG level if DESKAID_DEBUG is set (for backward compatibility)
debug_mode = False
if os.environ.get("DESKAID_DEBUG"):
log_level = logging.DEBUG
debug_mode = True
# Create a root logger
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
# Clear any existing handlers
if root_logger.hasHandlers():
root_logger.handlers.clear()
# Create file handler
file_handler = logging.FileHandler(log_path)
file_handler.setLevel(log_level)
# Create console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(log_level)
# Create formatter and add it to the handlers
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
# Set up filter to exclude logs from 'mcp' module unless in debug mode
class ModuleFilter(logging.Filter):
def filter(self, record):
# Allow all logs in debug mode, otherwise filter 'mcp' module
if debug_mode or not record.name.startswith("mcp"):
return True
return False
module_filter = ModuleFilter()
file_handler.addFilter(module_filter)
console_handler.addFilter(module_filter)
# Add the handlers to the root logger
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
logging.info(f"Logging configured. Log file: {log_path}")
logging.info(f"Log level set to: {logging.getLevelName(log_level)}")
if not debug_mode:
logging.info("Logs from 'mcp' module are being filtered")
def run():
"""Run the MCP server."""
configure_logging()
mcp.run()