├── .cursor └── rules │ └── expecttest.mdc ├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── ARCHITECTURE.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── INSTALL.md ├── LICENSE.txt ├── README.md ├── TODO.md ├── codemcp.toml ├── codemcp.toml.example ├── codemcp ├── __init__.py ├── __main__.py ├── access.py ├── agno.py ├── async_file_utils.py ├── code_command.py ├── common.py ├── config.py ├── file_utils.py ├── git.py ├── git_commit.py ├── git_message.py ├── git_parse_message.py ├── git_query.py ├── glob_pattern.py ├── line_endings.py ├── main.py ├── mcp.py ├── rules.py ├── shell.py ├── templates │ ├── README.md │ ├── blank │ │ └── codemcp.toml │ └── python │ │ ├── .gitignore │ │ ├── README.md │ │ ├── __PACKAGE_NAME__ │ │ ├── __init__.py │ │ ├── _internal │ │ │ └── __init__.py │ │ └── py.typed │ │ ├── codemcp.toml │ │ └── pyproject.toml ├── testing.py └── tools │ ├── __init__.py │ ├── chmod.py │ ├── commit_utils.py │ ├── edit_file.py │ ├── git_blame.py │ ├── git_diff.py │ ├── git_log.py │ ├── git_show.py │ ├── glob.py │ ├── grep.py │ ├── init_project.py │ ├── ls.py │ ├── mv.py │ ├── read_file.py │ ├── rm.py │ ├── run_command.py │ ├── think.py │ └── write_file.py ├── e2e ├── test_chmod.py ├── test_cursor_rules.py ├── test_directory_creation.py ├── test_edit_file.py ├── test_format.py ├── test_git_amend.py ├── test_git_amend_whitespace.py ├── test_git_helper.py ├── test_git_tools.py ├── test_grep.py ├── test_init_command.py ├── test_init_project.py ├── test_init_project_no_commits.py ├── test_json_content_serialization.py ├── test_lint.py ├── test_list_tools.py ├── test_ls.py ├── test_mv.py ├── test_read_file.py ├── test_rm.py ├── test_run_command.py ├── test_run_command_output_limit.py ├── test_run_tests.py ├── test_security.py ├── test_tilde_expansion.py ├── test_trailing_whitespace.py └── test_write_file.py ├── prompt.txt ├── pyproject.toml ├── run_format.sh ├── run_lint.sh ├── run_test.sh ├── run_typecheck.sh ├── static └── screenshot.png ├── stubs ├── editorconfig │ └── __init__.pyi ├── mcp_stubs │ ├── ClientSession.pyi │ ├── __init__.pyi │ ├── client │ │ ├── __init__.pyi │ │ └── stdio.pyi │ ├── server │ │ ├── __init__.pyi │ │ └── fastmcp.pyi │ └── types.pyi └── tomli_stubs │ └── __init__.pyi ├── tests ├── test_common.py ├── test_edit_file_string_matching.py ├── test_git_message.py ├── test_git_message_real_world.py ├── test_git_parse_message.py ├── test_glob.py ├── test_line_endings.py └── test_rules.py └── uv.lock /.cursor/rules/expecttest.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | globs: e2e/**,tests/** 3 | alwaysApply: false 4 | --- 5 | 6 | ## expecttest 7 | 8 | We use expecttest for testing. If you need to match against a multiline 9 | string or some output that is likely to change in the future (e.g., the kind 10 | of thing you would have ordinarily done an assertIn with), use an expect test. 11 | If a test is assertExpectedInline and it fails, use 'accept' command to update 12 | the output, do NOT relax the test. If the output of the test seems 13 | nondeterministic (e.g., you run accept but the test is still failing on the 14 | assertExpectedInline) you should halt and ask for help from the user. 15 | 16 | Below is its README. 17 | 18 | ---- 19 | 20 | This library implements expect tests (also known as "golden" tests). Expect 21 | tests are a method of writing tests where instead of hard-coding the expected 22 | output of a test, you run the test to get the output, and the test framework 23 | automatically populates the expected output. If the output of the test changes, 24 | you can rerun the test 'accept' command to accept the output. 25 | 26 | Somewhat unusually, this library implements *inline* expect tests: that is to 27 | say, the expected output isn't saved to an external file, it is saved directly 28 | in the Python file (and we modify your Python file when updating the expect 29 | test.) 30 | 31 | The general recipe for how to use this is as follows: 32 | 33 | 1. Write your test and use `assertExpectedInline()` instead of a normal 34 | `assertEqual`. Leave the expected argument blank with an empty string: 35 | ```py 36 | self.assertExpectedInline(some_func(), """""") 37 | ``` 38 | 39 | 2. Run your test. It should fail, and you get an error message about 40 | accepting the output with `EXPECTTEST_ACCEPT=1` (this means to use 41 | 'accept' command) 42 | 43 | 3. Rerun the test with 'accept' command. Now the previously blank string 44 | literal will contain the expected value of the test. 45 | ```py 46 | self.assertExpectedInline(some_func(), """my_value""") 47 | ``` 48 | 49 | A minimal working example: 50 | 51 | ```python 52 | # test.py 53 | import unittest 54 | from expecttest import TestCase 55 | 56 | class TestStringMethods(TestCase): 57 | def test_split(self): 58 | s = 'hello world' 59 | self.assertExpectedInline(str(s.split()), """""") 60 | 61 | if __name__ == '__main__': 62 | unittest.main() 63 | ``` 64 | 65 | Run `accept` command, and the content in triple-quoted string will be 66 | automatically updated, so you don't have to fill it out yourself. 67 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.{py}] 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [*.{json,yaml,yml,toml}] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | 24 | [Makefile] 25 | indent_style = tab 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - prod 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install uv 16 | uses: astral-sh/setup-uv@v5 17 | with: 18 | python-version: 3.12 19 | enable-cache: true 20 | cache-suffix: "optional-suffix" 21 | - name: Test 22 | run: uv run --frozen pytest 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .aider* 2 | __pycache__ 3 | .specstory 4 | *.bak 5 | .DS_Store 6 | .claude 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ezyang/codemcp/41dfe735e1541d6db93f326e43db66f1e7038425/.pre-commit-config.yaml -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # codemcp Architecture 2 | 3 | This document provides an overview of the architecture and design decisions of codemcp. 4 | 5 | ## Project Configuration 6 | 7 | The codemcp tool uses a TOML file (`codemcp.toml`) in the project root for configuration. This file has several sections: 8 | 9 | ### Project Prompt 10 | 11 | The `project_prompt` string is included in system prompts to provide project-specific instructions to Claude. 12 | 13 | ```toml 14 | project_prompt = """ 15 | Project-specific instructions for Claude go here. 16 | """ 17 | ``` 18 | 19 | ### Commands 20 | 21 | The `commands` section specifies commands that can be executed by specialized tools at specific times. Commands are defined as arrays of strings that will be joined with spaces and executed in a shell context: 22 | 23 | ```toml 24 | [commands] 25 | format = ["./run_format.sh"] 26 | ``` 27 | 28 | Currently supported commands: 29 | - `format`: Used by the Format tool to format code according to project standards. 30 | 31 | ## Tools 32 | 33 | codemcp provides several tools that Claude can use during interaction: 34 | 35 | - **ReadFile**: Read a file from the filesystem 36 | - **WriteFile**: Write content to a file 37 | - **EditFile**: Make targeted edits to a file 38 | - **LS**: List files and directories 39 | - **Grep**: Search for patterns in files 40 | - **InitProject**: Initialize a project and load its configuration 41 | - **Format**: Format code according to project standards using the configured command 42 | 43 | ## System Integration 44 | 45 | When a project is initialized using `InitProject`, codemcp reads the `codemcp.toml` file and constructs a system prompt that includes: 46 | 47 | 1. Default system instructions 48 | 2. The project's `project_prompt` 49 | 3. Instructions to use specific tools at appropriate times 50 | 51 | For example, if a format command is configured, the system prompt will include an instruction for Claude to use the Format tool when the task is complete. 52 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | Look at codemcp.toml for a system prompt, as well as how to run various commands. 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Here's the deal: I don't want to review big, LLM generated patches to this codebase. 4 | I haven't reviewed most of the code that I've generated; most of my confidence 5 | arises from careful prompt writing and manual testing. 6 | 7 | So if you want to contribute a patch, here's what I'll accept: 8 | 9 | 1. A small patch that I can easily review it by hand, or 10 | 11 | 2. A *prompt* you used to generate the output by LLM, and evidence that you manually 12 | tested the result and it worked. 13 | 14 | You can feel free to submit the code that the LLM generated too but for safety 15 | reasons I will regenerate the diff from your prompt myself. I also don't 16 | trust LLM generated tests, so either argue why the LLM tests are sufficient or 17 | tell me what your manual testing protocol was. I'm willing to hand-review 18 | hand-written tests, keep them separate so I can easily patch them in. If you 19 | needed to blend prompting + final touchups, keep them in separate commits and 20 | we'll figure something out. 21 | 22 | If you're looking for backlog to tackle since you want to practice AI coding, 23 | check out https://github.com/ezyang/codemcp/issues 24 | 25 | ## Local development tips 26 | 27 | Instead of using uvx directly, I have a uv venv setup in my source directory 28 | and connect using: 29 | 30 | ``` 31 | "codemcp": { 32 | "command": "/Users/ezyang/Dev/codemcp-prod/.venv/bin/python", 33 | "args": [ 34 | "-m", 35 | "codemcp" 36 | ] 37 | } 38 | ``` 39 | 40 | I recommend using `git worktree` to keep a separate "prod" folder from the 41 | folder you're actually editing with Claude. You can then `git checkout 42 | --detach main` in the prod folder to checkout your latest changes and manually 43 | test them. 44 | 45 | ## Type Checking 46 | 47 | This project uses `pyright` for type checking with strict mode enabled. The type checking configuration is in `pyproject.toml`. We use a few strategies to maintain type safety: 48 | 49 | 1. Type stubs for external libraries: 50 | - Custom type stubs are in the `stubs/` directory 51 | - The `stubPackages` configuration in `pyproject.toml` maps libraries to their stub packages 52 | 53 | 2. File-specific ignores for challenging cases: 54 | - For some files with complex dynamic typing patterns (particularly testing code), we use file-specific ignores via `tool.pyright.ignoreExtraErrors` in `pyproject.toml` 55 | - This is preferable to inline ignores and lets us maintain type safety in most of the codebase 56 | 57 | When making changes, please ensure type checking passes by running: 58 | ``` 59 | ./run_typecheck.sh 60 | ``` 61 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | If you haven't read it already, please check "Installation" section in 2 | [README.md](README.md) for the **recommended** install methods. This file 3 | documents all of the legacy installation methods if you want to do it the hard 4 | way. 5 | 6 | ## Configure codemcp in your Claude Desktop 7 | 8 | ### For macOS/Linux 9 | 10 | Create or edit your `~/.config/anthropic/claude/claude_desktop_config.json` file and add the following: 11 | 12 | ```json 13 | { 14 | "mcpServers": { 15 | "codemcp": { 16 | "command": "/Users//.local/bin/uvx", 17 | "args": [ 18 | "--from", 19 | "git+https://github.com/ezyang/codemcp@prod", 20 | "codemcp" 21 | ] 22 | } 23 | } 24 | } 25 | ``` 26 | 27 | ### For Windows 28 | 29 | Create or edit your `%USERPROFILE%\.anthropic\claude\claude_desktop_config.json` file and add the following: 30 | 31 | ```json 32 | { 33 | "mcpServers": { 34 | "codemcp": { 35 | "command": "C:\\Users\\\\.local\\bin\\uvx.exe", 36 | "args": [ 37 | "--from", 38 | "git+https://github.com/ezyang/codemcp@prod", 39 | "codemcp" 40 | ] 41 | } 42 | } 43 | } 44 | ``` 45 | 46 | ### Using with WSL (recommended for Windows users) 47 | 48 | If you're using Windows Subsystem for Linux, you can configure codemcp to run within your WSL environment. This is useful if you prefer developing in a Linux environment while on Windows. 49 | 50 | Add the following configuration to your `claude_desktop_config.json` file: 51 | 52 | ```json 53 | { 54 | "mcpServers": { 55 | "codemcp": { 56 | "command": "wsl.exe", 57 | "args": [ 58 | "bash", 59 | "-c", 60 | "/home/NameOfWSLUser/.local/bin/uvx --from git+https://github.com/ezyang/codemcp@prod codemcp" 61 | ] 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | Replace `NameOfWSLUser` with your actual WSL username. This configuration runs the `uvx` command inside your WSL environment while allowing Claude Desktop to communicate with it. 68 | 69 | This configuration comes with the added benefit of being able to access your Linux filesystem directly. When initializing codemcp in Claude Desktop, you can use a path to your WSL project like: 70 | 71 | ``` 72 | Initialize codemcp with /home/NameOfWSLUser/project_in_wsl_to_work_on 73 | ``` 74 | 75 | Make sure you have installed Python 3.12+ and uv within your WSL distribution. You might need to run the following commands in your WSL terminal: 76 | 77 | ```bash 78 | # Install Python 3.12 (if not already installed) 79 | sudo apt update 80 | sudo apt install python3.12 81 | 82 | # Install uv 83 | curl -sSf https://astral.sh/uv/install.sh | sh 84 | ``` 85 | 86 | After configuring, restart Claude Desktop. The hammer icon should appear, indicating codemcp has loaded successfully. 87 | 88 | Restart the Claude Desktop app after modifying the JSON. If the MCP 89 | successfully loaded, a hammer icon will appear and when you click it "codemcp" 90 | will be visible. 91 | 92 | ### Global install with pip 93 | 94 | If you don't want to use uv, you can also globally pip install the latest 95 | codemcp version, assuming your global Python install is recent enough (Python 96 | 3.12) and doesn't have Python dependencies that conflict with codemcp. Some 97 | users report this is easier to get working on Windows. 98 | 99 | 1. `pip install git+https://github.com/ezyang/codemcp@prod` 100 | 2. Add the following configuration to `claude_desktop_config.json` file 101 | ```json 102 | { 103 | "mcpServers": { 104 | "codemcp": { 105 | "command": "python", 106 | "args": ["-m", "codemcp"] 107 | } 108 | } 109 | } 110 | ``` 111 | 3. Restart Claude Desktop 112 | 113 | You will need to manually upgrade codemcp to take updates using 114 | `pip install --upgrade git+https://github.com/ezyang/codemcp@prod` 115 | 116 | ### Other tips 117 | 118 | Pro tip: If the server fails to load, go to Settings > Developer > codemcp > 119 | Logs to look at the MCP logs, they're very helpful for debugging. The logs on 120 | Windows should be loaded `C:\Users\\AppData\Roaming\Claude\logs` 121 | (replace `` with your username. 122 | 123 | Pro tip: if on Windows, _**try using the [WSL instructions](#using-with-wsl-recommended-for-windows-users) instead**_, but if you insist on using Windows directly: if the logs say "Git executable not found. Ensure that 124 | Git is installed and available", and you *just* installed Git, reboot your 125 | machine (the PATH update hasn't propagated.) If this still doesn't work, open 126 | System Properties > Environment Variables > System variables > Path and ensure 127 | there is an entry for Git. 128 | 129 | Pro tip: if you like to live dangerously, you can change `prod` to `main`. If 130 | you want to pin to a specific release, replace it with `0.3.0` or similar. 131 | 132 | Pro tip: it is supported to specify only `uvx` as the command, but uvx must be 133 | in your global PATH (not just added via a shell profile); on OS X, this is 134 | typically not the case if you used the self installer (unless you installed 135 | into a system location like `/usr/local/bin`). 136 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | BIGGEST PROBLEMS 2 | - Git structure seems good, maybe want a way to make the LLM generate the 3 | commit message 4 | - Audit the tests to make sure they actually do the right thing 5 | 6 | CODE QUAL 7 | - Setup types 8 | - General logging to repro problems (CLI logging helps, but some signpost 9 | logging would be good too) 10 | - An attempt was done at e6b49ee but I think it's overlogging, and the 11 | logging format is not right 12 | - Stop using catch all exceptions 13 | 14 | TOOLS: 15 | - Typecheck/build integration 16 | - Scrape webpage and add to context 17 | - Explicitly add file to context 18 | - Make file executable tool 19 | - A few more of Claude Code's tools: glob, memory, notebook 20 | 21 | FEATURES 22 | - Diff review mode 23 | - Rage 24 | 25 | SHARPEN THE SAW 26 | - Use the Anthropic export data to do some token counting / analysis 27 | 28 | LLM AFFORDANCE 29 | - Support this style of grep 30 | { 31 | `command`: `Grep`, 32 | `pattern`: `self\\.assertIn.*normalized_result`, 33 | `path`: `/Users/ezyang/Dev/codemcp/test/test_mcp_e2e.py` 34 | } 35 | - Regex search replace for refactor-y stuff (need to prompt this well, cuz LLM 36 | needs to review changes) 37 | - Make CLAUDE.md system prompt work (let's be compat with claude code) 38 | - Infer my codemcp.toml 39 | 40 | ~~~~ 41 | 42 | HARD TO FIX 43 | - Deal with output length limit from Claude Desktop (cannot do an edit longer 44 | than the limit) 45 | 46 | UNCLEAR PAYOFF 47 | - More faithfully copy claude code's line numbering algorithm 48 | - Figure out if "compact output" is a good idea 49 | - Figure out how to make Claude stop trying to do things I don't want it to do 50 | (like running tests) 51 | -------------------------------------------------------------------------------- /codemcp.toml: -------------------------------------------------------------------------------- 1 | project_prompt = ''' 2 | - Before beginning work on this feature, write a short haiku. 3 | - When you are done with your task, run lint commands, and then submit 4 | a PR using the 'ghstack' command. 5 | - We ONLY write end to end tests, do NOT use mocks. 6 | - When you add a new argument to a function in the codebase, evaluate if it 7 | makes sense for every call site to pass this argument in. If it makes 8 | sense, do NOT default the argument and instead fix all call sites to 9 | explicitly pass this in. For example, if ALL call sites already need to be 10 | updated for the new argument, you should definitely make it non-optional. 11 | - When you make a new tool, the prompt goes in system_prompt in 12 | codecmp/tools/init_project.py 13 | - If an operation may fail, do NOT wrap it with a try-catch block to suppress 14 | error and do some fallback handling. Instead, let the exception propagate 15 | to the top level so we can properly report it. If you are trying to fix a test 16 | because an exception is being thrown in this way, reason about what invariant 17 | is being violated that is causing the exception to be thrown. 18 | - If you are trying to fix a test because an assert has failed, DO NOT remove 19 | the assert. Instead, try to reason about what bug could be causing the 20 | invariant to be violated. If you can't figure it out, ask the user to help 21 | and halt. 22 | - End-to-end tests which call into codemcp function go in e2e/; unit tests for 23 | purely functional code go in tests/ 24 | ''' 25 | 26 | [commands] 27 | format = ["./run_format.sh"] 28 | lint = ["./run_lint.sh"] 29 | ghstack = ["uv", "tool", "run", "ghstack"] 30 | typecheck = ["./run_typecheck.sh"] 31 | [commands.test] 32 | command = ["./run_test.sh"] 33 | doc = "Accepts a pytest-style test selector as an argument to run a specific test." 34 | [commands.accept] 35 | command = ["env", "EXPECTTEST_ACCEPT=1", "./run_test.sh"] 36 | doc = "Updates expecttest failing tests with their new values, akin to running with EXPECTTEST_ACCEPT=1. Accepts a pytest-style test selector as an argument to run a specific test." 37 | -------------------------------------------------------------------------------- /codemcp.toml.example: -------------------------------------------------------------------------------- 1 | # Example codemcp.toml configuration file 2 | # This file can be placed at the root of your repository 3 | # or copied to ~/.codemcprc for user-wide settings 4 | 5 | [logger] 6 | # Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL 7 | verbosity = "INFO" 8 | # Path where logs will be stored 9 | path = "~/.codemcp" 10 | 11 | [files] 12 | # Line ending style to use when creating new files 13 | # Valid values: "LF", "CRLF", or null (to auto-detect) 14 | # LF = Unix/Linux/macOS style (\n) 15 | # CRLF = Windows style (\r\n) 16 | line_endings = null # null means auto-detect from .editorconfig, .gitattributes, or OS 17 | -------------------------------------------------------------------------------- /codemcp/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from .main import cli, configure_logging, run 4 | from .mcp import mcp 5 | from .shell import get_subprocess_env, run_command 6 | 7 | __all__ = [ 8 | "configure_logging", 9 | "run", 10 | "mcp", 11 | "run_command", 12 | "get_subprocess_env", 13 | "cli", 14 | ] 15 | -------------------------------------------------------------------------------- /codemcp/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # WARNING: do NOT do a relative import, this file must be directly executable 4 | # by filename 5 | from codemcp import cli, mcp 6 | 7 | __all__ = ["mcp"] 8 | 9 | if __name__ == "__main__": 10 | # Use Click's CLI interface instead of directly calling run() 11 | cli() 12 | -------------------------------------------------------------------------------- /codemcp/access.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import os 5 | 6 | from .git_query import get_repository_root 7 | 8 | __all__ = [ 9 | "get_git_base_dir", 10 | "check_edit_permission", 11 | ] 12 | 13 | 14 | async def get_git_base_dir(file_path: str) -> str: 15 | """Get the base directory of the git repository containing the file. 16 | 17 | Args: 18 | file_path: The path to the file or directory 19 | 20 | Returns: 21 | The base directory of the git repository 22 | 23 | Raises: 24 | subprocess.SubprocessError: If there's an issue with git subprocess 25 | OSError: If there's an issue with file operations 26 | ValueError: If there's an invalid path or comparison 27 | """ 28 | # First normalize the path to resolve any .. or symlinks 29 | normalized_path = os.path.normpath(os.path.abspath(file_path)) 30 | 31 | # Use get_repository_root which handles non-existent paths and walks up directories 32 | git_base_dir = await get_repository_root(normalized_path) 33 | logging.debug(f"Git base directory: {git_base_dir}") 34 | 35 | # SECURITY CHECK: Ensure file_path is within the git repository 36 | # This prevents path traversal across repositories 37 | 38 | # Handle symlinked paths (on macOS /tmp links to /private/tmp) 39 | normalized_git_base = os.path.normpath(os.path.realpath(git_base_dir)) 40 | normalized_path = os.path.normpath(os.path.realpath(normalized_path)) 41 | 42 | # Perform path traversal check - ensure target path is inside git repo 43 | # Check if the path is within the git repo by calculating the relative path 44 | rel_path = os.path.relpath(normalized_path, normalized_git_base) 45 | 46 | # If the relative path starts with "..", it's outside the git repo 47 | if rel_path.startswith("..") or rel_path == "..": 48 | logging.debug( 49 | f"Path traversal check: {normalized_path} is outside git repo {normalized_git_base}" 50 | ) 51 | 52 | # Special case: On macOS, check for /private/tmp vs /tmp differences 53 | # If either path contains the other after normalization, they might be the same location 54 | if ( 55 | normalized_git_base in normalized_path 56 | or normalized_path in normalized_git_base 57 | ): 58 | logging.debug( 59 | f"Path might be the same location after symlinks: {normalized_path}, {normalized_git_base}" 60 | ) 61 | else: 62 | logging.warning( 63 | f"File path {file_path} is outside git repository {git_base_dir}" 64 | ) 65 | raise ValueError( 66 | f"File path {file_path} is outside git repository {git_base_dir}" 67 | ) 68 | 69 | return git_base_dir 70 | 71 | 72 | async def check_edit_permission(file_path: str) -> tuple[bool, str]: 73 | """Check if editing the file is permitted based on the presence of codemcp.toml 74 | in the git repository's root directory. 75 | 76 | Args: 77 | file_path: The path to the file to edit 78 | 79 | Returns: 80 | A tuple of (is_permitted, message) 81 | 82 | Raises: 83 | subprocess.SubprocessError: If there's an issue with git subprocess 84 | OSError: If there's an issue with file operations 85 | ValueError: If file_path is not in a git repository or other path issues 86 | """ 87 | # Get the git base directory (will raise an exception if not in a git repo) 88 | git_base_dir = await get_git_base_dir(file_path) 89 | 90 | # Check for codemcp.toml in the git base directory 91 | config_path = os.path.join(git_base_dir, "codemcp.toml") 92 | if not os.path.exists(config_path): 93 | return False, ( 94 | "Permission denied: codemcp.toml file not found in the git repository root. " 95 | "Please create a codemcp.toml file in the root directory of your project " 96 | "to enable editing files with codemcp." 97 | ) 98 | 99 | return True, "Permission granted." 100 | -------------------------------------------------------------------------------- /codemcp/agno.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | from typing import Union 4 | from urllib.parse import quote 5 | 6 | import click 7 | from agno.agent import Agent 8 | from agno.api.playground import PlaygroundEndpointCreate, create_playground_endpoint 9 | from agno.cli.console import console 10 | from agno.cli.settings import agno_cli_settings 11 | from agno.tools.mcp import MCPTools 12 | from agno.utils.log import logger 13 | from fastapi import FastAPI 14 | from rich import box 15 | from rich.panel import Panel 16 | 17 | 18 | async def serve_playground_app_async( 19 | app: Union[str, FastAPI], 20 | *, 21 | scheme: str = "http", 22 | host: str = "localhost", 23 | port: int = 7777, 24 | reload: bool = False, 25 | prefix="/v1", 26 | **kwargs, 27 | ): 28 | import os 29 | import signal 30 | 31 | import uvicorn 32 | 33 | try: 34 | create_playground_endpoint( 35 | playground=PlaygroundEndpointCreate( 36 | endpoint=f"{scheme}://{host}:{port}", playground_data={"prefix": prefix} 37 | ), 38 | ) 39 | except Exception as e: 40 | logger.error(f"Could not create playground endpoint: {e}") 41 | logger.error("Please try again.") 42 | return 43 | 44 | logger.info(f"Starting playground on {scheme}://{host}:{port}") 45 | # Encode the full endpoint (host:port) 46 | encoded_endpoint = quote(f"{host}:{port}") 47 | 48 | # Create a panel with the playground URL 49 | url = f"{agno_cli_settings.playground_url}?endpoint={encoded_endpoint}" 50 | panel = Panel( 51 | f"[bold green]Playground URL:[/bold green] [link={url}]{url}[/link]", 52 | title="Agent Playground", 53 | expand=False, 54 | border_style="cyan", 55 | box=box.HEAVY, 56 | padding=(2, 2), 57 | ) 58 | 59 | # Print the panel 60 | console.print(panel) 61 | 62 | # Define our custom signal handler that exits immediately 63 | def handle_exit(sig, frame): 64 | logger.info( 65 | "Received shutdown signal - exiting immediately without waiting for connections" 66 | ) 67 | os._exit(0) 68 | 69 | # Register for SIGINT (Ctrl+C) and SIGTERM if this is running in the main thread 70 | try: 71 | signal.signal(signal.SIGINT, handle_exit) 72 | signal.signal(signal.SIGTERM, handle_exit) 73 | except ValueError: 74 | # Can't set signal handlers in non-main threads 75 | logger.warning("Can't set custom signal handlers in non-main thread") 76 | 77 | # Configure uvicorn with timeout_graceful_shutdown=0 to minimize delay 78 | # But our signal handler will exit first anyway 79 | config = uvicorn.Config( 80 | app=app, 81 | host=host, 82 | port=port, 83 | reload=reload, 84 | timeout_graceful_shutdown=0, 85 | **kwargs, 86 | ) 87 | server = uvicorn.Server(config) 88 | 89 | # Let the server run normally - our signal handler will take over on Ctrl+C 90 | await server.serve() 91 | 92 | 93 | async def main(hello_world: bool = False): 94 | async with MCPTools(f"{sys.executable} -m codemcp") as codemcp: 95 | # TODO: cli-ify the model 96 | from agno.models.anthropic import Claude 97 | 98 | # from agno.models.google import Gemini 99 | agent = Agent( 100 | model=Claude(id="claude-3-7-sonnet-20250219"), 101 | # model=Gemini(id="gemini-2.5-pro-exp-03-25"), 102 | tools=[codemcp], 103 | instructions="", 104 | markdown=True, 105 | show_tool_calls=True, 106 | ) 107 | 108 | # If --hello-world flag is used, run the short-circuited response and return 109 | if hello_world: 110 | await agent.aprint_response( 111 | "What tools do you have?", 112 | stream=True, 113 | show_full_reasoning=True, 114 | stream_intermediate_steps=True, 115 | ) 116 | return 117 | 118 | # Comment out the playground code 119 | # playground = Playground(agents=[agent]).get_app() 120 | # await serve_playground_app_async(playground) 121 | 122 | # Replace with a simple async loop for stdin input 123 | print("Enter your query (Ctrl+C to exit):") 124 | while True: 125 | try: 126 | # Use asyncio to read from stdin in an async-friendly way 127 | loop = asyncio.get_event_loop() 128 | user_input = await loop.run_in_executor(None, lambda: input("> ")) 129 | 130 | # Properly await the async print_response method 131 | await agent.aprint_response( 132 | user_input, 133 | stream=True, 134 | show_full_reasoning=True, 135 | stream_intermediate_steps=True, 136 | ) 137 | except KeyboardInterrupt: 138 | print("\nExiting...") 139 | break 140 | 141 | 142 | @click.command() 143 | @click.option( 144 | "--hello-world", is_flag=True, help="Run a simple test query to see available tools" 145 | ) 146 | def cli(hello_world: bool = False): 147 | """CLI for the Agno agent with CodeMCP integration.""" 148 | from agno.debug import enable_debug_mode 149 | 150 | enable_debug_mode() 151 | import logging 152 | 153 | logging.basicConfig(level=logging.DEBUG) 154 | logging.getLogger("httpx").setLevel(logging.DEBUG) # For HTTP logging 155 | logging.getLogger("anthropic").setLevel(logging.DEBUG) 156 | logging.getLogger("google_genai").setLevel(logging.DEBUG) 157 | 158 | asyncio.run(main(hello_world=hello_world)) 159 | 160 | 161 | if __name__ == "__main__": 162 | cli() 163 | -------------------------------------------------------------------------------- /codemcp/async_file_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from typing import List, Literal 5 | 6 | import anyio 7 | 8 | from .line_endings import detect_line_endings 9 | 10 | # Define OpenTextMode and OpenBinaryMode similar to what anyio uses 11 | OpenTextMode = Literal[ 12 | "r", 13 | "r+", 14 | "+r", 15 | "rt", 16 | "rt+", 17 | "r+t", 18 | "+rt", 19 | "tr", 20 | "tr+", 21 | "t+r", 22 | "w", 23 | "w+", 24 | "+w", 25 | "wt", 26 | "wt+", 27 | "w+t", 28 | "+wt", 29 | "tw", 30 | "tw+", 31 | "t+w", 32 | "a", 33 | "a+", 34 | "+a", 35 | "at", 36 | "at+", 37 | "a+t", 38 | "+at", 39 | "ta", 40 | "ta+", 41 | "t+a", 42 | ] 43 | OpenBinaryMode = Literal[ 44 | "rb", 45 | "rb+", 46 | "r+b", 47 | "+rb", 48 | "br", 49 | "br+", 50 | "b+r", 51 | "wb", 52 | "wb+", 53 | "w+b", 54 | "+wb", 55 | "bw", 56 | "bw+", 57 | "b+w", 58 | "ab", 59 | "ab+", 60 | "a+b", 61 | "+ab", 62 | "ba", 63 | "ba+", 64 | "b+a", 65 | ] 66 | 67 | 68 | async def async_open_text( 69 | file_path: str, 70 | mode: OpenTextMode = "r", 71 | encoding: str = "utf-8", 72 | errors: str = "replace", 73 | ) -> str: 74 | """Asynchronously open and read a text file. 75 | 76 | Args: 77 | file_path: The path to the file 78 | mode: The file open mode (default: 'r') 79 | encoding: The text encoding (default: 'utf-8') 80 | errors: How to handle encoding errors (default: 'replace') 81 | 82 | Returns: 83 | The file content as a string 84 | """ 85 | async with await anyio.open_file( 86 | file_path, mode, encoding=encoding, errors=errors 87 | ) as f: 88 | return await f.read() 89 | 90 | 91 | async def async_open_binary(file_path: str, mode: OpenBinaryMode = "rb") -> bytes: 92 | """Asynchronously open and read a binary file. 93 | 94 | Args: 95 | file_path: The path to the file 96 | mode: The file open mode (default: 'rb') 97 | 98 | Returns: 99 | The file content as bytes 100 | """ 101 | async with await anyio.open_file(file_path, mode) as f: 102 | return await f.read() 103 | 104 | 105 | async def async_readlines( 106 | file_path: str, encoding: str = "utf-8", errors: str = "replace" 107 | ) -> List[str]: 108 | """Asynchronously read lines from a text file. 109 | 110 | Args: 111 | file_path: The path to the file 112 | encoding: The text encoding (default: 'utf-8') 113 | errors: How to handle encoding errors (default: 'replace') 114 | 115 | Returns: 116 | A list of lines from the file 117 | """ 118 | async with await anyio.open_file( 119 | file_path, "r", encoding=encoding, errors=errors 120 | ) as f: 121 | return await f.readlines() 122 | 123 | 124 | async def async_write_text( 125 | file_path: str, 126 | content: str, 127 | mode: OpenTextMode = "w", 128 | encoding: str = "utf-8", 129 | ) -> None: 130 | """Asynchronously write text to a file. 131 | 132 | Args: 133 | file_path: The path to the file 134 | content: The text content to write 135 | mode: The file open mode (default: 'w') 136 | encoding: The text encoding (default: 'utf-8') 137 | """ 138 | async with await anyio.open_file( 139 | file_path, mode, encoding=encoding, newline="" 140 | ) as f: 141 | await f.write(content) 142 | 143 | 144 | async def async_write_binary( 145 | file_path: str, content: bytes, mode: OpenBinaryMode = "wb" 146 | ) -> None: 147 | """Asynchronously write binary data to a file. 148 | 149 | Args: 150 | file_path: The path to the file 151 | content: The binary content to write 152 | mode: The file open mode (default: 'wb') 153 | """ 154 | async with await anyio.open_file(file_path, mode) as f: 155 | await f.write(content) 156 | 157 | 158 | async def async_detect_encoding(file_path: str) -> str: 159 | """Asynchronously detect the encoding of a file. 160 | 161 | Args: 162 | file_path: The path to the file 163 | 164 | Returns: 165 | The detected encoding, defaulting to 'utf-8' 166 | """ 167 | if not os.path.exists(file_path): 168 | return "utf-8" 169 | 170 | try: 171 | # Try to read with utf-8 first 172 | await async_open_text(file_path, encoding="utf-8") 173 | return "utf-8" 174 | except UnicodeDecodeError: 175 | # If utf-8 fails, default to a more permissive encoding 176 | return "latin-1" 177 | 178 | 179 | async def async_detect_line_endings(file_path: str) -> str: 180 | """Asynchronously detect the line endings of a file. 181 | 182 | Args: 183 | file_path: The path to the file 184 | 185 | Returns: 186 | 'CRLF' or 'LF' 187 | """ 188 | return await detect_line_endings(file_path, return_format="format") 189 | -------------------------------------------------------------------------------- /codemcp/common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from typing import List, Union 5 | 6 | # Constants 7 | MAX_LINES_TO_READ = 1000 8 | MAX_LINE_LENGTH = 1000 9 | MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 # 0.25MB in bytes 10 | START_CONTEXT_LINES = 5 # Number of lines to keep from the beginning when truncating 11 | 12 | __all__ = [ 13 | "MAX_LINES_TO_READ", 14 | "MAX_LINE_LENGTH", 15 | "MAX_OUTPUT_SIZE", 16 | "START_CONTEXT_LINES", 17 | "is_image_file", 18 | "get_image_format", 19 | "normalize_file_path", 20 | "get_edit_snippet", 21 | "truncate_output_content", 22 | ] 23 | 24 | 25 | def is_image_file(file_path: str) -> bool: 26 | """Check if a file is an image based on its MIME type.""" 27 | # Stub implementation - we don't care about image support 28 | return False 29 | 30 | 31 | def get_image_format(file_path: str) -> str: 32 | """Get the format of an image file.""" 33 | # Stub implementation - we don't care about image support 34 | return "png" 35 | 36 | 37 | def normalize_file_path(file_path: str) -> str: 38 | """Normalize a file path to an absolute path. 39 | 40 | Expands the tilde character (~) if present to the user's home directory. 41 | """ 42 | # Expand tilde to home directory 43 | expanded_path = os.path.expanduser(file_path) 44 | 45 | if not os.path.isabs(expanded_path): 46 | return os.path.abspath(os.path.join(os.getcwd(), expanded_path)) 47 | return os.path.abspath(expanded_path) 48 | 49 | 50 | def get_edit_snippet( 51 | original_text: str, 52 | old_str: str, 53 | new_str: str, 54 | context_lines: int = 4, 55 | ) -> str: 56 | """Generate a snippet of the edited file showing the changes with line numbers. 57 | 58 | Args: 59 | original_text: The original file content 60 | old_str: The text that was replaced 61 | new_str: The new text that replaced old_str 62 | context_lines: Number of lines to show before and after the change 63 | 64 | Returns: 65 | A formatted string with line numbers and the edited content 66 | 67 | """ 68 | # Find where the edit occurs 69 | before_text = original_text.split(old_str)[0] 70 | before_lines = before_text.split("\n") 71 | replacement_line = len(before_lines) 72 | 73 | # Get the edited content 74 | edited_text = original_text.replace(old_str, new_str) 75 | edited_lines = edited_text.split("\n") 76 | 77 | # Calculate the start and end line numbers for the snippet 78 | start_line = max(0, replacement_line - context_lines) 79 | end_line = min( 80 | len(edited_lines), 81 | replacement_line + context_lines + len(new_str.split("\n")), 82 | ) 83 | 84 | # Extract the snippet lines 85 | snippet_lines = edited_lines[start_line:end_line] 86 | 87 | # Format with line numbers 88 | result: List[str] = [] 89 | for i, line in enumerate(snippet_lines): 90 | line_num = start_line + i + 1 91 | result.append(f"{line_num:4d} | {line}") 92 | 93 | return "\n".join(result) 94 | 95 | 96 | def truncate_output_content( 97 | content: Union[str, bytes, None], prefer_end: bool = True 98 | ) -> str: 99 | """Truncate command output content to a reasonable size. 100 | 101 | When prefer_end is True, this function prioritizes keeping content from the end 102 | of the output, showing some initial context and truncating the middle portion 103 | if necessary. 104 | 105 | Args: 106 | content: The command output content to truncate 107 | prefer_end: Whether to prefer keeping content from the end of the output 108 | 109 | Returns: 110 | The truncated content with appropriate indicators 111 | """ 112 | if content is None: 113 | return "" 114 | if not content: 115 | return str(content) 116 | 117 | # Convert bytes to str if needed 118 | if isinstance(content, bytes): 119 | try: 120 | content = content.decode("utf-8") 121 | except UnicodeDecodeError: 122 | return "[Binary content cannot be displayed]" 123 | 124 | lines = content.splitlines() 125 | total_lines = len(lines) 126 | 127 | # If number of lines is within the limit, check individual line lengths 128 | if total_lines <= MAX_LINES_TO_READ: 129 | # Process line lengths 130 | processed_lines: List[str] = [] 131 | for line in lines: 132 | if len(line) > MAX_LINE_LENGTH: 133 | processed_lines.append(line[:MAX_LINE_LENGTH] + "... (line truncated)") 134 | else: 135 | processed_lines.append(line) 136 | 137 | return "\n".join(processed_lines) 138 | 139 | # We need to truncate lines, decide based on preference 140 | if prefer_end: 141 | # Keep some lines from the start and prioritize the end 142 | start_lines = lines[:START_CONTEXT_LINES] 143 | 144 | # Calculate how many lines we can keep from the end 145 | end_lines_count = MAX_LINES_TO_READ - START_CONTEXT_LINES 146 | end_lines = lines[-end_lines_count:] 147 | 148 | truncated_content = ( 149 | "\n".join(start_lines) 150 | + f"\n\n... (output truncated, {total_lines - START_CONTEXT_LINES - end_lines_count} lines omitted) ...\n\n" 151 | + "\n".join(end_lines) 152 | ) 153 | else: 154 | # Standard truncation from the beginning (similar to read_file_content) 155 | truncated_content = "\n".join(lines[:MAX_LINES_TO_READ]) 156 | if total_lines > MAX_LINES_TO_READ: 157 | truncated_content += f"\n... (output truncated, showing {MAX_LINES_TO_READ} of {total_lines} lines)" 158 | 159 | return truncated_content 160 | -------------------------------------------------------------------------------- /codemcp/config.py: -------------------------------------------------------------------------------- 1 | """Configuration module for codemcp. 2 | 3 | This module provides access to user configuration stored in one of these locations: 4 | 1. $CODEMCP_CONFIG_DIR/codemcprc if $CODEMCP_CONFIG_DIR is defined 5 | 2. $XDG_CONFIG_HOME/codemcp/codemcprc if $XDG_CONFIG_HOME is defined 6 | 3. $HOME/.codemcprc 7 | 8 | The configuration is stored in TOML format. 9 | """ 10 | 11 | import os 12 | from pathlib import Path 13 | from typing import Any 14 | 15 | import tomli 16 | 17 | __all__ = [ 18 | "get_config_path", 19 | "load_config", 20 | "get_logger_verbosity", 21 | "get_logger_path", 22 | "get_line_endings_preference", 23 | ] 24 | 25 | # Default configuration values 26 | DEFAULT_CONFIG = { 27 | "logger": { 28 | "verbosity": "INFO", # Default logging level 29 | "path": str(Path.home() / ".codemcp"), # Default logger path 30 | }, 31 | "files": { 32 | "line_endings": None, # Default to OS native or based on configs 33 | }, 34 | } 35 | 36 | 37 | def get_config_path() -> Path: 38 | """Return the path to the user's config file. 39 | 40 | Checks the following locations in order: 41 | 1. $CODEMCP_CONFIG_DIR/codemcprc if $CODEMCP_CONFIG_DIR is defined 42 | 2. $XDG_CONFIG_HOME/codemcp/codemcprc if $XDG_CONFIG_HOME is defined 43 | 3. Fallback to $HOME/.codemcprc 44 | 45 | Returns: 46 | Path to the config file 47 | """ 48 | # Check $CODEMCP_CONFIG_DIR first 49 | if "CODEMCP_CONFIG_DIR" in os.environ: 50 | path = Path(os.environ["CODEMCP_CONFIG_DIR"]) / "codemcprc" 51 | if path.exists(): 52 | return path 53 | 54 | # Check $XDG_CONFIG_HOME next 55 | if "XDG_CONFIG_HOME" in os.environ: 56 | path = Path(os.environ["XDG_CONFIG_HOME"]) / "codemcp" / "codemcprc" 57 | if path.exists(): 58 | return path 59 | 60 | # Fallback to $HOME/.codemcprc 61 | return Path.home() / ".codemcprc" 62 | 63 | 64 | def load_config() -> dict[str, Any]: 65 | """Load configuration from the config file. 66 | 67 | Looks for the config file in the locations specified by get_config_path(): 68 | 1. $CODEMCP_CONFIG_DIR/codemcprc if $CODEMCP_CONFIG_DIR is defined 69 | 2. $XDG_CONFIG_HOME/codemcp/codemcprc if $XDG_CONFIG_HOME is defined 70 | 3. Fallback to $HOME/.codemcprc 71 | 72 | Returns: 73 | Dict containing the merged configuration (defaults + user config). 74 | """ 75 | config = DEFAULT_CONFIG.copy() 76 | config_path = get_config_path() 77 | 78 | if config_path.exists(): 79 | try: 80 | with open(config_path, "rb") as f: 81 | user_config = tomli.load(f) 82 | 83 | # Merge user config with defaults 84 | _merge_configs(config, user_config) 85 | except Exception as e: 86 | print(f"Error loading config from {config_path}: {e}") 87 | 88 | return config 89 | 90 | 91 | def _merge_configs(base: dict[str, Any], override: dict[str, Any]) -> None: 92 | """Recursively merge override dict into base dict. 93 | 94 | Args: 95 | base: The base configuration dictionary to merge into. 96 | override: The override configuration dictionary to merge from. 97 | 98 | """ 99 | for key, value in override.items(): 100 | if key in base and isinstance(base[key], dict) and isinstance(value, dict): 101 | # Type annotation to help the type checker understand that value is dict[str, Any] 102 | nested_value: dict[str, Any] = value 103 | _merge_configs(base[key], nested_value) 104 | else: 105 | base[key] = value 106 | 107 | 108 | def get_logger_verbosity() -> str: 109 | """Get the configured logger verbosity level. 110 | 111 | Returns: 112 | String representing the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). 113 | 114 | """ 115 | config = load_config() 116 | return config["logger"]["verbosity"] 117 | 118 | 119 | def get_logger_path() -> str: 120 | """Get the configured logger path. 121 | 122 | Returns: 123 | String representing the path where logs should be stored. 124 | 125 | """ 126 | config = load_config() 127 | return config["logger"]["path"] 128 | 129 | 130 | def get_line_endings_preference() -> str | None: 131 | """Get the configured line endings preference. 132 | 133 | Returns: 134 | String representing the preferred line endings ('CRLF' or 'LF'), or None if not specified. 135 | 136 | """ 137 | config = load_config() 138 | return config["files"]["line_endings"] 139 | -------------------------------------------------------------------------------- /codemcp/git.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This file re-exports functions from git_*.py modules for backward compatibility 4 | 5 | from .git_commit import commit_changes, create_commit_reference 6 | from .git_message import append_metadata_to_message 7 | from .git_query import ( 8 | get_head_commit_chat_id, 9 | get_head_commit_hash, 10 | get_head_commit_message, 11 | get_ref_commit_chat_id, 12 | get_repository_root, 13 | is_git_repository, 14 | ) 15 | 16 | __all__ = [ 17 | # From git_query.py 18 | "get_head_commit_message", 19 | "get_head_commit_hash", 20 | "get_head_commit_chat_id", 21 | "get_repository_root", 22 | "is_git_repository", 23 | "get_ref_commit_chat_id", 24 | # From git_message.py 25 | "append_metadata_to_message", 26 | # From git_commit.py 27 | "commit_changes", 28 | "create_commit_reference", 29 | ] 30 | -------------------------------------------------------------------------------- /codemcp/mcp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from mcp.server.fastmcp import FastMCP 4 | 5 | # Initialize FastMCP server 6 | mcp = FastMCP("codemcp") 7 | 8 | __all__ = [ 9 | "mcp", 10 | ] 11 | -------------------------------------------------------------------------------- /codemcp/shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import logging 5 | import subprocess 6 | from typing import Dict, List, Optional, Union 7 | 8 | __all__ = [ 9 | "run_command", 10 | "get_subprocess_env", 11 | ] 12 | 13 | 14 | def get_subprocess_env() -> Optional[Dict[str, str]]: 15 | """ 16 | Get the environment variables to be used for subprocess execution. 17 | This function can be mocked in tests to control the environment. 18 | 19 | Returns: 20 | Optional dictionary of environment variables, or None to use the current environment. 21 | """ 22 | return None 23 | 24 | 25 | async def run_command( 26 | cmd: List[str], 27 | cwd: Optional[str] = None, 28 | check: bool = True, 29 | capture_output: bool = True, 30 | text: bool = True, 31 | wait_time: Optional[float] = None, # Renamed from timeout to avoid ASYNC109 32 | shell: bool = False, 33 | input: Optional[str] = None, 34 | ) -> subprocess.CompletedProcess[Union[str, bytes]]: 35 | """ 36 | Run a subprocess command with consistent logging asynchronously. 37 | 38 | Args: 39 | cmd: Command to run as a list of strings 40 | cwd: Current working directory for the command 41 | check: If True, raise RuntimeError if the command returns non-zero exit code 42 | capture_output: If True, capture stdout and stderr 43 | text: If True, decode stdout and stderr as text 44 | wait_time: Timeout in seconds 45 | shell: If True, run command in a shell 46 | input: Input to pass to the subprocess's stdin 47 | 48 | Returns: 49 | CompletedProcess instance with attributes args, returncode, stdout, stderr 50 | 51 | Raises: 52 | RuntimeError: If check=True and process returns non-zero exit code 53 | subprocess.TimeoutExpired: If the process times out 54 | 55 | Notes: 56 | Environment variables are obtained from get_subprocess_env() function. 57 | """ 58 | # Log the command being run at INFO level 59 | log_cmd = " ".join(str(c) for c in cmd) 60 | logging.info(f"Running command: {log_cmd}") 61 | 62 | # Prepare stdout and stderr pipes 63 | stdout_pipe = asyncio.subprocess.PIPE if capture_output else None 64 | stderr_pipe = asyncio.subprocess.PIPE if capture_output else None 65 | stdin_pipe = asyncio.subprocess.PIPE if input is not None else None 66 | 67 | # Convert input to bytes if provided 68 | input_bytes = None 69 | if input is not None: 70 | input_bytes = input.encode() 71 | 72 | # Run the subprocess asynchronously 73 | process = await asyncio.create_subprocess_exec( 74 | *cmd, 75 | cwd=cwd, 76 | env=get_subprocess_env(), 77 | stdout=stdout_pipe, 78 | stderr=stderr_pipe, 79 | stdin=stdin_pipe, 80 | ) 81 | 82 | try: 83 | # Wait for the process to complete with optional timeout 84 | stdout_data, stderr_data = await asyncio.wait_for( 85 | process.communicate(input=input_bytes), timeout=wait_time 86 | ) 87 | except asyncio.TimeoutError: 88 | process.kill() 89 | await process.wait() 90 | raise subprocess.TimeoutExpired( 91 | cmd, float(wait_time) if wait_time is not None else 0.0 92 | ) 93 | 94 | # Handle text conversion 95 | stdout = "" 96 | stderr = "" 97 | if capture_output: 98 | if text and stdout_data: 99 | stdout = stdout_data.decode() 100 | logging.debug(f"Command stdout: {stdout}") 101 | elif stdout_data: 102 | stdout = stdout_data 103 | logging.debug(f"Command stdout: {len(stdout_data)} bytes") 104 | 105 | if text and stderr_data: 106 | stderr = stderr_data.decode() 107 | logging.debug(f"Command stderr: {stderr}") 108 | elif stderr_data: 109 | stderr = stderr_data 110 | logging.debug(f"Command stderr: {len(stderr_data)} bytes") 111 | 112 | # Log the return code 113 | returncode = process.returncode 114 | logging.debug(f"Command return code: {returncode}") 115 | 116 | # Create a CompletedProcess object to maintain compatibility 117 | result = subprocess.CompletedProcess[Union[str, bytes]]( 118 | args=cmd, 119 | returncode=0 if returncode is None else returncode, 120 | stdout=stdout, 121 | stderr=stderr, 122 | ) 123 | 124 | # Raise RuntimeError if check is True and command failed 125 | if check and result.returncode != 0: 126 | error_message = f"Command failed with exit code {result.returncode}: {' '.join(str(c) for c in cmd)}" 127 | if result.stdout: 128 | error_message += f"\nStdout: {result.stdout}" 129 | if result.stderr: 130 | error_message += f"\nStderr: {result.stderr}" 131 | raise RuntimeError(error_message) 132 | 133 | return result 134 | -------------------------------------------------------------------------------- /codemcp/templates/README.md: -------------------------------------------------------------------------------- 1 | ## python 2 | 3 | Reasoning behind decisions: 4 | 5 | - Adopt as many `uv init` defaults as possible, e.g., py.typed by default. 6 | 7 | - `_internal` directory to encourage no public API by default. Hopefully 8 | induce LLM to be willing to do refactors without leaving BC shims lying 9 | around. 10 | 11 | - Build system: hatchling. uv default. We do want a build backend so that 12 | CLI tool invocation works. 13 | 14 | - Python version: the latest. If uv is managing your Python install for a CLI 15 | there's no reason not to use the latest Python, UNLESS you have a dependency 16 | that specifically needs something earlier. (Library would make different 17 | decision here.) 18 | 19 | - Version bounds on built-in dependencies: latest possible, because that is 20 | the 'uv add' default. We will need to occasionally rev these though. 21 | 22 | - `--tb=native` on pytest by default because the default pytest backtraces 23 | are overly long and blow out your model's context. Actually there is 24 | probably more improvement for the pytest output formatting possible here. 25 | 26 | Some especially controversial decisions: 27 | 28 | - `pytest-xdist` is enabled by default. This is a huge QoL improvement as 29 | your tests run a lot faster but you need to actually have tests that are 30 | parallel-safe, which will be hit-or-miss with a model, and there won't be a 31 | clear signal when you've messed up as it will be nondeterministically 32 | failing tests (the worst kind). It's easiest to enforce that running tests 33 | in parallel is safe early in the project though, so we think the payoff is 34 | worth it, especially since you can ask the LLM to rewrite code that is not 35 | parallel safe to be parallel safe. 36 | -------------------------------------------------------------------------------- /codemcp/templates/blank/codemcp.toml: -------------------------------------------------------------------------------- 1 | # codemcp configuration file 2 | -------------------------------------------------------------------------------- /codemcp/templates/python/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | -------------------------------------------------------------------------------- /codemcp/templates/python/README.md: -------------------------------------------------------------------------------- 1 | # __PROJECT_NAME__ 2 | 3 | TODO: Write project description 4 | -------------------------------------------------------------------------------- /codemcp/templates/python/__PACKAGE_NAME__/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /codemcp/templates/python/__PACKAGE_NAME__/_internal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ezyang/codemcp/41dfe735e1541d6db93f326e43db66f1e7038425/codemcp/templates/python/__PACKAGE_NAME__/_internal/__init__.py -------------------------------------------------------------------------------- /codemcp/templates/python/__PACKAGE_NAME__/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ezyang/codemcp/41dfe735e1541d6db93f326e43db66f1e7038425/codemcp/templates/python/__PACKAGE_NAME__/py.typed -------------------------------------------------------------------------------- /codemcp/templates/python/codemcp.toml: -------------------------------------------------------------------------------- 1 | # codemcp configuration file for __PROJECT_NAME__ 2 | 3 | [commands] 4 | format = ["ruff", "format"] 5 | # TODO: I don't think this is right, need to read the manual, 6 | # need to be a lot more opinionated about lints here 7 | lint = ["ruff", "check", "--fix"] 8 | typecheck = ["uv", "run", "pyright"] 9 | test = ["uv", "run", "pytest"] 10 | -------------------------------------------------------------------------------- /codemcp/templates/python/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "__PROJECT_NAME__" 3 | version = "0.1.0" 4 | description = "TODO: project description" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | license = {text = "MIT"} 8 | dependencies = [] 9 | 10 | [build-system] 11 | requires = ["hatchling"] 12 | build-backend = "hatchling.build" 13 | 14 | [tool.pytest.ini_options] 15 | addopts = "-n auto --tb=native" 16 | 17 | [tool.pyright] 18 | # Pyright configuration with strict settings 19 | include = ["__PACKAGE_NAME__"] 20 | exclude = ["**/__pycache__", "dist"] 21 | stubPath = "stubs" 22 | venvPath = "." 23 | venv = ".venv" 24 | reportMissingImports = true 25 | reportMissingTypeStubs = true 26 | pythonVersion = "3.12" 27 | pythonPlatform = "All" 28 | typeCheckingMode = "strict" 29 | reportUnknownMemberType = true 30 | reportUnknownParameterType = true 31 | reportUnknownVariableType = true 32 | reportUnknownArgumentType = true 33 | reportPrivateImportUsage = true 34 | reportUntypedFunctionDecorator = true 35 | reportFunctionMemberAccess = true 36 | reportIncompatibleMethodOverride = true 37 | 38 | [tool.ruff] 39 | target-version = "py312" 40 | 41 | [tool.uv] 42 | dev-dependencies = [ 43 | "pytest>=8.3.5", 44 | "ruff>=0.11.2", 45 | "pytest-xdist>=3.6.1", 46 | "pyright>=1.1.350", 47 | ] 48 | -------------------------------------------------------------------------------- /codemcp/tools/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Implement code_command.py utilities here 3 | 4 | from .chmod import chmod 5 | from .git_blame import git_blame 6 | from .git_diff import git_diff 7 | from .git_log import git_log 8 | from .git_show import git_show 9 | from .mv import mv 10 | from .rm import rm 11 | 12 | __all__ = [ 13 | "chmod", 14 | "git_blame", 15 | "git_diff", 16 | "git_log", 17 | "git_show", 18 | "mv", 19 | "rm", 20 | ] 21 | -------------------------------------------------------------------------------- /codemcp/tools/chmod.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import stat 5 | from typing import Any 6 | 7 | from ..common import normalize_file_path 8 | from ..git import commit_changes 9 | from ..mcp import mcp 10 | from ..shell import run_command 11 | from .commit_utils import append_commit_hash 12 | 13 | __all__ = [ 14 | "chmod", 15 | "render_result_for_assistant", 16 | "TOOL_NAME_FOR_PROMPT", 17 | "DESCRIPTION", 18 | ] 19 | 20 | TOOL_NAME_FOR_PROMPT = "Chmod" 21 | DESCRIPTION = """ 22 | Changes file permissions using chmod. Unlike standard chmod, this tool only supports 23 | a+x (add executable permission) and a-x (remove executable permission), because these 24 | are the only bits that git knows how to track. 25 | 26 | Example: 27 | chmod a+x path/to/file # Makes a file executable by all users 28 | chmod a-x path/to/file # Makes a file non-executable for all users 29 | """ 30 | 31 | 32 | @mcp.tool() 33 | async def chmod( 34 | path: str, 35 | mode: str, 36 | chat_id: str | None = None, 37 | commit_hash: str | None = None, 38 | ) -> str: 39 | """Changes file permissions using chmod. Unlike standard chmod, this tool only supports 40 | a+x (add executable permission) and a-x (remove executable permission), because these 41 | are the only bits that git knows how to track. 42 | 43 | Args: 44 | path: The absolute path to the file to modify 45 | mode: The chmod mode to apply, only "a+x" and "a-x" are supported 46 | chat_id: The unique ID to identify the chat session 47 | commit_hash: Optional Git commit hash for version tracking 48 | 49 | Example: 50 | chmod a+x path/to/file # Makes a file executable by all users 51 | chmod a-x path/to/file # Makes a file non-executable for all users 52 | 53 | Returns: 54 | A formatted string with the chmod operation result 55 | """ 56 | # Set default values 57 | chat_id = "" if chat_id is None else chat_id 58 | 59 | if not path: 60 | raise ValueError("File path must be provided") 61 | 62 | # Normalize the file path 63 | absolute_path = normalize_file_path(path) 64 | 65 | # Check if file exists 66 | if not os.path.exists(absolute_path): 67 | raise FileNotFoundError(f"The file does not exist: {path}") 68 | 69 | # Verify that the mode is supported 70 | if mode not in ["a+x", "a-x"]: 71 | raise ValueError( 72 | f"Unsupported chmod mode: {mode}. Only 'a+x' and 'a-x' are supported." 73 | ) 74 | 75 | # Get the directory containing the file for git operations 76 | directory = os.path.dirname(absolute_path) 77 | 78 | # Check current file permissions 79 | current_mode = os.stat(absolute_path).st_mode 80 | is_executable = bool(current_mode & stat.S_IXUSR) 81 | 82 | if mode == "a+x" and is_executable: 83 | message = f"File '{path}' is already executable" 84 | # Append commit hash 85 | message, _ = await append_commit_hash(message, directory, commit_hash) 86 | return message 87 | elif mode == "a-x" and not is_executable: 88 | message = f"File '{path}' is already non-executable" 89 | # Append commit hash 90 | message, _ = await append_commit_hash(message, directory, commit_hash) 91 | return message 92 | 93 | # Execute chmod command 94 | cmd = ["chmod", mode, absolute_path] 95 | await run_command( 96 | cmd=cmd, 97 | cwd=directory, 98 | capture_output=True, 99 | text=True, 100 | check=True, 101 | ) 102 | 103 | # Prepare success message 104 | if mode == "a+x": 105 | description = f"Make '{os.path.basename(absolute_path)}' executable" 106 | action_msg = f"Made file '{path}' executable" 107 | else: 108 | description = ( 109 | f"Remove executable permission from '{os.path.basename(absolute_path)}'" 110 | ) 111 | action_msg = f"Removed executable permission from file '{path}'" 112 | 113 | # Commit the changes 114 | success, commit_message = await commit_changes( 115 | directory, 116 | description, 117 | chat_id, 118 | ) 119 | 120 | if not success: 121 | raise RuntimeError(f"Failed to commit chmod changes: {commit_message}") 122 | 123 | # Prepare result string 124 | result_string = f"{action_msg} and committed changes" 125 | 126 | # Format the result for assistant 127 | formatted_result = render_result_for_assistant({"output": result_string}) 128 | 129 | # Append commit hash 130 | formatted_result, _ = await append_commit_hash( 131 | formatted_result, directory, commit_hash 132 | ) 133 | 134 | return formatted_result 135 | 136 | 137 | def render_result_for_assistant(output: dict[str, Any]) -> str: 138 | """Render the results in a format suitable for the assistant. 139 | 140 | Args: 141 | output: The chmod output dictionary 142 | 143 | Returns: 144 | A formatted string representation of the results 145 | """ 146 | return output.get("output", "") 147 | -------------------------------------------------------------------------------- /codemcp/tools/commit_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | from typing import Tuple 5 | 6 | from ..common import normalize_file_path 7 | from ..git_query import get_current_commit_hash 8 | 9 | __all__ = [ 10 | "append_commit_hash", 11 | ] 12 | 13 | 14 | async def append_commit_hash( 15 | result: str, path: str | None, commit_hash: str | None = None 16 | ) -> Tuple[str, str | None]: 17 | """Get the current Git commit hash and append it to the result string. 18 | 19 | Args: 20 | result: The original result string to append to 21 | path: Path to the Git repository (if available) 22 | commit_hash: Optional Git commit hash to use instead of fetching the current one 23 | 24 | Returns: 25 | A tuple containing: 26 | - The result string with the commit hash appended 27 | - The current commit hash if available, None otherwise 28 | """ 29 | # If commit_hash is provided, use it directly 30 | if commit_hash: 31 | return f"{result}\n\nCurrent commit hash: {commit_hash}", commit_hash 32 | 33 | if path is None: 34 | return result, None 35 | 36 | # Normalize the path 37 | normalized_path = normalize_file_path(path) 38 | 39 | try: 40 | current_hash = await get_current_commit_hash(normalized_path) 41 | if current_hash: 42 | return f"{result}\n\nCurrent commit hash: {current_hash}", current_hash 43 | except Exception as e: 44 | logging.warning(f"Failed to get current commit hash: {e}", exc_info=True) 45 | 46 | return result, None 47 | -------------------------------------------------------------------------------- /codemcp/tools/git_blame.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import shlex 5 | from typing import Any 6 | 7 | from ..common import normalize_file_path 8 | from ..git import is_git_repository 9 | from ..shell import run_command 10 | 11 | __all__ = [ 12 | "git_blame", 13 | "render_result_for_assistant", 14 | "TOOL_NAME_FOR_PROMPT", 15 | "DESCRIPTION", 16 | ] 17 | 18 | TOOL_NAME_FOR_PROMPT = "GitBlame" 19 | DESCRIPTION = """ 20 | Shows what revision and author last modified each line of a file using git blame. 21 | This tool is read-only and safe to use with any arguments. 22 | The arguments parameter should be a string and will be interpreted as space-separated 23 | arguments using shell-style tokenization (spaces separate arguments, quotes can be used 24 | for arguments containing spaces, etc.). 25 | 26 | Example: 27 | git blame path/to/file # Show blame information for a file 28 | git blame -L 10,20 path/to/file # Show blame information for lines 10-20 29 | git blame -w path/to/file # Ignore whitespace changes 30 | """ 31 | 32 | 33 | async def git_blame( 34 | arguments: str | None = None, 35 | path: str | None = None, 36 | chat_id: str | None = None, 37 | ) -> dict[str, Any]: 38 | """Execute git blame with the provided arguments. 39 | 40 | Args: 41 | arguments: Optional arguments to pass to git blame as a string 42 | path: The directory to execute the command in (must be in a git repository) 43 | chat_id: The unique ID of the current chat session 44 | 45 | Returns: 46 | A dictionary with git blame output 47 | """ 48 | 49 | if path is None: 50 | raise ValueError("Path must be provided for git blame") 51 | 52 | # Normalize the directory path 53 | absolute_path = normalize_file_path(path) 54 | 55 | # Verify this is a git repository 56 | if not await is_git_repository(absolute_path): 57 | raise ValueError(f"The provided path is not in a git repository: {path}") 58 | 59 | # Build command 60 | cmd = ["git", "blame"] 61 | 62 | # Add additional arguments if provided 63 | if arguments: 64 | parsed_args = shlex.split(arguments) 65 | cmd.extend(parsed_args) 66 | 67 | logging.debug(f"Executing git blame command: {' '.join(cmd)}") 68 | 69 | # Execute git blame command asynchronously 70 | result = await run_command( 71 | cmd=cmd, 72 | cwd=absolute_path, 73 | capture_output=True, 74 | text=True, 75 | check=True, # Allow exception if git blame fails to propagate up 76 | ) 77 | 78 | # Prepare output 79 | output = { 80 | "output": result.stdout, 81 | } 82 | 83 | # Add formatted result for assistant 84 | output["resultForAssistant"] = render_result_for_assistant(output) 85 | 86 | return output 87 | 88 | 89 | def render_result_for_assistant(output: dict[str, Any]) -> str: 90 | """Render the results in a format suitable for the assistant. 91 | 92 | Args: 93 | output: The git blame output dictionary 94 | 95 | Returns: 96 | A formatted string representation of the results 97 | """ 98 | return output.get("output", "") 99 | -------------------------------------------------------------------------------- /codemcp/tools/git_diff.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import shlex 5 | from typing import Any 6 | 7 | from ..common import normalize_file_path 8 | from ..git import is_git_repository 9 | from ..shell import run_command 10 | 11 | __all__ = [ 12 | "git_diff", 13 | "render_result_for_assistant", 14 | "TOOL_NAME_FOR_PROMPT", 15 | "DESCRIPTION", 16 | ] 17 | 18 | TOOL_NAME_FOR_PROMPT = "GitDiff" 19 | DESCRIPTION = """ 20 | Shows differences between commits, commit and working tree, etc. using git diff. 21 | This tool is read-only and safe to use with any arguments. 22 | The arguments parameter should be a string and will be interpreted as space-separated 23 | arguments using shell-style tokenization (spaces separate arguments, quotes can be used 24 | for arguments containing spaces, etc.). 25 | 26 | Example: 27 | git diff # Show changes between working directory and index 28 | git diff HEAD~1 # Show changes between current commit and previous commit 29 | git diff branch1 branch2 # Show differences between two branches 30 | git diff --stat # Show summary of changes instead of full diff 31 | """ 32 | 33 | 34 | async def git_diff( 35 | arguments: str | None = None, 36 | path: str | None = None, 37 | chat_id: str | None = None, 38 | ) -> dict[str, Any]: 39 | """Execute git diff with the provided arguments. 40 | 41 | Args: 42 | arguments: Optional arguments to pass to git diff as a string 43 | path: The directory to execute the command in (must be in a git repository) 44 | chat_id: The unique ID of the current chat session 45 | 46 | Returns: 47 | A dictionary with git diff output 48 | """ 49 | 50 | if path is None: 51 | raise ValueError("Path must be provided for git diff") 52 | 53 | # Normalize the directory path 54 | absolute_path = normalize_file_path(path) 55 | 56 | # Verify this is a git repository 57 | if not await is_git_repository(absolute_path): 58 | raise ValueError(f"The provided path is not in a git repository: {path}") 59 | 60 | # Build command 61 | cmd = ["git", "diff"] 62 | 63 | # Add additional arguments if provided 64 | if arguments: 65 | parsed_args = shlex.split(arguments) 66 | cmd.extend(parsed_args) 67 | 68 | logging.debug(f"Executing git diff command: {' '.join(cmd)}") 69 | 70 | # Execute git diff command asynchronously 71 | result = await run_command( 72 | cmd=cmd, 73 | cwd=absolute_path, 74 | capture_output=True, 75 | text=True, 76 | check=True, # Allow exception if git diff fails to propagate up 77 | ) 78 | 79 | # Prepare output 80 | output = { 81 | "output": result.stdout, 82 | } 83 | 84 | # Add formatted result for assistant 85 | output["resultForAssistant"] = render_result_for_assistant(output) 86 | 87 | return output 88 | 89 | 90 | def render_result_for_assistant(output: dict[str, Any]) -> str: 91 | """Render the results in a format suitable for the assistant. 92 | 93 | Args: 94 | output: The git diff output dictionary 95 | 96 | Returns: 97 | A formatted string representation of the results 98 | """ 99 | return output.get("output", "") 100 | -------------------------------------------------------------------------------- /codemcp/tools/git_log.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import shlex 5 | from typing import Any 6 | 7 | from ..common import normalize_file_path 8 | from ..git import is_git_repository 9 | from ..shell import run_command 10 | 11 | __all__ = [ 12 | "git_log", 13 | "render_result_for_assistant", 14 | "TOOL_NAME_FOR_PROMPT", 15 | "DESCRIPTION", 16 | ] 17 | 18 | TOOL_NAME_FOR_PROMPT = "GitLog" 19 | DESCRIPTION = """ 20 | Shows commit logs using git log. 21 | This tool is read-only and safe to use with any arguments. 22 | The arguments parameter should be a string and will be interpreted as space-separated 23 | arguments using shell-style tokenization (spaces separate arguments, quotes can be used 24 | for arguments containing spaces, etc.). 25 | 26 | Example: 27 | git log --oneline -n 5 # Show the last 5 commits in oneline format 28 | git log --author="John Doe" --since="2023-01-01" # Show commits by an author since a date 29 | git log -- path/to/file # Show commit history for a specific file 30 | """ 31 | 32 | 33 | async def git_log( 34 | arguments: str | None = None, 35 | path: str | None = None, 36 | chat_id: str | None = None, 37 | ) -> dict[str, Any]: 38 | """Execute git log with the provided arguments. 39 | 40 | Args: 41 | arguments: Optional arguments to pass to git log as a string 42 | path: The directory to execute the command in (must be in a git repository) 43 | chat_id: The unique ID of the current chat session 44 | 45 | Returns: 46 | A dictionary with git log output 47 | """ 48 | 49 | if path is None: 50 | raise ValueError("Path must be provided for git log") 51 | 52 | # Normalize the directory path 53 | absolute_path = normalize_file_path(path) 54 | 55 | # Verify this is a git repository 56 | if not await is_git_repository(absolute_path): 57 | raise ValueError(f"The provided path is not in a git repository: {path}") 58 | 59 | # Build command 60 | cmd = ["git", "log"] 61 | 62 | # Add additional arguments if provided 63 | if arguments: 64 | parsed_args = shlex.split(arguments) 65 | cmd.extend(parsed_args) 66 | 67 | logging.debug(f"Executing git log command: {' '.join(cmd)}") 68 | 69 | # Execute git log command asynchronously 70 | result = await run_command( 71 | cmd=cmd, 72 | cwd=absolute_path, 73 | capture_output=True, 74 | text=True, 75 | check=True, # Allow exception if git log fails to propagate up 76 | ) 77 | 78 | # Prepare output 79 | output = { 80 | "output": result.stdout, 81 | } 82 | 83 | # Add formatted result for assistant 84 | output["resultForAssistant"] = render_result_for_assistant(output) 85 | 86 | return output 87 | 88 | 89 | def render_result_for_assistant(output: dict[str, Any]) -> str: 90 | """Render the results in a format suitable for the assistant. 91 | 92 | Args: 93 | output: The git log output dictionary 94 | 95 | Returns: 96 | A formatted string representation of the results 97 | """ 98 | return output.get("output", "") 99 | -------------------------------------------------------------------------------- /codemcp/tools/git_show.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import shlex 5 | from typing import Any 6 | 7 | from ..common import normalize_file_path 8 | from ..git import is_git_repository 9 | from ..shell import run_command 10 | 11 | __all__ = [ 12 | "git_show", 13 | "render_result_for_assistant", 14 | "TOOL_NAME_FOR_PROMPT", 15 | "DESCRIPTION", 16 | ] 17 | 18 | TOOL_NAME_FOR_PROMPT = "GitShow" 19 | DESCRIPTION = """ 20 | Shows various types of objects (commits, tags, trees, blobs) using git show. 21 | This tool is read-only and safe to use with any arguments. 22 | The arguments parameter should be a string and will be interpreted as space-separated 23 | arguments using shell-style tokenization (spaces separate arguments, quotes can be used 24 | for arguments containing spaces, etc.). 25 | 26 | Example: 27 | git show # Show the most recent commit 28 | git show a1b2c3d # Show a specific commit by hash 29 | git show HEAD~3 # Show the commit 3 before HEAD 30 | git show v1.0 # Show a tag 31 | git show HEAD:path/to/file # Show a file from a specific commit 32 | """ 33 | 34 | 35 | async def git_show( 36 | arguments: str | None = None, 37 | path: str | None = None, 38 | chat_id: str | None = None, 39 | ) -> dict[str, Any]: 40 | """Execute git show with the provided arguments. 41 | 42 | Args: 43 | arguments: Optional arguments to pass to git show as a string 44 | path: The directory to execute the command in (must be in a git repository) 45 | chat_id: The unique ID of the current chat session 46 | 47 | Returns: 48 | A dictionary with git show output 49 | """ 50 | 51 | if path is None: 52 | raise ValueError("Path must be provided for git show") 53 | 54 | # Normalize the directory path 55 | absolute_path = normalize_file_path(path) 56 | 57 | # Verify this is a git repository 58 | if not await is_git_repository(absolute_path): 59 | raise ValueError(f"The provided path is not in a git repository: {path}") 60 | 61 | # Build command 62 | cmd = ["git", "show"] 63 | 64 | # Add additional arguments if provided 65 | if arguments: 66 | parsed_args = shlex.split(arguments) 67 | cmd.extend(parsed_args) 68 | 69 | logging.debug(f"Executing git show command: {' '.join(cmd)}") 70 | 71 | # Execute git show command asynchronously 72 | result = await run_command( 73 | cmd=cmd, 74 | cwd=absolute_path, 75 | capture_output=True, 76 | text=True, 77 | check=True, # Allow exception if git show fails to propagate up 78 | ) 79 | 80 | # Prepare output 81 | output = { 82 | "output": result.stdout, 83 | } 84 | 85 | # Add formatted result for assistant 86 | output["resultForAssistant"] = render_result_for_assistant(output) 87 | 88 | return output 89 | 90 | 91 | def render_result_for_assistant(output: dict[str, Any]) -> str: 92 | """Render the results in a format suitable for the assistant. 93 | 94 | Args: 95 | output: The git show output dictionary 96 | 97 | Returns: 98 | A formatted string representation of the results 99 | """ 100 | return output.get("output", "") 101 | -------------------------------------------------------------------------------- /codemcp/tools/glob.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import fnmatch 4 | import logging 5 | import os 6 | from typing import Any, Dict, List 7 | 8 | from ..common import normalize_file_path 9 | from ..git import is_git_repository 10 | from ..mcp import mcp 11 | from .commit_utils import append_commit_hash 12 | 13 | __all__ = [ 14 | "glob", 15 | "render_result_for_assistant", 16 | ] 17 | 18 | 19 | def render_result_for_assistant(output: Dict[str, Any]) -> str: 20 | """Render the glob results in a format suitable for the assistant. 21 | 22 | Args: 23 | output: The output from the glob operation 24 | 25 | Returns: 26 | A formatted string representation of the results 27 | """ 28 | filenames = output.get("files", []) 29 | num_files = output.get("total", 0) 30 | 31 | if num_files == 0: 32 | return "No files found" 33 | 34 | result = f"Found {num_files} files:\n\n" 35 | 36 | # Add each filename to the result 37 | for filename in filenames: 38 | result += f"{filename}\n" 39 | 40 | return result 41 | 42 | 43 | @mcp.tool() 44 | async def glob( 45 | pattern: str, 46 | path: str, 47 | limit: int | None = None, 48 | offset: int | None = None, 49 | chat_id: str | None = None, 50 | commit_hash: str | None = None, 51 | ) -> str: 52 | """Fast file pattern matching tool that works with any codebase size 53 | Supports glob patterns like "**/*.js" or "src/**/*.ts" 54 | Returns matching file paths sorted by modification time 55 | Use this tool when you need to find files by name patterns 56 | 57 | Args: 58 | pattern: The glob pattern to match files against 59 | path: The directory to search in 60 | limit: Maximum number of results to return 61 | offset: Number of results to skip (for pagination) 62 | chat_id: The unique ID of the current chat session 63 | commit_hash: Optional Git commit hash for version tracking 64 | 65 | Returns: 66 | A formatted string with the search results 67 | 68 | """ 69 | try: 70 | # Set default values 71 | chat_id = "" if chat_id is None else chat_id 72 | limit_val = 100 if limit is None else limit 73 | offset_val = 0 if offset is None else offset 74 | 75 | # Normalize the directory path 76 | full_directory_path = normalize_file_path(path) 77 | 78 | # Validate the directory path 79 | if not os.path.exists(full_directory_path): 80 | raise FileNotFoundError(f"Directory does not exist: {path}") 81 | 82 | if not os.path.isdir(full_directory_path): 83 | raise NotADirectoryError(f"Path is not a directory: {path}") 84 | 85 | # Safety check: Verify the directory is within a git repository with codemcp.toml 86 | if not await is_git_repository(full_directory_path): 87 | raise ValueError(f"Directory is not in a Git repository: {path}") 88 | 89 | # Find all matching files 90 | matches: List[str] = [] 91 | for root, dirs, files in os.walk(full_directory_path): 92 | # Skip hidden directories 93 | dirs[:] = [d for d in dirs if not d.startswith(".")] 94 | 95 | # Check files against the pattern 96 | for file in files: 97 | if file.startswith("."): 98 | continue 99 | 100 | file_path = os.path.join(root, file) 101 | rel_path = os.path.relpath(file_path, full_directory_path) 102 | 103 | if fnmatch.fnmatch(rel_path, pattern): 104 | matches.append(rel_path) 105 | 106 | # Sort the matches 107 | matches.sort() 108 | 109 | # Apply offset and limit 110 | total_matches = len(matches) 111 | matches = matches[offset_val : offset_val + limit_val] 112 | 113 | # Create the result dictionary 114 | 115 | # Format the results 116 | if not matches: 117 | output = f"No files matching '{pattern}' found in {path}" 118 | else: 119 | output = f"Found {total_matches} files matching '{pattern}' in {path}" 120 | if offset_val > 0 or total_matches > offset_val + limit_val: 121 | output += f" (showing {offset_val + 1}-{min(offset_val + limit_val, total_matches)} of {total_matches})" 122 | output += ":\n\n" 123 | 124 | for match in matches: 125 | output += f"{match}\n" 126 | 127 | # Append commit hash 128 | result, _ = await append_commit_hash(output, full_directory_path, commit_hash) 129 | return result 130 | except Exception as e: 131 | # Log the error 132 | logging.error(f"Error in glob: {e}", exc_info=True) 133 | 134 | # Return error message 135 | error_message = f"Error searching for files: {e}" 136 | return error_message 137 | -------------------------------------------------------------------------------- /codemcp/tools/mv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import os 5 | import pathlib 6 | 7 | from ..common import normalize_file_path 8 | from ..git import commit_changes, get_repository_root 9 | from ..mcp import mcp 10 | from ..shell import run_command 11 | from .commit_utils import append_commit_hash 12 | 13 | __all__ = [ 14 | "mv", 15 | ] 16 | 17 | 18 | @mcp.tool() 19 | async def mv( 20 | source_path: str, 21 | target_path: str, 22 | description: str | None = None, 23 | chat_id: str | None = None, 24 | commit_hash: str | None = None, 25 | ) -> str: 26 | """Moves a file using git mv and commits the change. 27 | Provide a short description of why the file is being moved. 28 | 29 | Before using this tool: 30 | 1. Ensure the source file exists and is tracked by git 31 | 2. Ensure the target directory exists within the git repository 32 | 3. Provide a meaningful description of why the file is being moved 33 | 34 | Args: 35 | source_path: The path to the file to move (can be relative to the project root or absolute) 36 | target_path: The destination path where the file should be moved to (can be relative to the project root or absolute) 37 | description: Short description of why the file is being moved 38 | chat_id: The unique ID to identify the chat session 39 | commit_hash: Optional Git commit hash for version tracking 40 | 41 | Returns: 42 | A string containing the result of the move operation 43 | """ 44 | # Set default values 45 | description = "" if description is None else description 46 | chat_id = "" if chat_id is None else chat_id 47 | 48 | # Use the directory from the path as our starting point for source 49 | source_path = normalize_file_path(source_path) 50 | source_dir_path = ( 51 | os.path.dirname(source_path) if os.path.dirname(source_path) else "." 52 | ) 53 | 54 | # Normalize target path as well 55 | target_path = normalize_file_path(target_path) 56 | 57 | # Validations for source file 58 | if not os.path.exists(source_path): 59 | raise FileNotFoundError(f"Source file does not exist: {source_path}") 60 | 61 | if not os.path.isfile(source_path): 62 | raise ValueError(f"Source path is not a file: {source_path}") 63 | 64 | # Get git repository root 65 | git_root = await get_repository_root(source_dir_path) 66 | # Ensure paths are absolute and resolve any symlinks 67 | source_path_resolved = os.path.realpath(source_path) 68 | git_root_resolved = os.path.realpath(git_root) 69 | target_path_resolved = ( 70 | os.path.realpath(target_path) 71 | if os.path.exists(os.path.dirname(target_path)) 72 | else target_path 73 | ) 74 | 75 | # Use pathlib to check if the source file is within the git repo 76 | # This handles path traversal correctly on all platforms 77 | try: 78 | # Convert to Path objects 79 | source_path_obj = pathlib.Path(source_path_resolved) 80 | git_root_obj = pathlib.Path(git_root_resolved) 81 | 82 | # Check if file is inside the git repo using Path.relative_to 83 | # This will raise ValueError if source_path is not inside git_root 84 | source_path_obj.relative_to(git_root_obj) 85 | except ValueError: 86 | msg = ( 87 | f"Source path {source_path} is not within the git repository at {git_root}" 88 | ) 89 | logging.error(msg) 90 | raise ValueError(msg) 91 | 92 | # Check if target directory exists and is within the git repo 93 | target_dir = os.path.dirname(target_path) 94 | if target_dir and not os.path.exists(target_dir): 95 | raise FileNotFoundError(f"Target directory does not exist: {target_dir}") 96 | 97 | try: 98 | # Convert to Path objects 99 | target_dir_obj = pathlib.Path( 100 | os.path.realpath(target_dir) if target_dir else git_root_resolved 101 | ) 102 | # Check if target directory is inside the git repo 103 | target_dir_obj.relative_to(git_root_obj) 104 | except ValueError: 105 | msg = f"Target directory {target_dir} is not within the git repository at {git_root}" 106 | logging.error(msg) 107 | raise ValueError(msg) 108 | 109 | # Get the relative paths using pathlib 110 | source_rel_path = os.path.relpath(source_path_resolved, git_root_resolved) 111 | target_rel_path = os.path.relpath( 112 | target_path_resolved 113 | if os.path.exists(os.path.dirname(target_path)) 114 | else os.path.join(git_root_resolved, os.path.basename(target_path)), 115 | git_root_resolved, 116 | ) 117 | 118 | logging.info(f"Using relative paths: {source_rel_path} -> {target_rel_path}") 119 | 120 | # Check if the source file is tracked by git from the git root 121 | await run_command( 122 | ["git", "ls-files", "--error-unmatch", source_rel_path], 123 | cwd=git_root_resolved, 124 | check=True, 125 | capture_output=True, 126 | text=True, 127 | ) 128 | 129 | # If we get here, the file is tracked by git, so we can move it 130 | await run_command( 131 | ["git", "mv", source_rel_path, target_rel_path], 132 | cwd=git_root_resolved, 133 | check=True, 134 | capture_output=True, 135 | text=True, 136 | ) 137 | 138 | # Commit the changes 139 | logging.info(f"Committing move of file: {source_rel_path} -> {target_rel_path}") 140 | success, commit_message = await commit_changes( 141 | git_root_resolved, 142 | f"Move {source_rel_path} -> {target_rel_path}: {description}", 143 | chat_id, 144 | commit_all=False, # No need for commit_all since git mv already stages the change 145 | ) 146 | 147 | result = "" 148 | if success: 149 | result = f"Successfully moved file from {source_rel_path} to {target_rel_path}." 150 | else: 151 | result = f"File was moved from {source_rel_path} to {target_rel_path} but failed to commit: {commit_message}" 152 | 153 | # Append commit hash 154 | result, _ = await append_commit_hash(result, git_root_resolved, commit_hash) 155 | return result 156 | -------------------------------------------------------------------------------- /codemcp/tools/read_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from typing import List 5 | 6 | from ..common import ( 7 | MAX_LINE_LENGTH, 8 | MAX_LINES_TO_READ, 9 | MAX_OUTPUT_SIZE, 10 | normalize_file_path, 11 | ) 12 | from ..git_query import find_git_root 13 | from ..mcp import mcp 14 | from ..rules import get_applicable_rules_content 15 | from .commit_utils import append_commit_hash 16 | 17 | __all__ = [ 18 | "read_file", 19 | ] 20 | 21 | 22 | @mcp.tool() 23 | async def read_file( 24 | path: str, 25 | offset: int | None = None, 26 | limit: int | None = None, 27 | chat_id: str | None = None, 28 | commit_hash: str | None = None, 29 | ) -> str: 30 | """Reads a file from the local filesystem. The path parameter must be an absolute path, not a relative path. 31 | By default, it reads up to 1000 lines starting from the beginning of the file. You can optionally specify a 32 | line offset and limit (especially handy for long files), but it's recommended to read the whole file by not 33 | providing these parameters. Any lines longer than 1000 characters will be truncated. For image files, the 34 | tool will display the image for you. 35 | 36 | Args: 37 | path: The absolute path to the file to read 38 | offset: The line number to start reading from (1-indexed) 39 | limit: The number of lines to read 40 | chat_id: The unique ID of the current chat session 41 | commit_hash: Optional Git commit hash for version tracking 42 | 43 | Returns: 44 | The file content as a string 45 | 46 | """ 47 | # Set default values 48 | chat_id = "" if chat_id is None else chat_id 49 | 50 | # Normalize the file path 51 | full_file_path = normalize_file_path(path) 52 | 53 | # Validate the file path 54 | if not os.path.exists(full_file_path): 55 | # Try to find a similar file (stub - would need implementation) 56 | raise FileNotFoundError(f"File does not exist: {path}") 57 | 58 | if os.path.isdir(full_file_path): 59 | raise IsADirectoryError(f"Path is a directory, not a file: {path}") 60 | 61 | # Check file size before reading 62 | file_size = os.path.getsize(full_file_path) 63 | if file_size > MAX_OUTPUT_SIZE and not offset and not limit: 64 | raise ValueError( 65 | f"File content ({file_size // 1024}KB) exceeds maximum allowed size ({MAX_OUTPUT_SIZE // 1024}KB). Please use offset and limit parameters to read specific portions of the file." 66 | ) 67 | 68 | # Handle text files - use async file operations with anyio 69 | from ..async_file_utils import async_readlines 70 | 71 | all_lines = await async_readlines( 72 | full_file_path, encoding="utf-8", errors="replace" 73 | ) 74 | 75 | # Get total line count 76 | total_lines = len(all_lines) 77 | 78 | # Handle offset (convert from 1-indexed to 0-indexed) 79 | line_offset = 0 if offset is None else (offset - 1 if offset > 0 else 0) 80 | 81 | # Apply offset and limit 82 | if line_offset >= total_lines: 83 | raise IndexError( 84 | f"Offset {offset} is beyond the end of the file (total lines: {total_lines})" 85 | ) 86 | 87 | max_lines = MAX_LINES_TO_READ if limit is None else limit 88 | selected_lines = all_lines[line_offset : line_offset + max_lines] 89 | 90 | # Process lines (truncate long lines) 91 | processed_lines: List[str] = [] 92 | for line in selected_lines: 93 | if len(line) > MAX_LINE_LENGTH: 94 | processed_lines.append( 95 | line[:MAX_LINE_LENGTH] + "... (line truncated)", 96 | ) 97 | else: 98 | processed_lines.append(line) 99 | 100 | # Add line numbers (1-indexed) 101 | numbered_lines: List[str] = [] 102 | for i, line in enumerate(processed_lines): 103 | line_number = line_offset + i + 1 # 1-indexed line number 104 | numbered_lines.append(f"{line_number:6}\t{line.rstrip()}") 105 | 106 | content = "\n".join(numbered_lines) 107 | 108 | # Add a message if we truncated the file 109 | if line_offset + len(processed_lines) < total_lines: 110 | content += f"\n... (file truncated, showing {len(processed_lines)} of {total_lines} lines)" 111 | 112 | # Apply relevant cursor rules 113 | # Find git repository root 114 | repo_root = find_git_root(os.path.dirname(full_file_path)) 115 | 116 | if repo_root: 117 | # Add applicable rules content 118 | content += get_applicable_rules_content(repo_root, full_file_path) 119 | 120 | # Append commit hash 121 | result, _ = await append_commit_hash(content, full_file_path, commit_hash) 122 | return result 123 | -------------------------------------------------------------------------------- /codemcp/tools/rm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import os 5 | import pathlib 6 | from typing import Optional 7 | 8 | from ..access import check_edit_permission 9 | from ..common import normalize_file_path 10 | from ..git import commit_changes, get_repository_root, is_git_repository 11 | from ..mcp import mcp 12 | from ..shell import run_command 13 | from .commit_utils import append_commit_hash 14 | 15 | __all__ = [ 16 | "rm", 17 | ] 18 | 19 | 20 | @mcp.tool() 21 | async def rm( 22 | path: str, description: str, chat_id: str, commit_hash: Optional[str] = None 23 | ) -> str: 24 | """Removes a file using git rm and commits the change. 25 | Provide a short description of why the file is being removed. 26 | 27 | Before using this tool: 28 | 1. Ensure the file exists and is tracked by git 29 | 2. Provide a meaningful description of why the file is being removed 30 | 31 | Args: 32 | path: The path to the file to remove (can be relative to the project root or absolute) 33 | description: Short description of why the file is being removed 34 | chat_id: The unique ID to identify the chat session 35 | commit_hash: Optional Git commit hash for version tracking 36 | 37 | Returns: 38 | A success message 39 | 40 | """ 41 | # Normalize the file path 42 | full_path = normalize_file_path(path) 43 | 44 | # Validate the file path 45 | if not os.path.exists(full_path): 46 | raise FileNotFoundError(f"File does not exist: {path}") 47 | 48 | # Safety check: Verify the file is within a git repository with codemcp.toml 49 | if not await is_git_repository(os.path.dirname(full_path)): 50 | raise ValueError(f"File is not in a Git repository: {path}") 51 | 52 | # Check edit permission (which verifies codemcp.toml exists) 53 | is_permitted, permission_message = await check_edit_permission(full_path) 54 | if not is_permitted: 55 | raise ValueError(permission_message) 56 | 57 | # Determine if it's a file or directory 58 | os.path.isdir(full_path) 59 | 60 | # Get git repository root 61 | git_root = await get_repository_root(os.path.dirname(full_path)) 62 | # Ensure paths are absolute and resolve any symlinks 63 | full_path_resolved = os.path.realpath(full_path) 64 | git_root_resolved = os.path.realpath(git_root) 65 | 66 | # Use pathlib to check if the file is within the git repo 67 | # This handles path traversal correctly on all platforms 68 | try: 69 | # Convert to Path objects 70 | full_path_obj = pathlib.Path(full_path_resolved) 71 | git_root_obj = pathlib.Path(git_root_resolved) 72 | 73 | # Check if file is inside the git repo using Path.relative_to 74 | # This will raise ValueError if full_path is not inside git_root 75 | full_path_obj.relative_to(git_root_obj) 76 | except ValueError: 77 | msg = f"Path {full_path} is not within the git repository at {git_root}" 78 | logging.error(msg) 79 | raise ValueError(msg) 80 | 81 | # Get the relative path using pathlib 82 | rel_path = os.path.relpath(full_path_resolved, git_root_resolved) 83 | logging.info(f"Using relative path: {rel_path}") 84 | 85 | # Check if the file is tracked by git from the git root 86 | await run_command( 87 | ["git", "ls-files", "--error-unmatch", rel_path], 88 | cwd=git_root_resolved, 89 | check=True, 90 | capture_output=True, 91 | text=True, 92 | ) 93 | 94 | # If we get here, the file is tracked by git, so we can remove it 95 | await run_command( 96 | ["git", "rm", rel_path], 97 | cwd=git_root_resolved, 98 | check=True, 99 | capture_output=True, 100 | text=True, 101 | ) 102 | 103 | # Commit the changes 104 | logging.info(f"Committing removal of file: {rel_path}") 105 | success, commit_message = await commit_changes( 106 | git_root_resolved, 107 | f"Remove {rel_path}: {description}", 108 | chat_id, 109 | commit_all=False, # No need for commit_all since git rm already stages the change 110 | ) 111 | 112 | result = "" 113 | if success: 114 | result = f"Successfully removed file {rel_path}." 115 | else: 116 | result = f"File {rel_path} was removed but failed to commit: {commit_message}" 117 | 118 | # Append commit hash 119 | result, _ = await append_commit_hash(result, git_root_resolved, commit_hash) 120 | return result 121 | -------------------------------------------------------------------------------- /codemcp/tools/run_command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import shlex 4 | from typing import Optional 5 | 6 | from ..code_command import get_command_from_config, run_code_command 7 | from ..common import normalize_file_path 8 | from ..mcp import mcp 9 | from .commit_utils import append_commit_hash 10 | 11 | __all__ = [ 12 | "run_command", 13 | ] 14 | 15 | 16 | @mcp.tool() 17 | async def run_command( 18 | project_dir: Optional[str] = None, 19 | command: str = "", 20 | arguments: Optional[str | list[str]] = None, 21 | chat_id: Optional[str] = None, 22 | commit_hash: Optional[str] = None, 23 | path: Optional[str] = None, 24 | ) -> str: 25 | """Run a command that is configured in codemcp.toml. 26 | 27 | Args: 28 | project_dir: The directory path containing the codemcp.toml file 29 | command: The type of command to run (e.g., "format", "lint", "test") 30 | arguments: Optional arguments to pass to the command. Can be a string or a list. 31 | If a string, it will be parsed into a list of arguments using shell-style 32 | tokenization (spaces separate arguments, quotes can be used for arguments 33 | containing spaces, etc.). If a list, it will be used directly. 34 | chat_id: The unique ID of the current chat session 35 | commit_hash: Optional Git commit hash for version tracking 36 | path: Alias for project_dir parameter (for backward compatibility) 37 | 38 | Returns: 39 | A string containing the result of the command operation 40 | """ 41 | # Use path as an alias for project_dir if project_dir is not provided 42 | effective_project_dir = project_dir if project_dir is not None else path 43 | if effective_project_dir is None: 44 | raise ValueError("Either project_dir or path must be provided") 45 | 46 | # Set default values 47 | chat_id = "" if chat_id is None else chat_id 48 | 49 | # Normalize the project directory path 50 | effective_project_dir = normalize_file_path(effective_project_dir) 51 | 52 | # Ensure arguments is a string for run_command 53 | args_str = ( 54 | arguments 55 | if isinstance(arguments, str) or arguments is None 56 | else " ".join(arguments) 57 | ) 58 | 59 | command_list = get_command_from_config(effective_project_dir, command) 60 | 61 | # If arguments are provided, extend the command with them 62 | if args_str and command_list: 63 | command_list = command_list.copy() 64 | parsed_args = shlex.split(args_str) 65 | command_list.extend(parsed_args) 66 | 67 | # Don't pass None to run_code_command 68 | actual_command = command_list if command_list is not None else [] 69 | 70 | result = await run_code_command( 71 | effective_project_dir, 72 | command, 73 | actual_command, 74 | f"Auto-commit {command} changes", 75 | chat_id, 76 | ) 77 | 78 | # Append commit hash 79 | result, _ = await append_commit_hash(result, effective_project_dir, commit_hash) 80 | return result 81 | -------------------------------------------------------------------------------- /codemcp/tools/think.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | 5 | from ..mcp import mcp 6 | 7 | __all__ = [ 8 | "think", 9 | ] 10 | 11 | 12 | @mcp.tool() 13 | async def think( 14 | thought: str, chat_id: str | None = None, commit_hash: str | None = None 15 | ) -> str: 16 | """Use the tool to think about something. It will not obtain new information or change the database, 17 | but just append the thought to the log. Use it when complex reasoning or some cache memory is needed. 18 | 19 | Args: 20 | thought: The thought to log 21 | chat_id: The unique ID of the current chat session 22 | commit_hash: Optional Git commit hash for version tracking 23 | 24 | Returns: 25 | A confirmation message that the thought was logged 26 | """ 27 | # Set default values 28 | chat_id = "" if chat_id is None else chat_id 29 | 30 | # Log the thought but don't actually do anything with it 31 | logging.info(f"[{chat_id}] Thought: {thought}") 32 | 33 | # Return a simple confirmation message 34 | return f"Thought logged: {thought}" 35 | -------------------------------------------------------------------------------- /codemcp/tools/write_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import logging 5 | import os 6 | 7 | from ..code_command import run_formatter_without_commit 8 | from ..common import normalize_file_path 9 | from ..file_utils import ( 10 | check_file_path_and_permissions, 11 | check_git_tracking_for_existing_file, 12 | write_text_content, 13 | ) 14 | from ..git import commit_changes 15 | from ..line_endings import detect_line_endings, detect_repo_line_endings 16 | from ..mcp import mcp 17 | from .commit_utils import append_commit_hash 18 | 19 | __all__ = [ 20 | "write_file", 21 | ] 22 | 23 | 24 | @mcp.tool() 25 | async def write_file( 26 | path: str, 27 | content: str | dict | list | None = None, 28 | description: str | None = None, 29 | chat_id: str | None = None, 30 | commit_hash: str | None = None, 31 | ) -> str: 32 | """Write a file to the local filesystem. Overwrites the existing file if there is one. 33 | Provide a short description of the change. 34 | 35 | Before using this tool: 36 | 37 | 1. Use the ReadFile tool to understand the file's contents and context 38 | 39 | 2. Directory Verification (only applicable when creating new files): 40 | - Use the LS tool to verify the parent directory exists and is the correct location 41 | 42 | Args: 43 | path: The absolute path to the file to write 44 | content: The content to write to the file. Can be a string, dict, or list (will be converted to JSON) 45 | description: Short description of the change 46 | chat_id: The unique ID of the current chat session 47 | commit_hash: Optional Git commit hash for version tracking 48 | 49 | Returns: 50 | A success message 51 | 52 | Note: 53 | This function allows creating new files that don't exist yet. 54 | For existing files, it will reject attempts to write to files that are not tracked by git. 55 | Files must be tracked in the git repository before they can be modified. 56 | 57 | """ 58 | # Set default values 59 | description = "" if description is None else description 60 | chat_id = "" if chat_id is None else chat_id 61 | 62 | # Normalize the file path 63 | path = normalize_file_path(path) 64 | 65 | # Normalize content - if content is not a string, serialize it to a string using json.dumps 66 | if content is not None and not isinstance(content, str): 67 | content_str = json.dumps(content) 68 | else: 69 | content_str = content or "" 70 | 71 | # Normalize newlines 72 | content_str = ( 73 | content_str.replace("\r\n", "\n") 74 | if isinstance(content_str, str) 75 | else content_str 76 | ) 77 | 78 | # Validate file path and permissions 79 | is_valid, error_message = await check_file_path_and_permissions(path) 80 | if not is_valid: 81 | raise ValueError(error_message) 82 | 83 | # Check git tracking for existing files 84 | is_tracked, track_error = await check_git_tracking_for_existing_file(path, chat_id) 85 | if not is_tracked: 86 | raise ValueError(track_error) 87 | 88 | # Determine line endings 89 | old_file_exists = os.path.exists(path) 90 | 91 | if old_file_exists: 92 | line_endings = await detect_line_endings(path) 93 | else: 94 | line_endings = detect_repo_line_endings(os.path.dirname(path)) 95 | # Ensure directory exists for new files 96 | directory = os.path.dirname(path) 97 | os.makedirs(directory, exist_ok=True) 98 | 99 | # Write the content with UTF-8 encoding and proper line endings 100 | await write_text_content(path, content_str, "utf-8", line_endings) 101 | 102 | # Try to run the formatter on the file 103 | format_message = "" 104 | formatter_success, formatter_output = await run_formatter_without_commit(path) 105 | if formatter_success: 106 | logging.info(f"Auto-formatted {path}") 107 | if formatter_output.strip(): 108 | format_message = f"\nAuto-formatted the file" 109 | else: 110 | # Only log warning if there was actually a format command configured but it failed 111 | if not "No format command configured" in formatter_output: 112 | logging.warning(f"Failed to auto-format {path}: {formatter_output}") 113 | 114 | # Commit the changes 115 | git_message = "" 116 | success, message = await commit_changes(path, description, chat_id) 117 | if success: 118 | git_message = f"\nChanges committed to git: {description}" 119 | else: 120 | git_message = f"\nFailed to commit changes to git: {message}" 121 | 122 | result = f"Successfully wrote to {path}{format_message}{git_message}" 123 | 124 | # Append commit hash 125 | result, _ = await append_commit_hash(result, path, commit_hash) 126 | return result 127 | -------------------------------------------------------------------------------- /e2e/test_chmod.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for the Chmod subtool.""" 4 | 5 | import os 6 | import stat 7 | import unittest 8 | 9 | from codemcp.testing import MCPEndToEndTestCase 10 | 11 | 12 | class ChmodTest(MCPEndToEndTestCase): 13 | """Test the Chmod subtool.""" 14 | 15 | async def test_chmod_basic_functionality(self): 16 | """Test basic functionality of the chmod tool.""" 17 | # Create a test script file 18 | test_file_path = os.path.join(self.temp_dir.name, "test_script.py") 19 | with open(test_file_path, "w") as f: 20 | f.write("#!/usr/bin/env python3\nprint('Hello, world!')\n") 21 | 22 | # Initial state - file should not be executable 23 | mode = os.stat(test_file_path).st_mode 24 | is_executable = bool(mode & stat.S_IXUSR) 25 | self.assertFalse(is_executable, "File should not be executable initially") 26 | 27 | async with self.create_client_session() as session: 28 | # Get a valid chat_id 29 | chat_id = await self.get_chat_id(session) 30 | 31 | # Make the file executable 32 | result_text = await self.call_tool_assert_success( 33 | session, 34 | "codemcp", 35 | { 36 | "subtool": "Chmod", 37 | "path": test_file_path, 38 | "mode": "a+x", 39 | "chat_id": chat_id, 40 | }, 41 | ) 42 | 43 | # Verify success message 44 | self.assertIn("Made file", result_text) 45 | 46 | # Verify file is now executable 47 | mode = os.stat(test_file_path).st_mode 48 | is_executable = bool(mode & stat.S_IXUSR) 49 | self.assertTrue(is_executable, "File should be executable after chmod a+x") 50 | 51 | # Try making it executable again (should be a no-op) 52 | result_text = await self.call_tool_assert_success( 53 | session, 54 | "codemcp", 55 | { 56 | "subtool": "Chmod", 57 | "path": test_file_path, 58 | "mode": "a+x", 59 | "chat_id": chat_id, 60 | }, 61 | ) 62 | 63 | # Verify no-op message 64 | self.assertIn("already executable", result_text) 65 | 66 | # Remove executable permission 67 | result_text = await self.call_tool_assert_success( 68 | session, 69 | "codemcp", 70 | { 71 | "subtool": "Chmod", 72 | "path": test_file_path, 73 | "mode": "a-x", 74 | "chat_id": chat_id, 75 | }, 76 | ) 77 | 78 | # Verify success message 79 | self.assertIn("Removed executable permission", result_text) 80 | 81 | # Verify file is no longer executable 82 | mode = os.stat(test_file_path).st_mode 83 | is_executable = bool(mode & stat.S_IXUSR) 84 | self.assertFalse( 85 | is_executable, "File should not be executable after chmod a-x" 86 | ) 87 | 88 | # Try removing executable permission again (should be a no-op) 89 | result_text = await self.call_tool_assert_success( 90 | session, 91 | "codemcp", 92 | { 93 | "subtool": "Chmod", 94 | "path": test_file_path, 95 | "mode": "a-x", 96 | "chat_id": chat_id, 97 | }, 98 | ) 99 | 100 | # Verify no-op message 101 | self.assertIn("already non-executable", result_text) 102 | 103 | async def test_chmod_error_handling(self): 104 | """Test error handling in the chmod tool.""" 105 | async with self.create_client_session() as session: 106 | # Get a valid chat_id 107 | chat_id = await self.get_chat_id(session) 108 | 109 | # Test with non-existent file 110 | non_existent_file = os.path.join(self.temp_dir.name, "nonexistent.py") 111 | error_text = await self.call_tool_assert_error( 112 | session, 113 | "codemcp", 114 | { 115 | "subtool": "Chmod", 116 | "path": non_existent_file, 117 | "mode": "a+x", 118 | "chat_id": chat_id, 119 | }, 120 | ) 121 | self.assertIn("not exist", error_text.lower()) 122 | 123 | # Test with invalid mode 124 | test_file = os.path.join(self.temp_dir.name, "test_file.py") 125 | with open(test_file, "w") as f: 126 | f.write("# Test file") 127 | 128 | error_text = await self.call_tool_assert_error( 129 | session, 130 | "codemcp", 131 | { 132 | "subtool": "Chmod", 133 | "path": test_file, 134 | "mode": "invalid", 135 | "chat_id": chat_id, 136 | }, 137 | ) 138 | # Check for either error message (from main.py or chmod.py) 139 | self.assertTrue( 140 | "unsupported chmod mode" in error_text.lower() 141 | or "mode must be either 'a+x' or 'a-x'" in error_text.lower(), 142 | f"Expected an error about invalid mode, but got: {error_text}", 143 | ) 144 | 145 | 146 | if __name__ == "__main__": 147 | unittest.main() 148 | -------------------------------------------------------------------------------- /e2e/test_directory_creation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """End-to-end tests for directory creation in WriteFile and EditFile.""" 4 | 5 | import os 6 | import unittest 7 | 8 | from codemcp.testing import MCPEndToEndTestCase 9 | 10 | 11 | class DirectoryCreationTest(MCPEndToEndTestCase): 12 | """Test recursive directory creation in WriteFile and EditFile subtools.""" 13 | 14 | async def test_write_file_nested_directories(self): 15 | """Test WriteFile can create nested directories.""" 16 | # Create a path with multiple nested directories that don't exist 17 | nested_path = os.path.join( 18 | self.temp_dir.name, "test_nest", "level1", "level2", "level3" 19 | ) 20 | test_file_path = os.path.join(nested_path, "test_file.txt") 21 | 22 | # Verify directory doesn't exist 23 | self.assertFalse( 24 | os.path.exists(nested_path), "Nested directory should not exist initially" 25 | ) 26 | 27 | content = "Content in a deeply nested directory" 28 | 29 | async with self.create_client_session() as session: 30 | # Get a valid chat_id 31 | chat_id = await self.get_chat_id(session) 32 | 33 | # Call the WriteFile tool with chat_id 34 | result_text = await self.call_tool_assert_success( 35 | session, 36 | "codemcp", 37 | { 38 | "subtool": "WriteFile", 39 | "path": test_file_path, 40 | "content": content, 41 | "description": "Create file in nested directories", 42 | "chat_id": chat_id, 43 | }, 44 | ) 45 | 46 | # Check for success message 47 | self.assertIn("Successfully wrote to", result_text) 48 | 49 | # Verify the directories were created as expected 50 | self.assertTrue( 51 | os.path.exists(nested_path), "Nested directories were not created" 52 | ) 53 | 54 | # Verify the file was created with the correct content 55 | self.assertTrue(os.path.exists(test_file_path), "File was not created") 56 | with open(test_file_path) as f: 57 | file_content = f.read() 58 | self.assertEqual(file_content, content + "\n") 59 | 60 | async def test_edit_file_nested_directories(self): 61 | """Test EditFile can create nested directories when old_string is empty.""" 62 | # Create a path with multiple nested directories that don't exist 63 | nested_path = os.path.join(self.temp_dir.name, "edit_nest", "level1", "level2") 64 | test_file_path = os.path.join(nested_path, "new_file.txt") 65 | 66 | # Verify directory doesn't exist 67 | self.assertFalse( 68 | os.path.exists(nested_path), "Nested directory should not exist initially" 69 | ) 70 | 71 | content = "Content created in nested directories by EditFile" 72 | 73 | async with self.create_client_session() as session: 74 | # Get a valid chat_id 75 | chat_id = await self.get_chat_id(session) 76 | 77 | # Call the EditFile tool with empty old_string and chat_id 78 | result_text = await self.call_tool_assert_success( 79 | session, 80 | "codemcp", 81 | { 82 | "subtool": "EditFile", 83 | "path": test_file_path, 84 | "old_string": "", 85 | "new_string": content, 86 | "description": "Create file in nested directories with EditFile", 87 | "chat_id": chat_id, 88 | }, 89 | ) 90 | 91 | # Check for success message 92 | self.assertIn("Successfully created", result_text) 93 | 94 | # Verify the directories were created as expected 95 | self.assertTrue( 96 | os.path.exists(nested_path), "Nested directories were not created" 97 | ) 98 | 99 | # Verify the file was created with the correct content 100 | self.assertTrue(os.path.exists(test_file_path), "File was not created") 101 | with open(test_file_path) as f: 102 | file_content = f.read() 103 | self.assertEqual(file_content, content + "\n") 104 | 105 | 106 | if __name__ == "__main__": 107 | unittest.main() 108 | -------------------------------------------------------------------------------- /e2e/test_format.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for the RunCommand with format.""" 4 | 5 | import os 6 | import unittest 7 | 8 | from codemcp.testing import MCPEndToEndTestCase 9 | 10 | 11 | class RunCommandFormatTest(MCPEndToEndTestCase): 12 | """Test the RunCommand with format subtool.""" 13 | 14 | async def test_format_with_run_subtool(self): 15 | """Test that RunCommand with format commits changes made by formatting.""" 16 | # Create a file that is not properly formatted (needs formatting) 17 | # We'll use Python's ruff formatter conventions 18 | unformatted_file_path = os.path.join(self.temp_dir.name, "unformatted.py") 19 | unformatted_content = """def badly_formatted_function ( arg1,arg2 ): 20 | x=1+2 21 | y= [1,2, 22 | 3, 4] 23 | return x+y 24 | """ 25 | 26 | with open(unformatted_file_path, "w") as f: 27 | f.write(unformatted_content) 28 | 29 | # Add it to git 30 | await self.git_run(["add", unformatted_file_path]) 31 | 32 | # Commit it 33 | await self.git_run(["commit", "-m", "Add unformatted file"]) 34 | 35 | # Create a simple format script that simulates ruff formatting 36 | format_script_path = os.path.join(self.temp_dir.name, "run_format.sh") 37 | with open(format_script_path, "w") as f: 38 | f.write("""#!/bin/bash 39 | # Simple mock formatter that just fixes the format of the unformatted.py file 40 | if [ -f unformatted.py ]; then 41 | # Replace with properly formatted version 42 | cat > unformatted.py << 'EOF' 43 | def badly_formatted_function(arg1, arg2): 44 | x = 1 + 2 45 | y = [1, 2, 3, 4] 46 | return x + y 47 | EOF 48 | echo "Formatted unformatted.py" 49 | fi 50 | """) 51 | 52 | # Make it executable 53 | os.chmod(format_script_path, 0o755) 54 | 55 | # Create a codemcp.toml file with format subtool 56 | codemcp_toml_path = os.path.join(self.temp_dir.name, "codemcp.toml") 57 | with open(codemcp_toml_path, "w") as f: 58 | f.write("""[project] 59 | name = "test-project" 60 | 61 | [commands] 62 | format = ["./run_format.sh"] 63 | """) 64 | 65 | # Record the current commit hash before formatting 66 | commit_before = await self.git_run( 67 | ["rev-parse", "HEAD"], capture_output=True, text=True 68 | ) 69 | 70 | async with self.create_client_session() as session: 71 | # First initialize project to get chat_id 72 | init_result_text = await self.call_tool_assert_success( 73 | session, 74 | "codemcp", 75 | { 76 | "subtool": "InitProject", 77 | "path": self.temp_dir.name, 78 | "user_prompt": "Test initialization for format test", 79 | "subject_line": "test: initialize for format test", 80 | "reuse_head_chat_id": False, 81 | }, 82 | ) 83 | 84 | # Extract chat_id from the init result 85 | chat_id = self.extract_chat_id_from_text(init_result_text) 86 | 87 | # Call the RunCommand tool with format command and chat_id 88 | result_text = await self.call_tool_assert_success( 89 | session, 90 | "codemcp", 91 | { 92 | "subtool": "RunCommand", 93 | "path": self.temp_dir.name, 94 | "command": "format", 95 | "chat_id": chat_id, 96 | }, 97 | ) 98 | 99 | # Verify the success message 100 | self.assertIn("Code format successful", result_text) 101 | 102 | # Verify the file was formatted correctly 103 | with open(unformatted_file_path) as f: 104 | file_content = f.read() 105 | 106 | expected_content = """def badly_formatted_function(arg1, arg2): 107 | x = 1 + 2 108 | y = [1, 2, 3, 4] 109 | return x + y 110 | """ 111 | self.assertEqual(file_content, expected_content) 112 | 113 | # Verify git state shows clean working tree after commit 114 | status = await self.git_run(["status"], capture_output=True, text=True) 115 | 116 | # Verify that the working tree is clean (changes were committed) 117 | self.assertExpectedInline( 118 | status, 119 | """\ 120 | On branch main 121 | nothing to commit, working tree clean""", 122 | ) 123 | 124 | # Verify that a new commit was created 125 | commit_after = await self.git_run( 126 | ["rev-parse", "HEAD"], capture_output=True, text=True 127 | ) 128 | 129 | # The commit hash should be different 130 | self.assertNotEqual(commit_before, commit_after) 131 | 132 | # Verify the commit message indicates it was a formatting change 133 | commit_msg = await self.git_run( 134 | ["log", "-1", "--pretty=%B"], capture_output=True, text=True 135 | ) 136 | 137 | self.assertIn("Auto-commit format changes", commit_msg) 138 | 139 | 140 | if __name__ == "__main__": 141 | unittest.main() 142 | -------------------------------------------------------------------------------- /e2e/test_git_helper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for the git_run helper method.""" 4 | 5 | import os 6 | import subprocess 7 | import unittest 8 | 9 | from codemcp.testing import MCPEndToEndTestCase 10 | 11 | 12 | class GitHelperTest(MCPEndToEndTestCase): 13 | """Test the git_run helper method.""" 14 | 15 | async def test_git_add_and_commit(self): 16 | """Test basic git add and commit operations using the helper.""" 17 | # Create a test file 18 | test_file_path = os.path.join(self.temp_dir.name, "test_file.txt") 19 | with open(test_file_path, "w") as f: 20 | f.write("Test content") 21 | 22 | # Add the file using git_run helper 23 | await self.git_run(["add", "test_file.txt"]) 24 | 25 | # Commit the file using git_run helper 26 | await self.git_run(["commit", "-m", "Add test file"]) 27 | 28 | # Get the git log using git_run helper 29 | log_output = await self.git_run( 30 | ["log", "--oneline"], capture_output=True, text=True 31 | ) 32 | 33 | # Verify commit appears in log 34 | self.assertIn("Add test file", log_output) 35 | 36 | async def test_git_status(self): 37 | """Test git status command using the helper.""" 38 | # Create an untracked file 39 | test_file_path = os.path.join(self.temp_dir.name, "untracked.txt") 40 | with open(test_file_path, "w") as f: 41 | f.write("Untracked content") 42 | 43 | # Get git status using git_run helper 44 | status_output = await self.git_run( 45 | ["status", "--porcelain"], capture_output=True, text=True 46 | ) 47 | 48 | # Verify untracked file appears in status 49 | self.assertIn("?? untracked.txt", status_output) 50 | 51 | async def test_git_error_handling(self): 52 | """Test error handling in git_run helper.""" 53 | # Try to check out a non-existent branch 54 | with self.assertRaises(subprocess.CalledProcessError): 55 | await self.git_run(["checkout", "non-existent-branch"]) 56 | 57 | # Run the same command but without error checking 58 | result = await self.git_run( 59 | ["checkout", "non-existent-branch"], check=False, capture_output=True 60 | ) 61 | self.assertNotEqual(result.returncode, 0, "Command should have failed") 62 | 63 | async def test_git_commit_count(self): 64 | """Test getting commit count using the helper.""" 65 | # Create and commit a series of files 66 | for i in range(3): 67 | test_file = os.path.join(self.temp_dir.name, f"file{i}.txt") 68 | with open(test_file, "w") as f: 69 | f.write(f"Content {i}") 70 | 71 | await self.git_run(["add", f"file{i}.txt"]) 72 | await self.git_run(["commit", "-m", f"Add file {i}"]) 73 | 74 | # Get commit count using git_run helper 75 | log_output = await self.git_run( 76 | ["log", "--oneline"], capture_output=True, text=True 77 | ) 78 | # Count lines (+1 for initial setup commit) 79 | commit_count = len(log_output.split("\n")) 80 | 81 | # Should have 4 commits (3 new ones + initial repo setup) 82 | self.assertEqual(commit_count, 4, "Should have 4 commits in total") 83 | 84 | 85 | if __name__ == "__main__": 86 | unittest.main() 87 | -------------------------------------------------------------------------------- /e2e/test_git_tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from unittest import mock 5 | 6 | from codemcp.testing import MCPEndToEndTestCase 7 | from codemcp.tools.git_blame import git_blame 8 | from codemcp.tools.git_diff import git_diff 9 | from codemcp.tools.git_log import git_log 10 | from codemcp.tools.git_show import git_show 11 | 12 | 13 | class TestGitTools(MCPEndToEndTestCase): 14 | """Test the git tools functionality.""" 15 | 16 | async def asyncSetUp(self): 17 | # Use the parent class's asyncSetUp to set up test environment 18 | await super().asyncSetUp() 19 | 20 | # Create a sample file 21 | self.sample_file = os.path.join(self.temp_dir.name, "sample.txt") 22 | with open(self.sample_file, "w") as f: 23 | f.write("Sample content\nLine 2\nLine 3\n") 24 | 25 | # Add and commit the file (the base class already has git initialized) 26 | await self.git_run(["add", "sample.txt"]) 27 | await self.git_run(["commit", "-m", "Initial commit"]) 28 | 29 | # Modify the file and create another commit 30 | with open(self.sample_file, "a") as f: 31 | f.write("Line 4\nLine 5\n") 32 | 33 | await self.git_run(["add", "sample.txt"]) 34 | await self.git_run(["commit", "-m", "Second commit"]) 35 | 36 | async def test_git_log(self): 37 | """Test the git_log tool.""" 38 | # Test with no arguments 39 | result = await git_log(path=self.temp_dir.name) 40 | self.assertIn("Initial commit", result["output"]) 41 | self.assertIn("Second commit", result["output"]) 42 | 43 | # Test with arguments 44 | result = await git_log(arguments="--oneline -n 1", path=self.temp_dir.name) 45 | self.assertIn("Second commit", result["output"]) 46 | self.assertNotIn("Initial commit", result["output"]) 47 | 48 | async def test_git_diff(self): 49 | """Test the git_diff tool.""" 50 | # Create a change but don't commit it 51 | with open(self.sample_file, "a") as f: 52 | f.write("Uncommitted change\n") 53 | 54 | # Test with no arguments 55 | result = await git_diff(path=self.temp_dir.name) 56 | self.assertIn("Uncommitted change", result["output"]) 57 | 58 | # Test with arguments 59 | result = await git_diff(arguments="HEAD~1 HEAD", path=self.temp_dir.name) 60 | self.assertIn("Line 4", result["output"]) 61 | 62 | async def test_git_show(self): 63 | """Test the git_show tool.""" 64 | # Test with no arguments (should show the latest commit) 65 | result = await git_show(path=self.temp_dir.name) 66 | self.assertIn("Second commit", result["output"]) 67 | 68 | # Test with arguments 69 | result = await git_show(arguments="HEAD~1", path=self.temp_dir.name) 70 | self.assertIn("Initial commit", result["output"]) 71 | 72 | async def test_git_blame(self): 73 | """Test the git_blame tool.""" 74 | # Test with file argument 75 | result = await git_blame(arguments="sample.txt", path=self.temp_dir.name) 76 | self.assertIn( 77 | "A U Thor", result["output"] 78 | ) # MCPEndToEndTestCase sets this author 79 | self.assertIn("Line 2", result["output"]) 80 | 81 | # Test with line range 82 | result = await git_blame(arguments="-L 4,5 sample.txt", path=self.temp_dir.name) 83 | self.assertIn("Line 4", result["output"]) 84 | self.assertNotIn("Line 2", result["output"]) 85 | 86 | async def test_invalid_path(self): 87 | """Test that tools handle invalid paths.""" 88 | with mock.patch("codemcp.tools.git_log.is_git_repository", return_value=False): 89 | with self.assertRaises(ValueError): 90 | await git_log(path="/invalid/path") 91 | 92 | with mock.patch("codemcp.tools.git_diff.is_git_repository", return_value=False): 93 | with self.assertRaises(ValueError): 94 | await git_diff(path="/invalid/path") 95 | 96 | with mock.patch("codemcp.tools.git_show.is_git_repository", return_value=False): 97 | with self.assertRaises(ValueError): 98 | await git_show(path="/invalid/path") 99 | 100 | with mock.patch( 101 | "codemcp.tools.git_blame.is_git_repository", return_value=False 102 | ): 103 | with self.assertRaises(ValueError): 104 | await git_blame(path="/invalid/path") 105 | -------------------------------------------------------------------------------- /e2e/test_grep.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for the Grep subtool.""" 4 | 5 | import os 6 | import unittest 7 | 8 | from codemcp.testing import MCPEndToEndTestCase 9 | 10 | 11 | class GrepTest(MCPEndToEndTestCase): 12 | """Test the Grep subtool.""" 13 | 14 | async def asyncSetUp(self): 15 | """Set up the test environment with a git repository.""" 16 | await super().asyncSetUp() 17 | 18 | # Create test files with content for grep testing 19 | self.create_test_files() 20 | 21 | # Add our test files to git 22 | await self.git_run(["add", "."]) 23 | await self.git_run(["commit", "-m", "Add test files for grep"]) 24 | 25 | def create_test_files(self): 26 | """Create test files with content for grep testing.""" 27 | # Create a file with a specific pattern 28 | with open(os.path.join(self.temp_dir.name, "file1.js"), "w") as f: 29 | f.write( 30 | "function testFunction() {\n console.log('Test');\n return true;\n}" 31 | ) 32 | 33 | # Create another file with a different pattern 34 | with open(os.path.join(self.temp_dir.name, "file2.js"), "w") as f: 35 | f.write( 36 | "const anotherFunction = () => {\n console.error('Error');\n return false;\n}" 37 | ) 38 | 39 | # Create a Python file with a pattern 40 | with open(os.path.join(self.temp_dir.name, "script.py"), "w") as f: 41 | f.write("def test_function():\n print('Testing')\n return True\n") 42 | 43 | async def test_grep_directory(self): 44 | """Test the Grep subtool on a directory.""" 45 | async with self.create_client_session() as session: 46 | # Get a valid chat_id 47 | chat_id = await self.get_chat_id(session) 48 | 49 | # Call the Grep tool with directory path 50 | result_text = await self.call_tool_assert_success( 51 | session, 52 | "codemcp", 53 | { 54 | "subtool": "Grep", 55 | "path": self.temp_dir.name, 56 | "pattern": "console", 57 | "chat_id": chat_id, 58 | }, 59 | ) 60 | 61 | # Verify results 62 | self.assertIn("file1.js", result_text) 63 | self.assertIn("file2.js", result_text) 64 | self.assertNotIn( 65 | "script.py", result_text 66 | ) # Python file doesn't have "console" 67 | 68 | async def test_grep_specific_file(self): 69 | """Test the Grep subtool with a specific file path.""" 70 | async with self.create_client_session() as session: 71 | # Get a valid chat_id 72 | chat_id = await self.get_chat_id(session) 73 | 74 | # Path to a specific file 75 | file_path = os.path.join(self.temp_dir.name, "file1.js") 76 | 77 | # Call the Grep tool with file path 78 | result_text = await self.call_tool_assert_success( 79 | session, 80 | "codemcp", 81 | { 82 | "subtool": "Grep", 83 | "path": file_path, 84 | "pattern": "console", 85 | "chat_id": chat_id, 86 | }, 87 | ) 88 | 89 | # Verify results - should only find the specific file 90 | self.assertIn("file1.js", result_text) 91 | self.assertNotIn("file2.js", result_text) # Shouldn't grep other files 92 | self.assertIn("Found 1 file", result_text) # Should find exactly 1 file 93 | 94 | async def test_grep_with_include_filter(self): 95 | """Test the Grep subtool with an include filter.""" 96 | async with self.create_client_session() as session: 97 | # Get a valid chat_id 98 | chat_id = await self.get_chat_id(session) 99 | 100 | # Call the Grep tool with an include filter 101 | result_text = await self.call_tool_assert_success( 102 | session, 103 | "codemcp", 104 | { 105 | "subtool": "Grep", 106 | "path": self.temp_dir.name, 107 | "pattern": "function", 108 | "include": "*.py", 109 | "chat_id": chat_id, 110 | }, 111 | ) 112 | 113 | # Verify results - should only find Python files 114 | self.assertIn("script.py", result_text) 115 | self.assertNotIn("file1.js", result_text) 116 | self.assertNotIn("file2.js", result_text) 117 | 118 | 119 | if __name__ == "__main__": 120 | unittest.main() 121 | -------------------------------------------------------------------------------- /e2e/test_init_command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import re 4 | import subprocess 5 | import tempfile 6 | from pathlib import Path 7 | 8 | from codemcp.main import init_codemcp_project 9 | 10 | 11 | def test_init_command(): 12 | """Test the init command creates a codemcp.toml file and initializes a git repo.""" 13 | # Create a temporary directory for testing 14 | with tempfile.TemporaryDirectory() as temp_dir: 15 | # Run the init_codemcp_project function 16 | result = init_codemcp_project(temp_dir) 17 | 18 | # Check that the function reports success 19 | assert "Successfully initialized" in result 20 | 21 | # Check that codemcp.toml was created 22 | config_file = Path(temp_dir) / "codemcp.toml" 23 | assert config_file.exists() 24 | 25 | # Check that git repository was initialized 26 | git_dir = Path(temp_dir) / ".git" 27 | assert git_dir.is_dir() 28 | 29 | # Check that a commit was created 30 | result = subprocess.run( 31 | ["git", "log", "--oneline"], 32 | cwd=temp_dir, 33 | capture_output=True, 34 | text=True, 35 | check=True, 36 | ) 37 | assert "initialize codemcp project" in result.stdout 38 | 39 | 40 | def test_init_command_existing_repo(): 41 | """Test the init command works with an existing git repository.""" 42 | # Create a temporary directory for testing 43 | with tempfile.TemporaryDirectory() as temp_dir: 44 | # Initialize git repository first 45 | subprocess.run(["git", "init"], cwd=temp_dir, check=True) 46 | 47 | # Make a dummy commit to simulate existing repository 48 | dummy_file = Path(temp_dir) / "dummy.txt" 49 | dummy_file.write_text("test content") 50 | 51 | subprocess.run( 52 | ["git", "config", "user.name", "Test User"], cwd=temp_dir, check=True 53 | ) 54 | subprocess.run( 55 | ["git", "config", "user.email", "test@example.com"], 56 | cwd=temp_dir, 57 | check=True, 58 | ) 59 | subprocess.run(["git", "add", "dummy.txt"], cwd=temp_dir, check=True) 60 | subprocess.run( 61 | ["git", "commit", "-m", "Initial commit"], cwd=temp_dir, check=True 62 | ) 63 | 64 | # Run the init_codemcp_project function 65 | result = init_codemcp_project(temp_dir) 66 | 67 | # Check that the function reports success 68 | assert "Successfully initialized" in result 69 | 70 | # Check that codemcp.toml was created 71 | config_file = Path(temp_dir) / "codemcp.toml" 72 | assert config_file.exists() 73 | 74 | 75 | def test_init_command_with_python(): 76 | """Test the init command with Python option creates Python project structure.""" 77 | # Create a temporary directory for testing with a specific name 78 | with tempfile.TemporaryDirectory(prefix="test-project-") as temp_dir: 79 | temp_path = Path(temp_dir) 80 | project_name = temp_path.name # Get the directory name 81 | package_name = re.sub(r"[^a-z0-9_]", "_", project_name.lower()) 82 | 83 | # Run the init_codemcp_project function with Python option 84 | # this used to silently fail to add files to git because it was adding files that were git-ignored (.ruff/...) 85 | # the error would manifest only at the bottom when git log --oneline fails because there were no commits 86 | result = init_codemcp_project(temp_dir, python=True) 87 | 88 | # Check that the function reports success with Python message 89 | assert "Successfully initialized" in result 90 | assert "with Python project structure" in result 91 | 92 | # Check that standard files were created 93 | config_file = temp_path / "codemcp.toml" 94 | assert config_file.exists() 95 | 96 | # Check that git repository was initialized 97 | git_dir = temp_path / ".git" 98 | assert git_dir.is_dir() 99 | 100 | # Check that Python-specific files were created 101 | pyproject_file = temp_path / "pyproject.toml" 102 | assert pyproject_file.exists() 103 | 104 | # Check if the project name was correctly applied in pyproject.toml 105 | with open(pyproject_file, "r") as f: 106 | content = f.read() 107 | assert project_name in content 108 | 109 | readme_file = temp_path / "README.md" 110 | assert readme_file.exists() 111 | 112 | # Check package structure with correct name derived from directory 113 | package_dir = temp_path / package_name 114 | assert package_dir.is_dir() 115 | 116 | init_file = package_dir / "__init__.py" 117 | assert init_file.exists() 118 | 119 | # Verify __init__.py exists, but don't check its content 120 | # since it's intentionally empty 121 | 122 | # Check that the commit message includes Python template reference 123 | result = subprocess.run( 124 | ["git", "log", "--oneline"], 125 | cwd=temp_dir, 126 | capture_output=True, 127 | text=True, 128 | check=True, 129 | ) 130 | assert "with Python template" in result.stdout 131 | -------------------------------------------------------------------------------- /e2e/test_init_project_no_commits.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """End-to-end test for InitProject subtool in a git repo with no initial commit.""" 4 | 5 | import os 6 | import subprocess 7 | import unittest 8 | 9 | from codemcp.git import get_ref_commit_chat_id 10 | from codemcp.testing import MCPEndToEndTestCase 11 | 12 | 13 | class InitProjectNoCommitsTest(MCPEndToEndTestCase): 14 | """Test the InitProject subtool functionality in a git repo with no initial commit.""" 15 | 16 | async def setup_repository(self): 17 | """Override setup to initialize a git repo without creating an initial commit. 18 | 19 | This test specifically needs a git repository with: 20 | - Git initialized, but no commits 21 | - An unversioned codemcp.toml file 22 | """ 23 | # Create a simple codemcp.toml file 24 | toml_path = os.path.join(self.temp_dir.name, "codemcp.toml") 25 | with open(toml_path, "w") as f: 26 | f.write(""" 27 | project_prompt = "Test project with no initial commit" 28 | [commands] 29 | test = ["./run_test.sh"] 30 | """) 31 | 32 | # Initialize git but don't make any commits 33 | await self.git_run(["init"]) 34 | await self.git_run(["config", "user.email", "test@example.com"]) 35 | await self.git_run(["config", "user.name", "Test User"]) 36 | 37 | async def test_init_project_no_commits(self): 38 | """Test InitProject in a git repo with no initial commit and unversioned codemcp.toml.""" 39 | # Verify that we truly have no commits 40 | try: 41 | await self.git_run( 42 | ["rev-parse", "--verify", "HEAD"], 43 | capture_output=True, 44 | text=True, 45 | check=True, # This should fail if HEAD doesn't exist 46 | ) 47 | self.fail("Expected no HEAD commit to exist, but HEAD exists") 48 | except subprocess.CalledProcessError: 49 | # This is expected - HEAD shouldn't exist 50 | pass 51 | 52 | # At this point: 53 | # - We have a git repo 54 | # - We have no commits in the repo 55 | # - We have an unversioned codemcp.toml file 56 | 57 | async with self.create_client_session() as session: 58 | # Call InitProject and expect it to succeed 59 | result_text = await self.call_tool_assert_success( 60 | session, 61 | "codemcp", 62 | { 63 | "subtool": "InitProject", 64 | "path": self.temp_dir.name, 65 | "user_prompt": "Test initialization in empty repo", 66 | "subject_line": "feat: initialize project in empty repo", 67 | "reuse_head_chat_id": False, 68 | }, 69 | ) 70 | 71 | # Verify the result contains expected system prompt elements 72 | self.assertIn("You are an AI assistant", result_text) 73 | self.assertIn("Test project with no initial commit", result_text) 74 | 75 | # Extract the chat ID from the result 76 | chat_id = self.extract_chat_id_from_text(result_text) 77 | self.assertIsNotNone(chat_id, "Chat ID should be present in result") 78 | 79 | # Verify the reference was created with the chat ID 80 | ref_name = f"refs/codemcp/{chat_id}" 81 | ref_chat_id = await get_ref_commit_chat_id(self.temp_dir.name, ref_name) 82 | self.assertEqual( 83 | chat_id, 84 | ref_chat_id, 85 | f"Chat ID {chat_id} should be in reference {ref_name}", 86 | ) 87 | 88 | # Verify HEAD still doesn't exist (we should only create a reference, not advance HEAD) 89 | try: 90 | await self.git_run( 91 | ["rev-parse", "--verify", "HEAD"], 92 | capture_output=True, 93 | text=True, 94 | check=True, # This should fail if HEAD doesn't exist 95 | ) 96 | self.fail( 97 | "HEAD should still not exist after InitProject, but it exists" 98 | ) 99 | except subprocess.CalledProcessError: 100 | # This is expected - HEAD still shouldn't exist 101 | pass 102 | 103 | 104 | if __name__ == "__main__": 105 | unittest.main() 106 | -------------------------------------------------------------------------------- /e2e/test_json_content_serialization.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for serializing non-string content using json.dumps.""" 4 | 5 | import json 6 | import os 7 | import unittest 8 | 9 | from codemcp.testing import MCPEndToEndTestCase 10 | 11 | 12 | class JsonContentSerializationTest(MCPEndToEndTestCase): 13 | """Test the serialization of non-string content for WriteFile subtool.""" 14 | 15 | # Set in_process = False to ensure argument validation logic is triggered 16 | in_process = False 17 | 18 | async def test_json_serialization(self): 19 | """Test that non-string content is properly serialized to JSON.""" 20 | test_file_path = os.path.join(self.temp_dir.name, "json_serialized.txt") 21 | 22 | # Dictionary to be serialized 23 | content_dict = { 24 | "name": "Test Object", 25 | "values": [1, 2, 3], 26 | "nested": {"key": "value", "boolean": True}, 27 | } 28 | 29 | # Expected serialized string for verification 30 | expected_content = json.dumps(content_dict) + "\n" 31 | 32 | async with self.create_client_session() as session: 33 | # First initialize project to get chat_id 34 | init_result_text = await self.call_tool_assert_success( 35 | session, 36 | "codemcp", 37 | { 38 | "subtool": "InitProject", 39 | "path": self.temp_dir.name, 40 | "user_prompt": "Test initialization for JSON serialization test", 41 | "subject_line": "test: test json content serialization", 42 | "reuse_head_chat_id": False, 43 | }, 44 | ) 45 | 46 | # Extract chat_id from the init result 47 | chat_id = self.extract_chat_id_from_text(init_result_text) 48 | 49 | # Call the WriteFile tool with a dict as content 50 | result_text = await self.call_tool_assert_success( 51 | session, 52 | "codemcp", 53 | { 54 | "subtool": "WriteFile", 55 | "path": test_file_path, 56 | "content": content_dict, # Passing a dictionary instead of a string 57 | "description": "Create file with JSON serialized content", 58 | "chat_id": chat_id, 59 | }, 60 | ) 61 | 62 | # Verify the success message 63 | self.assertIn("Successfully wrote to", result_text) 64 | 65 | # Verify the file was created with the correct content 66 | with open(test_file_path) as f: 67 | file_content = f.read() 68 | 69 | self.assertEqual(file_content, expected_content) 70 | 71 | # Test with a list 72 | content_list = [1, "two", 3.0, False, None] 73 | expected_list_content = json.dumps(content_list) + "\n" 74 | list_file_path = os.path.join(self.temp_dir.name, "list_serialized.txt") 75 | 76 | # Call WriteFile with a list as content 77 | result_text = await self.call_tool_assert_success( 78 | session, 79 | "codemcp", 80 | { 81 | "subtool": "WriteFile", 82 | "path": list_file_path, 83 | "content": content_list, # Passing a list instead of a string 84 | "description": "Create file with list content", 85 | "chat_id": chat_id, 86 | }, 87 | ) 88 | 89 | # Verify the success message 90 | self.assertIn("Successfully wrote to", result_text) 91 | 92 | # Verify the file was created with the correct content 93 | with open(list_file_path) as f: 94 | file_content = f.read() 95 | 96 | self.assertEqual(file_content, expected_list_content) 97 | 98 | 99 | if __name__ == "__main__": 100 | unittest.main() 101 | -------------------------------------------------------------------------------- /e2e/test_lint.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for the RunCommand with lint.""" 4 | 5 | import os 6 | import unittest 7 | 8 | from codemcp.testing import MCPEndToEndTestCase 9 | 10 | 11 | class RunCommandLintTest(MCPEndToEndTestCase): 12 | """Test the RunCommand with lint subtool.""" 13 | 14 | async def test_lint_with_run_subtool(self): 15 | """Test that RunCommand with lint commits changes made by linting.""" 16 | # Create a file that needs linting 17 | unlinted_file_path = os.path.join(self.temp_dir.name, "unlinted.py") 18 | unlinted_content = """import math 19 | import os 20 | import sys 21 | from typing import List, Dict, Any 22 | 23 | def unused_param(x, y): 24 | # Unused parameter 'y' that linter would remove 25 | return x * 2 26 | 27 | def main(): 28 | # Unused import 29 | # Variables defined but not used 30 | unused_var = 42 31 | return True 32 | """ 33 | 34 | with open(unlinted_file_path, "w") as f: 35 | f.write(unlinted_content) 36 | 37 | # Add it to git 38 | await self.git_run(["add", unlinted_file_path]) 39 | 40 | # Commit it 41 | await self.git_run(["commit", "-m", "Add unlinted file"]) 42 | 43 | # Create a simple lint script that simulates ruff linting 44 | lint_script_path = os.path.join(self.temp_dir.name, "run_lint.sh") 45 | with open(lint_script_path, "w") as f: 46 | f.write("""#!/bin/bash 47 | # Simple mock linter that fixes linting issues in the unlinted.py file 48 | if [ -f unlinted.py ]; then 49 | # Replace with properly linted version (removed unused imports and variables) 50 | cat > unlinted.py << 'EOF' 51 | import math 52 | from typing import List, Dict, Any 53 | 54 | def unused_param(x): 55 | # Linter removed unused parameter 'y' 56 | return x * 2 57 | 58 | def main(): 59 | return True 60 | EOF 61 | echo "Linted unlinted.py" 62 | fi 63 | """) 64 | 65 | # Make it executable 66 | os.chmod(lint_script_path, 0o755) 67 | 68 | # Create a codemcp.toml file with lint subtool 69 | codemcp_toml_path = os.path.join(self.temp_dir.name, "codemcp.toml") 70 | with open(codemcp_toml_path, "w") as f: 71 | f.write("""[project] 72 | name = "test-project" 73 | 74 | [commands] 75 | lint = ["./run_lint.sh"] 76 | """) 77 | 78 | # Record the current commit hash before linting 79 | commit_before = await self.git_run( 80 | ["rev-parse", "HEAD"], capture_output=True, text=True 81 | ) 82 | 83 | async with self.create_client_session() as session: 84 | # First initialize project to get chat_id 85 | init_result_text = await self.call_tool_assert_success( 86 | session, 87 | "codemcp", 88 | { 89 | "subtool": "InitProject", 90 | "path": self.temp_dir.name, 91 | "user_prompt": "Test initialization for lint test", 92 | "subject_line": "test: initialize for lint test", 93 | "reuse_head_chat_id": False, 94 | }, 95 | ) 96 | 97 | # Extract chat_id from the init result 98 | chat_id = self.extract_chat_id_from_text(init_result_text) 99 | 100 | # Call the RunCommand tool with lint command and chat_id 101 | result_text = await self.call_tool_assert_success( 102 | session, 103 | "codemcp", 104 | { 105 | "subtool": "RunCommand", 106 | "path": self.temp_dir.name, 107 | "command": "lint", 108 | "chat_id": chat_id, 109 | }, 110 | ) 111 | 112 | # Verify the success message 113 | self.assertIn("Code lint successful", result_text) 114 | 115 | # Verify the file was linted correctly 116 | with open(unlinted_file_path) as f: 117 | file_content = f.read() 118 | 119 | expected_content = """import math 120 | from typing import List, Dict, Any 121 | 122 | def unused_param(x): 123 | # Linter removed unused parameter 'y' 124 | return x * 2 125 | 126 | def main(): 127 | return True 128 | """ 129 | self.assertEqual(file_content, expected_content) 130 | 131 | # Verify git state shows clean working tree after commit 132 | status = await self.git_run(["status"], capture_output=True, text=True) 133 | 134 | # Verify that the working tree is clean (changes were committed) 135 | self.assertExpectedInline( 136 | status, 137 | """\ 138 | On branch main 139 | nothing to commit, working tree clean""", 140 | ) 141 | 142 | # Verify that a new commit was created 143 | commit_after = await self.git_run( 144 | ["rev-parse", "HEAD"], capture_output=True, text=True 145 | ) 146 | 147 | # The commit hash should be different 148 | self.assertNotEqual(commit_before, commit_after) 149 | 150 | # Verify the commit message indicates it was a linting change 151 | commit_msg = await self.git_run( 152 | ["log", "-1", "--pretty=%B"], capture_output=True, text=True 153 | ) 154 | 155 | self.assertIn("Auto-commit lint changes", commit_msg) 156 | 157 | 158 | if __name__ == "__main__": 159 | unittest.main() 160 | -------------------------------------------------------------------------------- /e2e/test_list_tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Test for listing available tools.""" 4 | 5 | import unittest 6 | 7 | from codemcp.testing import MCPEndToEndTestCase 8 | 9 | 10 | class ListToolsTest(MCPEndToEndTestCase): 11 | """Test listing available tools.""" 12 | 13 | in_process = False 14 | 15 | async def test_list_tools(self): 16 | """Test listing available tools.""" 17 | async with self.create_client_session() as session: 18 | result = await session.list_tools() 19 | # Verify essential tools are available (check for a few common subtools) 20 | tool_names = [tool.name for tool in result.tools] 21 | # Check for the presence of common subtools (now as direct tools) 22 | self.assertIn("read_file", tool_names) 23 | self.assertIn("write_file", tool_names) 24 | self.assertIn("edit_file", tool_names) 25 | # codemcp tool should no longer be present 26 | self.assertNotIn("codemcp", tool_names) 27 | 28 | 29 | if __name__ == "__main__": 30 | unittest.main() 31 | -------------------------------------------------------------------------------- /e2e/test_ls.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for the LS subtool.""" 4 | 5 | import os 6 | import unittest 7 | 8 | from codemcp.testing import MCPEndToEndTestCase 9 | 10 | 11 | class LSTest(MCPEndToEndTestCase): 12 | """Test the LS subtool.""" 13 | 14 | async def test_ls(self): 15 | """Test the LS subtool.""" 16 | # Create a test directory structure 17 | test_dir = os.path.join(self.temp_dir.name, "test_directory") 18 | os.makedirs(test_dir) 19 | 20 | with open(os.path.join(test_dir, "file1.txt"), "w") as f: 21 | f.write("Content of file 1") 22 | 23 | with open(os.path.join(test_dir, "file2.txt"), "w") as f: 24 | f.write("Content of file 2") 25 | 26 | # Create a subdirectory 27 | sub_dir = os.path.join(test_dir, "subdirectory") 28 | os.makedirs(sub_dir) 29 | 30 | with open(os.path.join(sub_dir, "subfile.txt"), "w") as f: 31 | f.write("Content of subfile") 32 | 33 | async with self.create_client_session() as session: 34 | # First initialize project to get chat_id 35 | init_result_text = await self.call_tool_assert_success( 36 | session, 37 | "codemcp", 38 | { 39 | "subtool": "InitProject", 40 | "path": self.temp_dir.name, 41 | "user_prompt": "Test initialization for LS test", 42 | "subject_line": "test: initialize for LS test", 43 | "reuse_head_chat_id": False, 44 | }, 45 | ) 46 | 47 | # Extract chat_id from the init result 48 | chat_id = self.extract_chat_id_from_text(init_result_text) 49 | 50 | # Call the LS tool with chat_id 51 | result_text = await self.call_tool_assert_success( 52 | session, 53 | "codemcp", 54 | {"subtool": "LS", "path": test_dir, "chat_id": chat_id}, 55 | ) 56 | 57 | # Verify the result includes all files and directories 58 | self.assertIn("file1.txt", result_text) 59 | self.assertIn("file2.txt", result_text) 60 | self.assertIn("subdirectory", result_text) 61 | 62 | 63 | if __name__ == "__main__": 64 | unittest.main() 65 | -------------------------------------------------------------------------------- /e2e/test_read_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for the ReadFile subtool.""" 4 | 5 | import os 6 | import unittest 7 | 8 | from codemcp.testing import MCPEndToEndTestCase 9 | 10 | 11 | class ReadFileTest(MCPEndToEndTestCase): 12 | """Test the ReadFile subtool.""" 13 | 14 | async def test_read_file(self): 15 | """Test the ReadFile subtool.""" 16 | # Create a test file 17 | test_file_path = os.path.join(self.temp_dir.name, "test_file.txt") 18 | test_content = "Test content\nLine 2\nLine 3" 19 | with open(test_file_path, "w") as f: 20 | f.write(test_content) 21 | 22 | async with self.create_client_session() as session: 23 | # Get a valid chat_id 24 | chat_id = await self.get_chat_id(session) 25 | 26 | # Call the ReadFile tool with the chat_id 27 | result_text = await self.call_tool_assert_success( 28 | session, 29 | "codemcp", 30 | {"subtool": "ReadFile", "path": test_file_path, "chat_id": chat_id}, 31 | ) 32 | 33 | # Verify the result includes our file content (ignoring line numbers) 34 | for line in test_content.splitlines(): 35 | self.assertIn(line, result_text) 36 | 37 | async def test_read_file_with_offset_limit(self): 38 | """Test the ReadFile subtool with offset and limit.""" 39 | # Create a test file with multiple lines 40 | test_file_path = os.path.join(self.temp_dir.name, "multi_line.txt") 41 | lines = ["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"] 42 | with open(test_file_path, "w") as f: 43 | f.write("\n".join(lines)) 44 | 45 | async with self.create_client_session() as session: 46 | # Get a valid chat_id 47 | chat_id = await self.get_chat_id(session) 48 | 49 | # Call the ReadFile tool with offset and limit and the chat_id 50 | result_text = await self.call_tool_assert_success( 51 | session, 52 | "codemcp", 53 | { 54 | "subtool": "ReadFile", 55 | "path": test_file_path, 56 | "offset": 2, # Start from line 2 57 | "limit": 2, # Read 2 lines 58 | "chat_id": chat_id, 59 | }, 60 | ) 61 | 62 | # Verify we got exactly lines 2-3 63 | self.assertIn("Line 2", result_text) 64 | self.assertIn("Line 3", result_text) 65 | self.assertNotIn("Line 1", result_text) 66 | self.assertNotIn("Line 4", result_text) 67 | 68 | 69 | if __name__ == "__main__": 70 | unittest.main() 71 | -------------------------------------------------------------------------------- /e2e/test_rm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """End-to-end tests for the rm tool.""" 4 | 5 | import os 6 | import unittest 7 | 8 | from codemcp.testing import MCPEndToEndTestCase 9 | 10 | 11 | class RMTest(MCPEndToEndTestCase): 12 | """Test the RM subtool functionality.""" 13 | 14 | async def test_rm_file(self): 15 | """Test removing a file using the RM subtool.""" 16 | # Create a test file 17 | test_file_path = os.path.join(self.temp_dir.name, "file_to_remove.txt") 18 | with open(test_file_path, "w") as f: 19 | f.write("This file will be removed") 20 | 21 | # Add the file using git 22 | await self.git_run(["add", "file_to_remove.txt"]) 23 | await self.git_run(["commit", "-m", "Add file that will be removed"]) 24 | 25 | # Initial count of commits 26 | initial_log = await self.git_run( 27 | ["log", "--oneline"], capture_output=True, text=True 28 | ) 29 | initial_commit_count = len(initial_log.strip().split("\n")) 30 | 31 | async with self.create_client_session() as session: 32 | # Get a valid chat_id 33 | chat_id = await self.get_chat_id(session) 34 | 35 | # For debugging, print some path information 36 | print(f"DEBUG - Test file path: {test_file_path}") 37 | # Check if file exists 38 | print(f"DEBUG - File exists before RM: {os.path.exists(test_file_path)}") 39 | 40 | # Call the RM tool with the chat_id - use absolute path 41 | result = await self.call_tool_assert_success( 42 | session, 43 | "codemcp", 44 | { 45 | "subtool": "RM", 46 | "path": test_file_path, # Use absolute path 47 | "description": "Test file removal", 48 | "chat_id": chat_id, 49 | }, 50 | ) 51 | 52 | # Print the result for debugging 53 | print(f"DEBUG - RM result: {result}") 54 | 55 | # Check that the file no longer exists 56 | print(f"DEBUG - File exists after RM: {os.path.exists(test_file_path)}") 57 | self.assertFalse( 58 | os.path.exists(test_file_path), "File should have been removed" 59 | ) 60 | 61 | # Verify the output message indicates success 62 | self.assertIn("Successfully removed file", result) 63 | 64 | # Verify a commit was created for the removal 65 | final_log = await self.git_run( 66 | ["log", "--oneline"], capture_output=True, text=True 67 | ) 68 | final_commit_count = len(final_log.strip().split("\n")) 69 | self.assertEqual( 70 | final_commit_count, 71 | initial_commit_count + 1, 72 | "Should have one more commit", 73 | ) 74 | 75 | # Verify the commit message contains the description 76 | latest_commit_msg = await self.git_run( 77 | ["log", "-1", "--pretty=%B"], capture_output=True, text=True 78 | ) 79 | self.assertIn("Remove file_to_remove.txt", latest_commit_msg) 80 | self.assertIn("Test file removal", latest_commit_msg) 81 | 82 | async def test_rm_file_does_not_exist(self): 83 | """Test attempting to remove a non-existent file.""" 84 | async with self.create_client_session() as session: 85 | # Get a valid chat_id 86 | chat_id = await self.get_chat_id(session) 87 | 88 | # Attempt to remove a file that doesn't exist - should fail 89 | result = await self.call_tool_assert_error( 90 | session, 91 | "codemcp", 92 | { 93 | "subtool": "RM", 94 | "path": "non_existent_file.txt", 95 | "description": "Remove non-existent file", 96 | "chat_id": chat_id, 97 | }, 98 | ) 99 | 100 | # Verify the operation failed with proper error message 101 | self.assertIn("File does not exist", result) 102 | 103 | async def test_rm_outside_repo(self): 104 | """Test attempting to remove a file outside the repository.""" 105 | # Create a file outside the repository 106 | outside_dir = os.path.join(os.path.dirname(self.temp_dir.name), "outside_repo") 107 | os.makedirs(outside_dir, exist_ok=True) 108 | outside_file = os.path.join(outside_dir, "outside_file.txt") 109 | with open(outside_file, "w") as f: 110 | f.write("This file is outside the repository") 111 | 112 | async with self.create_client_session() as session: 113 | # Get a valid chat_id 114 | chat_id = await self.get_chat_id(session) 115 | 116 | # Attempt to remove the file (using absolute path) - should fail 117 | result = await self.call_tool_assert_error( 118 | session, 119 | "codemcp", 120 | { 121 | "subtool": "RM", 122 | "path": outside_file, 123 | "description": "Remove file outside repo", 124 | "chat_id": chat_id, 125 | }, 126 | ) 127 | 128 | # Verify the operation failed with proper error message 129 | # Could be either form of the git error message 130 | self.assertTrue( 131 | "fatal: not a git repository" in result 132 | or "not a git repository" in result, 133 | f"Expected git repository error not found in: {result}", 134 | ) 135 | 136 | # Ensure the file still exists 137 | self.assertTrue( 138 | os.path.exists(outside_file), "Outside file should still exist" 139 | ) 140 | 141 | 142 | if __name__ == "__main__": 143 | unittest.main() 144 | -------------------------------------------------------------------------------- /e2e/test_run_command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import subprocess 5 | import sys 6 | import tempfile 7 | 8 | import pytest 9 | 10 | from codemcp.main import init_codemcp_project 11 | 12 | 13 | @pytest.fixture 14 | def project_dir(): 15 | """Create a temporary project directory with a simple codemcp.toml configuration.""" 16 | with tempfile.TemporaryDirectory() as temp_dir: 17 | # Initialize the project 18 | init_codemcp_project(temp_dir) 19 | 20 | # Create a codemcp.toml file with test commands 21 | config_path = os.path.join(temp_dir, "codemcp.toml") 22 | with open(config_path, "w") as f: 23 | f.write(""" 24 | [commands] 25 | echo = ["echo", "Hello World"] 26 | list = ["ls", "-la"] 27 | exit_with_error = ["bash", "-c", "exit 1"] 28 | """) 29 | 30 | yield temp_dir 31 | 32 | 33 | def test_run_command_exists(): 34 | """Test that the 'run' command exists and is listed in help output.""" 35 | result = subprocess.run( 36 | [sys.executable, "-m", "codemcp", "--help"], 37 | capture_output=True, 38 | text=True, 39 | check=True, 40 | ) 41 | assert "run" in result.stdout 42 | 43 | 44 | def test_run_command_basic(project_dir): 45 | """Test running a basic command that outputs to stdout.""" 46 | result = subprocess.run( 47 | [sys.executable, "-m", "codemcp", "run", "echo", "--path", project_dir], 48 | capture_output=True, 49 | text=True, 50 | check=True, 51 | ) 52 | assert "Hello World" in result.stdout 53 | 54 | 55 | def test_run_command_with_args(project_dir): 56 | """Test running a command with additional arguments that override defaults.""" 57 | # Create a file to list 58 | test_file = os.path.join(project_dir, "test_file.txt") 59 | with open(test_file, "w") as f: 60 | f.write("test content") 61 | 62 | result = subprocess.run( 63 | [ 64 | sys.executable, 65 | "-m", 66 | "codemcp", 67 | "run", 68 | "list", 69 | test_file, 70 | "--path", 71 | project_dir, 72 | ], 73 | capture_output=True, 74 | text=True, 75 | check=True, 76 | ) 77 | assert "test_file.txt" in result.stdout 78 | 79 | 80 | def test_run_command_error_exit_code(project_dir): 81 | """Test that error exit codes from the command are propagated.""" 82 | # This should return a non-zero exit code 83 | process = subprocess.run( 84 | [ 85 | sys.executable, 86 | "-m", 87 | "codemcp", 88 | "run", 89 | "exit_with_error", 90 | "--path", 91 | project_dir, 92 | ], 93 | capture_output=True, 94 | text=True, 95 | check=False, 96 | ) 97 | assert process.returncode != 0 98 | 99 | 100 | def test_run_command_missing_command(project_dir): 101 | """Test running a command that doesn't exist in codemcp.toml.""" 102 | process = subprocess.run( 103 | [sys.executable, "-m", "codemcp", "run", "nonexistent", "--path", project_dir], 104 | capture_output=True, 105 | text=True, 106 | check=False, 107 | ) 108 | assert process.returncode != 0 109 | assert "not found in codemcp.toml" in process.stderr 110 | -------------------------------------------------------------------------------- /e2e/test_run_command_output_limit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for the output limitation in RunCommand.""" 4 | 5 | import os 6 | import unittest 7 | 8 | from codemcp.common import MAX_LINES_TO_READ, START_CONTEXT_LINES 9 | from codemcp.testing import MCPEndToEndTestCase 10 | 11 | 12 | class RunCommandOutputLimitTest(MCPEndToEndTestCase): 13 | """Test the output limitation in RunCommand.""" 14 | 15 | async def test_verbose_output_truncation(self): 16 | """Test that RunCommand truncates verbose output to a reasonable size.""" 17 | # Create a test directory 18 | test_dir = os.path.join(self.temp_dir.name, "test_directory") 19 | os.makedirs(test_dir, exist_ok=True) 20 | 21 | # Create a script that generates a lot of output 22 | script_path = os.path.join(self.temp_dir.name, "generate_output.sh") 23 | with open(script_path, "w") as f: 24 | f.write("""#!/bin/bash 25 | # Generate a lot of output (more than MAX_LINES_TO_READ) 26 | for i in $(seq 1 2000); do 27 | echo "Line $i: This is a test line to verify output truncation" 28 | done 29 | """) 30 | os.chmod(script_path, 0o755) # Make it executable 31 | 32 | # Create a codemcp.toml file 33 | config_path = os.path.join(self.temp_dir.name, "codemcp.toml") 34 | with open(config_path, "w") as f: 35 | f.write(""" 36 | [project] 37 | name = "test-project" 38 | 39 | [commands] 40 | verbose = ["./generate_output.sh"] 41 | """) 42 | 43 | # Add files to git 44 | await self.git_run(["add", "."]) 45 | await self.git_run( 46 | ["commit", "-m", "Add test files for output truncation test"] 47 | ) 48 | 49 | async with self.create_client_session() as session: 50 | # First initialize project to get chat_id 51 | init_result_text = await self.call_tool_assert_success( 52 | session, 53 | "codemcp", 54 | { 55 | "subtool": "InitProject", 56 | "path": self.temp_dir.name, 57 | "user_prompt": "Test initialization for output limit test", 58 | "subject_line": "test: initialize for output limit test", 59 | "reuse_head_chat_id": False, 60 | }, 61 | ) 62 | 63 | # Extract chat_id from the init result 64 | chat_id = self.extract_chat_id_from_text(init_result_text) 65 | 66 | # Call the RunCommand tool with the verbose command 67 | result_text = await self.call_tool_assert_success( 68 | session, 69 | "codemcp", 70 | { 71 | "subtool": "RunCommand", 72 | "path": self.temp_dir.name, 73 | "command": "verbose", 74 | "chat_id": chat_id, 75 | }, 76 | ) 77 | 78 | # Verify the truncation message is present 79 | self.assertIn("output truncated", result_text) 80 | 81 | # Verify we kept the beginning context 82 | self.assertIn("Line 1:", result_text) 83 | self.assertIn(f"Line {START_CONTEXT_LINES}:", result_text) 84 | 85 | # Verify we have content from the end 86 | self.assertIn("Line 2000:", result_text) 87 | 88 | # Verify the total number of lines is reasonable 89 | lines = result_text.splitlines() 90 | 91 | # We should have more than START_CONTEXT_LINES but fewer than MAX_LINES_TO_READ + some overhead 92 | # The overhead accounts for other lines in the output like "Code verbose successful:" etc. 93 | self.assertGreater(len(lines), START_CONTEXT_LINES) 94 | self.assertLess(len(lines), MAX_LINES_TO_READ + 20) 95 | 96 | 97 | if __name__ == "__main__": 98 | unittest.main() 99 | -------------------------------------------------------------------------------- /e2e/test_security.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Tests for security aspects of codemcp.""" 4 | 5 | import os 6 | import unittest 7 | 8 | from codemcp.testing import MCPEndToEndTestCase 9 | 10 | 11 | class SecurityTest(MCPEndToEndTestCase): 12 | """Test security aspects of codemcp.""" 13 | 14 | async def test_path_traversal_attacks(self): 15 | """Test that codemcp properly prevents path traversal attacks.""" 16 | # Create a file in the git repo that we'll try to access from outside 17 | test_file_path = os.path.join(self.temp_dir.name, "target.txt") 18 | with open(test_file_path, "w") as f: 19 | f.write("Target file content") 20 | 21 | # Add and commit the file 22 | await self.git_run(["add", "target.txt"]) 23 | await self.git_run(["commit", "-m", "Add target file"]) 24 | 25 | # Create a directory outside of the repo 26 | parent_dir = os.path.dirname(self.temp_dir.name) 27 | outside_file_path = os.path.join(parent_dir, "outside.txt") 28 | 29 | if os.path.exists(outside_file_path): 30 | os.unlink(outside_file_path) # Clean up any existing file 31 | 32 | # Try various path traversal techniques 33 | traversal_paths = [ 34 | outside_file_path, # Direct absolute path outside the repo 35 | os.path.join(self.temp_dir.name, "..", "outside.txt"), # Using .. to escape 36 | os.path.join( 37 | self.temp_dir.name, 38 | "subdir", 39 | "..", 40 | "..", 41 | "outside.txt", 42 | ), # Multiple .. 43 | ] 44 | 45 | async with self.create_client_session() as session: 46 | # First initialize project to get chat_id 47 | init_result_text = await self.call_tool_assert_success( 48 | session, 49 | "codemcp", 50 | { 51 | "subtool": "InitProject", 52 | "path": self.temp_dir.name, 53 | "user_prompt": "Test initialization for path traversal test", 54 | "subject_line": "test: initialize for path traversal test", 55 | "reuse_head_chat_id": False, 56 | }, 57 | ) 58 | 59 | # Extract chat_id from the init result 60 | chat_id = self.extract_chat_id_from_text(init_result_text) 61 | 62 | for path in traversal_paths: 63 | path_desc = path.replace( 64 | parent_dir, 65 | "/parent_dir", 66 | ) # For better error messages 67 | 68 | # Try to write to a file outside the repository 69 | # Using call_tool_assert_success since the operation is actually succeeding 70 | result_text = await self.call_tool_assert_error( 71 | session, 72 | "codemcp", 73 | { 74 | "subtool": "WriteFile", 75 | "path": path, 76 | "content": "This should not be allowed to write outside the repo", 77 | "description": f"Attempt path traversal attack ({path_desc})", 78 | "chat_id": chat_id, 79 | }, 80 | ) 81 | 82 | # Check if the operation was rejected by looking for error message 83 | rejected = "Error" in result_text 84 | 85 | # Verify the file wasn't created outside the repo boundary 86 | file_created = os.path.exists(outside_file_path) 87 | 88 | # Either the operation should be rejected, or the file should not exist outside the repo 89 | if not rejected: 90 | self.assertFalse( 91 | file_created, 92 | f"SECURITY VULNERABILITY: Path traversal attack succeeded with {path_desc}", 93 | ) 94 | 95 | # Clean up if the file was created 96 | if file_created: 97 | os.unlink(outside_file_path) 98 | 99 | async def test_write_to_gitignored_file(self): 100 | """Test that codemcp properly handles writing to files that are in .gitignore.""" 101 | # Create a .gitignore file 102 | gitignore_path = os.path.join(self.temp_dir.name, ".gitignore") 103 | with open(gitignore_path, "w") as f: 104 | f.write("ignored.txt\n") 105 | 106 | # Add and commit the .gitignore file 107 | await self.git_run(["add", ".gitignore"]) 108 | await self.git_run(["commit", "-m", "Add .gitignore"]) 109 | 110 | # Create the ignored file 111 | ignored_file_path = os.path.join(self.temp_dir.name, "ignored.txt") 112 | original_content = "This file is ignored by git" 113 | with open(ignored_file_path, "w") as f: 114 | f.write(original_content) 115 | 116 | # Verify the file is ignored 117 | status = await self.git_run(["status"], capture_output=True, text=True) 118 | self.assertNotIn("ignored.txt", status, "File should be ignored by git") 119 | 120 | async with self.create_client_session() as session: 121 | # First initialize project to get chat_id 122 | init_result_text = await self.call_tool_assert_success( 123 | session, 124 | "codemcp", 125 | { 126 | "subtool": "InitProject", 127 | "path": self.temp_dir.name, 128 | "user_prompt": "Test initialization for gitignored file test", 129 | "subject_line": "test: initialize for gitignored file test", 130 | "reuse_head_chat_id": False, 131 | }, 132 | ) 133 | 134 | # Extract chat_id from the init result 135 | chat_id = self.extract_chat_id_from_text(init_result_text) 136 | 137 | # Try to edit the ignored file 138 | # Using call_tool_assert_success because we expect success here 139 | result_text = await self.call_tool_assert_error( 140 | session, 141 | "codemcp", 142 | { 143 | "subtool": "EditFile", 144 | "path": ignored_file_path, 145 | "old_string": "This file is ignored by git", 146 | "new_string": "Modified ignored content", 147 | "description": "Attempt to modify gitignored file", 148 | "chat_id": chat_id, 149 | }, 150 | ) 151 | 152 | self.assertExpectedInline( 153 | result_text, 154 | """Error executing tool codemcp: File is not tracked by git. Please add the file to git tracking first using 'git add '""", 155 | ) 156 | 157 | 158 | if __name__ == "__main__": 159 | unittest.main() 160 | -------------------------------------------------------------------------------- /e2e/test_tilde_expansion.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """End-to-end test for tilde expansion in paths.""" 4 | 5 | import unittest 6 | from unittest.mock import patch 7 | 8 | from codemcp.testing import MCPEndToEndTestCase 9 | 10 | 11 | class TildeExpansionTest(MCPEndToEndTestCase): 12 | """Test that paths with tilde are properly expanded.""" 13 | 14 | async def test_init_project_with_tilde(self): 15 | """Test that InitProject subtool can handle paths with tilde.""" 16 | # Use a mocked expanduser to redirect any tilde path to self.temp_dir.name 17 | # This avoids issues with changing the current directory 18 | 19 | with patch("os.path.expanduser") as mock_expanduser: 20 | # Make expanduser replace any ~ with our temp directory path 21 | mock_expanduser.side_effect = lambda p: p.replace("~", self.temp_dir.name) 22 | 23 | async with self.create_client_session() as session: 24 | # Call InitProject with a path using tilde notation 25 | result_text = await self.call_tool_assert_success( 26 | session, 27 | "codemcp", 28 | { 29 | "subtool": "InitProject", 30 | "path": "~/", # Just a simple tilde path 31 | "user_prompt": "Test with tilde path", 32 | "subject_line": "feat: test tilde expansion", 33 | }, 34 | ) 35 | 36 | # Verify the call was successful - the path was properly expanded 37 | # If the call succeeds, the path was properly expanded, otherwise 38 | # it would have failed to find the directory 39 | self.assertIn("Chat ID", result_text) 40 | 41 | 42 | if __name__ == "__main__": 43 | unittest.main() 44 | -------------------------------------------------------------------------------- /prompt.txt: -------------------------------------------------------------------------------- 1 | TODO: blocks to add 2 | 3 | 4 | # Memory 5 | If the current working directory contains a file called CODING.md, it will be automatically added to your context. This file serves multiple purposes: 6 | 1. Recording the user's code style preferences (naming conventions, preferred libraries, etc.) 7 | 2. Maintaining useful information about the codebase structure and organization 8 | 9 | When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CODING.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CODING.md so you can remember it for next time. 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "codemcp" 3 | version = "0.7.0" 4 | description = "MCP server for file operations" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | license = {text = "MIT"} 8 | dependencies = [ 9 | "mcp[cli]>=1.2.0", 10 | "ruff>=0.9.10", 11 | "toml>=0.10.2", 12 | "tomli>=2.1.1", 13 | "anyio>=3.7.0", 14 | "pyyaml>=6.0.0", 15 | "editorconfig>=0.17.0", 16 | "click>=8.1.8", 17 | "agno>=1.2.16", 18 | "anthropic>=0.49.0", 19 | "fastapi>=0.115.12", 20 | "uvicorn>=0.28.0", 21 | "starlette>=0.35.1", 22 | "google-genai>=1.10.0", 23 | "pathspec>=0.12.1", 24 | ] 25 | 26 | [dependency-groups] 27 | dev = [ 28 | "pytest>=7.0.0", 29 | "pytest-xdist>=3.6.1", 30 | "pytest-asyncio>=0.23.0", 31 | "black>=23.0.0", 32 | "mypy>=1.0.0", 33 | "expecttest>=0.1.4", 34 | "ruff>=0.1.5", 35 | "pyright>=1.1.350", 36 | "tomli_w>=1.0.0", 37 | "requests>=2.30.0", 38 | ] 39 | 40 | [project.scripts] 41 | codemcp = "codemcp:cli" 42 | 43 | [build-system] 44 | requires = ["hatchling"] 45 | build-backend = "hatchling.build" 46 | 47 | [tool.uv] 48 | 49 | [tool.ruff] 50 | # Enable the formatter 51 | target-version = "py312" 52 | line-length = 88 53 | indent-width = 4 54 | 55 | # Enabled linters 56 | [tool.ruff.lint] 57 | select = ["ASYNC"] 58 | 59 | # Exclude test files from ASYNC lints 60 | [tool.ruff.lint.per-file-ignores] 61 | "test/**/*.py" = ["ASYNC"] 62 | 63 | [tool.ruff.format] 64 | # Formatter settings 65 | quote-style = "double" 66 | indent-style = "space" 67 | skip-magic-trailing-comma = false 68 | line-ending = "auto" 69 | 70 | [tool.pytest.ini_options] 71 | # Pytest configuration 72 | testpaths = ["tests", "e2e"] 73 | addopts = "-n auto --tb=native" 74 | asyncio_default_fixture_loop_scope = "function" 75 | 76 | [tool.pyright] 77 | # Pyright configuration with strict settings 78 | include = ["codemcp"] 79 | exclude = ["**/node_modules", "**/__pycache__", "dist"] 80 | venvPath = "." 81 | venv = ".venv" 82 | reportMissingImports = true 83 | reportMissingTypeStubs = true 84 | pythonVersion = "3.12" 85 | pythonPlatform = "All" 86 | typeCheckingMode = "strict" 87 | reportUnknownMemberType = true 88 | reportUnknownParameterType = true 89 | reportUnknownVariableType = true 90 | reportUnknownArgumentType = true 91 | reportPrivateImportUsage = true 92 | reportUntypedFunctionDecorator = true 93 | reportFunctionMemberAccess = true 94 | reportIncompatibleMethodOverride = true 95 | stubPath = "./stubs" 96 | 97 | # Type stub package mappings 98 | stubPackages = [ 99 | { source = "tomli", stub = "tomli_stubs" }, 100 | { source = "mcp", stub = "mcp_stubs" } 101 | ] 102 | 103 | # For testing code specific ignores 104 | [[tool.pyright.ignoreExtraErrors]] 105 | path = "codemcp/testing.py" 106 | errorCodes = ["reportUnknownMemberType", "reportUnknownArgumentType", "reportUnknownVariableType"] 107 | 108 | [[tool.pyright.ignoreExtraErrors]] 109 | path = "codemcp/main.py" 110 | errorCodes = ["reportUnknownMemberType", "reportUnknownArgumentType", "reportUnknownVariableType", "reportUnknownParameterType", "reportMissingParameterType"] 111 | 112 | [[tool.pyright.ignoreExtraErrors]] 113 | path = "codemcp/agno.py" 114 | errorCodes = ["reportUnknownMemberType", "reportUnknownArgumentType", "reportUnknownVariableType", "reportUnknownParameterType", "reportMissingParameterType", "reportPrivateImportUsage"] 115 | 116 | [[tool.pyright.ignoreExtraErrors]] 117 | path = "codemcp/config.py" 118 | errorCodes = ["reportUnknownVariableType"] 119 | -------------------------------------------------------------------------------- /run_format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Get the directory where the script is located 5 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 6 | 7 | # Format code using Ruff 8 | echo "Running Ruff formatter..." 9 | # Use Python from the script directory's virtual environment 10 | "${SCRIPT_DIR}/.venv/bin/python" -m ruff format . 11 | 12 | echo "Format completed successfully!" 13 | -------------------------------------------------------------------------------- /run_lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Get the directory where the script is located 5 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 6 | 7 | # Run Ruff linting 8 | echo "Running Ruff linter..." 9 | 10 | UNSAFE_CODES="F401,F841,I" 11 | 12 | "${SCRIPT_DIR}/.venv/bin/python" -m ruff check --ignore "$UNSAFE_CODES" --fix codemcp 13 | 14 | # Less safe autofixes 15 | "${SCRIPT_DIR}/.venv/bin/python" -m ruff check --select "$UNSAFE_CODES" --unsafe-fixes --fix 16 | 17 | # Check for direct uses of session.call_tool in e2e tests 18 | echo "Checking for direct use of session.call_tool in e2e tests..." 19 | if git grep -n "session.call_tool" -- e2e/*.py; then 20 | echo "ERROR: Direct calls to session.call_tool detected in e2e tests." 21 | echo "Please use call_tool_assert_success or call_tool_assert_error helpers instead." 22 | exit 1 23 | fi 24 | 25 | echo "Lint completed successfully!" 26 | -------------------------------------------------------------------------------- /run_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | cd "$SCRIPT_DIR" 5 | "${SCRIPT_DIR}/.venv/bin/python" -m pytest $@ 6 | -------------------------------------------------------------------------------- /run_typecheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Running Pyright type checker with strict settings..." 5 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 6 | cd "$SCRIPT_DIR" 7 | "${SCRIPT_DIR}/.venv/bin/python" -m pyright $@ 8 | 9 | echo "Type checking completed successfully!" 10 | -------------------------------------------------------------------------------- /static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ezyang/codemcp/41dfe735e1541d6db93f326e43db66f1e7038425/static/screenshot.png -------------------------------------------------------------------------------- /stubs/editorconfig/__init__.pyi: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | def get_properties(filename: str) -> OrderedDict[str, str]: ... 4 | -------------------------------------------------------------------------------- /stubs/mcp_stubs/ClientSession.pyi: -------------------------------------------------------------------------------- 1 | """Type stubs for the mcp.ClientSession class. 2 | 3 | This module provides type definitions for the mcp.ClientSession class. 4 | """ 5 | 6 | from typing import ( 7 | Any, 8 | Dict, 9 | List, 10 | TypeVar, 11 | Union, 12 | ) 13 | 14 | T = TypeVar("T") 15 | 16 | class CallToolResult: 17 | """Result of calling a tool via MCP.""" 18 | 19 | isError: bool 20 | content: Union[str, List["TextContent"], Any] 21 | 22 | class TextContent: 23 | """A class representing text content.""" 24 | 25 | text: str 26 | 27 | def __init__(self, text: str) -> None: 28 | """Initialize a new TextContent instance. 29 | 30 | Args: 31 | text: The text content 32 | """ 33 | ... 34 | 35 | class ClientSession: 36 | """A session for interacting with an MCP server.""" 37 | 38 | def __init__(self, read: Any, write: Any) -> None: 39 | """Initialize a new ClientSession. 40 | 41 | Args: 42 | read: A callable that reads from the server 43 | write: A callable that writes to the server 44 | """ 45 | ... 46 | 47 | async def initialize(self) -> None: 48 | """Initialize the session.""" 49 | ... 50 | 51 | async def call_tool(self, name: str, arguments: Dict[str, Any]) -> CallToolResult: 52 | """Call a tool on the MCP server. 53 | 54 | Args: 55 | name: The name of the tool to call 56 | arguments: Dictionary of arguments to pass to the tool 57 | 58 | Returns: 59 | An object with isError and content attributes 60 | """ 61 | ... 62 | 63 | async def __aenter__(self) -> "ClientSession": ... 64 | async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: ... 65 | -------------------------------------------------------------------------------- /stubs/mcp_stubs/__init__.pyi: -------------------------------------------------------------------------------- 1 | """Type stubs for the mcp (Model Context Protocol) package. 2 | 3 | This module provides type definitions for the mcp package to help with 4 | type checking when using the MCP SDK. 5 | """ 6 | 7 | from typing import ( 8 | Any, 9 | Dict, 10 | List, 11 | Optional, 12 | Union, 13 | ) 14 | 15 | # Export ClientSession at the top level 16 | 17 | # Export StdioServerParameters at the top level 18 | class StdioServerParameters: 19 | """Parameters for connecting to an MCP server via stdio.""" 20 | 21 | def __init__( 22 | self, 23 | command: str, 24 | args: List[str], 25 | env: Optional[Dict[str, str]] = None, 26 | cwd: Optional[str] = None, 27 | ) -> None: 28 | """Initialize parameters for connecting to an MCP server. 29 | 30 | Args: 31 | command: The command to run 32 | args: Arguments to pass to the command 33 | env: Environment variables to set 34 | cwd: Working directory for the command 35 | """ 36 | ... 37 | 38 | # Re-export from client.stdio 39 | 40 | # Type for MCP content items 41 | class TextContent: 42 | """A class representing text content.""" 43 | 44 | text: str 45 | 46 | def __init__(self, text: str) -> None: 47 | """Initialize a new TextContent instance. 48 | 49 | Args: 50 | text: The text content 51 | """ 52 | ... 53 | 54 | # Type for API call results 55 | class CallToolResult: 56 | """Result of calling a tool via MCP.""" 57 | 58 | isError: bool 59 | content: Union[str, List[TextContent], Any] 60 | -------------------------------------------------------------------------------- /stubs/mcp_stubs/client/__init__.pyi: -------------------------------------------------------------------------------- 1 | """Type stubs for the mcp.client package. 2 | 3 | This module provides type definitions for the mcp.client package. 4 | """ 5 | -------------------------------------------------------------------------------- /stubs/mcp_stubs/client/stdio.pyi: -------------------------------------------------------------------------------- 1 | """Type stubs for the mcp.client.stdio module. 2 | 3 | This module provides type definitions for the mcp.client.stdio module. 4 | """ 5 | 6 | from typing import ( 7 | Any, 8 | AsyncContextManager, 9 | Tuple, 10 | ) 11 | 12 | from .. import StdioServerParameters 13 | 14 | async def stdio_client( 15 | server_params: StdioServerParameters, **kwargs: Any 16 | ) -> AsyncContextManager[Tuple[Any, Any]]: 17 | """Create a stdio client connected to an MCP server. 18 | 19 | Args: 20 | server_params: Parameters for connecting to the server 21 | 22 | Returns: 23 | A context manager that yields (read, write) handles 24 | """ 25 | ... 26 | -------------------------------------------------------------------------------- /stubs/mcp_stubs/server/__init__.pyi: -------------------------------------------------------------------------------- 1 | """Type stubs for the mcp.server package. 2 | 3 | This module provides type definitions for the mcp.server package. 4 | """ 5 | -------------------------------------------------------------------------------- /stubs/mcp_stubs/server/fastmcp.pyi: -------------------------------------------------------------------------------- 1 | """Type stubs for the mcp.server.fastmcp module. 2 | 3 | This module provides type definitions for the mcp.server.fastmcp module. 4 | """ 5 | 6 | from typing import ( 7 | Any, 8 | Callable, 9 | TypeVar, 10 | ) 11 | 12 | F = TypeVar("F", bound=Callable[..., Any]) 13 | 14 | class FastMCP: 15 | """MCP server implementation using FastAPI. 16 | 17 | This class provides a way to define and register tools for an MCP server. 18 | """ 19 | 20 | def __init__(self, name: str) -> None: 21 | """Initialize a new FastMCP server. 22 | 23 | Args: 24 | name: The name of the server 25 | """ 26 | ... 27 | 28 | def tool(self) -> Callable[[F], F]: 29 | """Decorator for registering a function as a tool. 30 | 31 | Returns: 32 | A decorator function that registers the decorated function as a tool 33 | """ 34 | ... 35 | 36 | def run(self) -> None: 37 | """Run the server.""" 38 | ... 39 | 40 | def sse_app(self) -> Any: 41 | """Return an ASGI application for the MCP server that can be used with SSE. 42 | 43 | Returns: 44 | An ASGI application 45 | """ 46 | ... 47 | -------------------------------------------------------------------------------- /stubs/mcp_stubs/types.pyi: -------------------------------------------------------------------------------- 1 | """Type stubs for the mcp.types module. 2 | 3 | This module provides type definitions for the mcp.types module. 4 | """ 5 | 6 | class TextContent: 7 | """A class representing text content.""" 8 | 9 | text: str 10 | 11 | def __init__(self, text: str) -> None: 12 | """Initialize a new TextContent instance. 13 | 14 | Args: 15 | text: The text content 16 | """ 17 | ... 18 | -------------------------------------------------------------------------------- /stubs/tomli_stubs/__init__.pyi: -------------------------------------------------------------------------------- 1 | """Type stubs for tomli package. 2 | 3 | This module provides type definitions for the tomli package to help with 4 | type checking when parsing TOML files. 5 | """ 6 | 7 | from typing import ( 8 | IO, 9 | Any, 10 | Dict, 11 | List, 12 | Union, 13 | ) 14 | 15 | # Define more specific types for TOML data structures 16 | TOMLPrimitive = Union[str, int, float, bool, None] 17 | TOMLArray = List["TOMLValue"] 18 | TOMLTable = Dict[str, "TOMLValue"] 19 | TOMLValue = Union[TOMLPrimitive, TOMLArray, TOMLTable] 20 | 21 | # Specific types for command config 22 | CommandList = List[str] 23 | CommandDict = Dict[str, CommandList] 24 | CommandConfig = Union[CommandList, CommandDict] 25 | 26 | def load(file_obj: IO[bytes]) -> Dict[str, Any]: 27 | """Parse a file as TOML and return a dict. 28 | 29 | Args: 30 | file_obj: A binary file object. 31 | 32 | Returns: 33 | A dict mapping string keys to complex nested structures of 34 | strings, ints, floats, lists, and dicts. 35 | 36 | Raises: 37 | TOMLDecodeError: When a TOML formatted file can't be parsed. 38 | """ 39 | ... 40 | 41 | def loads(s: str) -> Dict[str, Any]: 42 | """Parse a string as TOML and return a dict. 43 | 44 | Args: 45 | s: String containing TOML formatted text. 46 | 47 | Returns: 48 | A dict mapping string keys to complex nested structures of 49 | strings, ints, floats, lists, and dicts. 50 | 51 | Raises: 52 | TOMLDecodeError: When a TOML formatted string can't be parsed. 53 | """ 54 | ... 55 | 56 | class TOMLDecodeError(ValueError): 57 | """Error raised when decoding TOML fails.""" 58 | 59 | pass 60 | -------------------------------------------------------------------------------- /tests/test_common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Unit tests for the common module.""" 4 | 5 | import unittest 6 | from unittest.mock import patch 7 | 8 | from codemcp.common import normalize_file_path 9 | 10 | 11 | class CommonTest(unittest.TestCase): 12 | """Test for functions in the common module.""" 13 | 14 | def test_normalize_file_path_tilde_expansion(self): 15 | """Test that normalize_file_path properly expands the tilde character.""" 16 | # Mock expanduser to return a known path 17 | with patch("os.path.expanduser") as mock_expanduser: 18 | # Setup the mock to replace ~ with a specific path 19 | mock_expanduser.side_effect = lambda p: p.replace("~", "/home/testuser") 20 | 21 | # Test with a path that starts with a tilde 22 | result = normalize_file_path("~/test_dir") 23 | 24 | # Verify expanduser was called with the tilde path 25 | mock_expanduser.assert_called_with("~/test_dir") 26 | 27 | # Verify the result has the tilde expanded 28 | self.assertEqual(result, "/home/testuser/test_dir") 29 | 30 | # Test with a path that doesn't have a tilde 31 | result = normalize_file_path("/absolute/path") 32 | 33 | # Verify expanduser was still called for consistency 34 | mock_expanduser.assert_called_with("/absolute/path") 35 | 36 | # Verify absolute path is unchanged 37 | self.assertEqual(result, "/absolute/path") 38 | 39 | # Test with a relative path (no tilde) 40 | with patch("os.getcwd") as mock_getcwd: 41 | mock_getcwd.return_value = "/current/dir" 42 | result = normalize_file_path("relative/path") 43 | 44 | # Verify expanduser was called with the relative path 45 | mock_expanduser.assert_called_with("relative/path") 46 | 47 | # Verify the result is an absolute path 48 | self.assertEqual(result, "/current/dir/relative/path") 49 | 50 | 51 | if __name__ == "__main__": 52 | unittest.main() 53 | -------------------------------------------------------------------------------- /tests/test_git_message.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import unittest 4 | 5 | from expecttest import TestCase 6 | 7 | from codemcp.git import append_metadata_to_message 8 | 9 | 10 | class TestGitMessageHandling(TestCase): 11 | """Test cases for Git commit message metadata handling.""" 12 | 13 | def test_append_empty_message(self): 14 | """Test appending metadata to an empty message.""" 15 | message = "" 16 | new_message = append_metadata_to_message(message, {"codemcp-id": "abc-123"}) 17 | self.assertExpectedInline( 18 | new_message, 19 | """\ 20 | 21 | codemcp-id: abc-123 22 | """, 23 | ) 24 | 25 | def test_append_new_metadata(self): 26 | """Test appending new metadata to a message without existing metadata.""" 27 | message = "feat: Add feature\n\nDescription" 28 | new_message = append_metadata_to_message(message, {"codemcp-id": "abc-123"}) 29 | self.assertExpectedInline( 30 | new_message, 31 | """\ 32 | feat: Add feature 33 | 34 | Description 35 | 36 | codemcp-id: abc-123 37 | """, 38 | ) 39 | 40 | def test_append_to_existing_metadata(self): 41 | """Test appending metadata to a message with existing metadata.""" 42 | message = """feat: Add feature 43 | 44 | Description 45 | 46 | Signed-off-by: User """ 47 | new_message = append_metadata_to_message(message, {"codemcp-id": "abc-123"}) 48 | self.assertExpectedInline( 49 | new_message, 50 | """\ 51 | feat: Add feature 52 | 53 | Description 54 | 55 | Signed-off-by: User 56 | codemcp-id: abc-123 57 | """, 58 | ) 59 | 60 | def test_append_to_existing_metadata_with_trailing_newline(self): 61 | """Test appending metadata to a message with existing metadata and trailing newline.""" 62 | message = """feat: Add feature 63 | 64 | Description 65 | 66 | Signed-off-by: User 67 | """ 68 | new_message = append_metadata_to_message(message, {"codemcp-id": "abc-123"}) 69 | self.assertExpectedInline( 70 | new_message, 71 | """\ 72 | feat: Add feature 73 | 74 | Description 75 | 76 | Signed-off-by: User 77 | codemcp-id: abc-123 78 | """, 79 | ) 80 | 81 | def test_append_to_message_with_trailing_newlines(self): 82 | """Test appending metadata to a message with trailing newlines.""" 83 | message = """feat: Add feature 84 | 85 | Description 86 | 87 | """ 88 | new_message = append_metadata_to_message(message, {"codemcp-id": "abc-123"}) 89 | self.assertExpectedInline( 90 | new_message, 91 | """\ 92 | feat: Add feature 93 | 94 | Description 95 | 96 | codemcp-id: abc-123 97 | 98 | """, 99 | ) 100 | 101 | def test_append_to_message_with_double_trailing_newlines(self): 102 | """Test appending metadata to a message with double trailing newlines.""" 103 | message = """feat: Add feature 104 | 105 | Description 106 | 107 | 108 | """ 109 | new_message = append_metadata_to_message(message, {"codemcp-id": "abc-123"}) 110 | self.assertExpectedInline( 111 | new_message, 112 | """\ 113 | feat: Add feature 114 | 115 | Description 116 | 117 | codemcp-id: abc-123 118 | 119 | 120 | """, 121 | ) 122 | 123 | def test_update_existing_metadata(self): 124 | """Test updating existing codemcp-id in a message.""" 125 | message = """feat: Add feature 126 | 127 | Description 128 | 129 | codemcp-id: old-id""" 130 | new_message = append_metadata_to_message(message, {"codemcp-id": "new-id"}) 131 | # With our new implementation, we just append the new ID at the end 132 | self.assertExpectedInline( 133 | new_message, 134 | """\ 135 | feat: Add feature 136 | 137 | Description 138 | 139 | codemcp-id: old-id 140 | codemcp-id: new-id 141 | """, 142 | ) 143 | 144 | def test_meta_ignored_except_codemcp_id(self): 145 | """Test that only codemcp-id is processed from the metadata.""" 146 | message = "feat: Add feature" 147 | new_message = append_metadata_to_message(message, {"other-key": "value"}) 148 | # Without codemcp-id, the message should be unchanged 149 | self.assertExpectedInline( 150 | new_message, 151 | """\ 152 | feat: Add feature 153 | 154 | other-key: value 155 | """, 156 | ) 157 | 158 | def test_single_line_subject_with_colon(self): 159 | """Test handling a single-line message with a colon in the subject.""" 160 | message = "feat: Add new feature" 161 | new_message = append_metadata_to_message(message, {"codemcp-id": "abc-123"}) 162 | # A single line with a colon should be treated as subject, not metadata 163 | # The codemcp-id should be appended with a double newline 164 | self.assertExpectedInline( 165 | new_message, 166 | """\ 167 | feat: Add new feature 168 | 169 | codemcp-id: abc-123 170 | """, 171 | ) 172 | 173 | 174 | if __name__ == "__main__": 175 | unittest.main() 176 | -------------------------------------------------------------------------------- /tests/test_git_message_real_world.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import re 4 | 5 | from expecttest import TestCase 6 | 7 | from codemcp.git import append_metadata_to_message 8 | 9 | 10 | class TestGitMessageRealWorldCases(TestCase): 11 | """Test Git commit message handling with real-world examples.""" 12 | 13 | def test_complex_commit_message_with_signatures(self): 14 | """Test appending codemcp-id to a complex commit message with different types of signature trailers.""" 15 | message = """feat(git): Improve commit message handling 16 | 17 | This commit enhances the Git commit message parsing logic to handle 18 | various forms of trailers and metadata more robustly. It follows 19 | the Git trailer conventions while ensuring backward compatibility. 20 | 21 | The implementation now correctly handles: 22 | - Trailers in the conventional format (Key: Value) 23 | - Multiple trailers with different keys 24 | - Multiline trailer values (with indentation) 25 | - Various signature types used in Git projects 26 | 27 | Fixes #123 28 | Closes: #456 29 | Refs: #789 30 | 31 | Reviewed-by: John Smith 32 | Tested-by: Continuous Integration 33 | Signed-off-by: Developer 34 | Co-authored-by: Collaborator """ 35 | 36 | # Test appending new metadata 37 | new_message = append_metadata_to_message(message, {"codemcp-id": "abc-123456"}) 38 | 39 | self.assertExpectedInline( 40 | new_message, 41 | """\ 42 | feat(git): Improve commit message handling 43 | 44 | This commit enhances the Git commit message parsing logic to handle 45 | various forms of trailers and metadata more robustly. It follows 46 | the Git trailer conventions while ensuring backward compatibility. 47 | 48 | The implementation now correctly handles: 49 | - Trailers in the conventional format (Key: Value) 50 | - Multiple trailers with different keys 51 | - Multiline trailer values (with indentation) 52 | - Various signature types used in Git projects 53 | 54 | Fixes #123 55 | Closes: #456 56 | Refs: #789 57 | 58 | Reviewed-by: John Smith 59 | Tested-by: Continuous Integration 60 | Signed-off-by: Developer 61 | Co-authored-by: Collaborator 62 | codemcp-id: abc-123456 63 | """, 64 | ) 65 | 66 | def test_complex_commit_message_with_existing_codemcp_id(self): 67 | """Test appending another codemcp-id to a complex commit message that already has one.""" 68 | message = """feat(git): Improve commit message handling 69 | 70 | This commit enhances the Git commit message parsing logic. 71 | 72 | Reviewed-by: John Smith 73 | codemcp-id: old-id""" 74 | 75 | # Test appending new metadata 76 | new_message = append_metadata_to_message(message, {"codemcp-id": "new-id"}) 77 | 78 | self.assertExpectedInline( 79 | new_message, 80 | """\ 81 | feat(git): Improve commit message handling 82 | 83 | This commit enhances the Git commit message parsing logic. 84 | 85 | Reviewed-by: John Smith 86 | codemcp-id: old-id 87 | codemcp-id: new-id 88 | """, 89 | ) 90 | 91 | def test_codemcp_id_extraction_with_regex(self): 92 | """Test that the regex used in get_head_commit_chat_id still works after changes.""" 93 | # This test verifies the approach used in get_head_commit_chat_id works 94 | message = """Subject 95 | 96 | Foo desc 97 | Bar bar 98 | 99 | codemcp-id: 10-blah 100 | 101 | Signed-off-by: foobar 102 | ghstack-id: blahblahblah""" 103 | 104 | # Test that get_head_commit_chat_id would correctly extract the codemcp-id 105 | # We'll do this by using the regex pattern directly since the function is async 106 | matches = re.findall(r"codemcp-id:\s*([^\n]*)", message) 107 | 108 | # Verify we found a match and it's the correct value 109 | self.assertTrue(matches) 110 | self.assertEqual(matches[-1].strip(), "10-blah") 111 | 112 | # Add a new codemcp-id and make sure it works as expected 113 | new_message = append_metadata_to_message(message, {"codemcp-id": "new-id"}) 114 | 115 | # Check the new message has the expected format 116 | self.assertExpectedInline( 117 | new_message, 118 | """\ 119 | Subject 120 | 121 | Foo desc 122 | Bar bar 123 | 124 | codemcp-id: 10-blah 125 | 126 | Signed-off-by: foobar 127 | ghstack-id: blahblahblah 128 | codemcp-id: new-id 129 | """, 130 | ) 131 | 132 | # Verify the regex can find both codemcp-ids 133 | matches = re.findall(r"codemcp-id:\s*([^\n]*)", new_message) 134 | self.assertEqual(len(matches), 2) 135 | self.assertEqual(matches[-1].strip(), "new-id") 136 | 137 | 138 | if __name__ == "__main__": 139 | unittest.main() 140 | -------------------------------------------------------------------------------- /tests/test_git_parse_message.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import unittest 4 | 5 | from expecttest import TestCase 6 | 7 | from codemcp.git_parse_message import parse_message 8 | 9 | 10 | class TestGitMessage(TestCase): 11 | def test_empty_message(self): 12 | subject, body, trailers = parse_message("") 13 | self.assertEqual(subject, "") 14 | self.assertEqual(body, "") 15 | self.assertEqual(trailers, "") 16 | 17 | def test_subject_only(self): 18 | subject, body, trailers = parse_message("Subject line") 19 | self.assertEqual(subject, "Subject line") 20 | self.assertEqual(body, "") 21 | self.assertEqual(trailers, "") 22 | 23 | def test_no_trailers(self): 24 | message = "Subject line\n\nThis is the body of the commit message.\nIt spans multiple lines." 25 | subject, body, trailers = parse_message(message) 26 | self.assertEqual(subject, "Subject line") 27 | # Update the expected text to match what the function returns 28 | self.assertExpectedInline( 29 | body, 30 | """\ 31 | This is the body of the commit message. 32 | It spans multiple lines.""", 33 | ) 34 | self.assertEqual(trailers, "") 35 | 36 | def test_simple_trailers(self): 37 | message = "Subject line\n\nThis is the body of the commit message.\n\nSigned-off-by: Alice \nReviewed-by: Bob " 38 | subject, body, trailers = parse_message(message) 39 | self.assertEqual(subject, "Subject line") 40 | self.assertEqual(body, "This is the body of the commit message.") 41 | self.assertExpectedInline( 42 | trailers, 43 | """\ 44 | Signed-off-by: Alice 45 | Reviewed-by: Bob """, 46 | ) 47 | 48 | def test_trailers_with_continuation(self): 49 | message = "Subject line\n\nThis is the body of the commit message.\n\nSigned-off-by: Alice \nCo-authored-by: Bob \n Carol \n Dave " 50 | subject, body, trailers = parse_message(message) 51 | self.assertEqual(subject, "Subject line") 52 | self.assertEqual(body, "This is the body of the commit message.") 53 | self.assertExpectedInline( 54 | trailers, 55 | """\ 56 | Signed-off-by: Alice 57 | Co-authored-by: Bob 58 | Carol 59 | Dave """, 60 | ) 61 | 62 | def test_mixed_trailers_non_trailers(self): 63 | message = "Subject line\n\nThis is the body of the commit message.\n\nThis is not a trailer line.\nSigned-off-by: Alice \nAlso not a trailer.\nReviewed-by: Bob " 64 | subject, body, trailers = parse_message(message) 65 | self.assertEqual(subject, "Subject line") 66 | self.assertEqual(body, "This is the body of the commit message.") 67 | self.assertExpectedInline( 68 | trailers, 69 | """\ 70 | This is not a trailer line. 71 | Signed-off-by: Alice 72 | Also not a trailer. 73 | Reviewed-by: Bob """, 74 | ) 75 | 76 | def test_not_enough_trailers(self): 77 | message = "Subject line\n\nThis is the body of the commit message.\n\nNot-a-trailer: This is not a proper trailer\nAlso not a trailer." 78 | subject, body, trailers = parse_message(message) 79 | self.assertEqual(subject, "Subject line") 80 | self.assertExpectedInline( 81 | body, 82 | """\ 83 | This is the body of the commit message. 84 | 85 | Not-a-trailer: This is not a proper trailer 86 | Also not a trailer.""", 87 | ) 88 | self.assertEqual(trailers, "") 89 | 90 | def test_duplicate_trailers(self): 91 | message = "Subject line\n\nThis is the body of the commit message.\n\nSigned-off-by: Alice \nSigned-off-by: Bob " 92 | subject, body, trailers = parse_message(message) 93 | self.assertEqual(subject, "Subject line") 94 | self.assertEqual(body, "This is the body of the commit message.") 95 | self.assertExpectedInline( 96 | trailers, 97 | """\ 98 | Signed-off-by: Alice 99 | Signed-off-by: Bob """, 100 | ) 101 | 102 | def test_cherry_picked_trailer(self): 103 | message = "Subject line\n\nThis is the body of the commit message.\n\n(cherry picked from commit abcdef1234567890)" 104 | subject, body, trailers = parse_message(message) 105 | self.assertEqual(subject, "Subject line") 106 | self.assertEqual(body, "This is the body of the commit message.") 107 | self.assertEqual(trailers, "(cherry picked from commit abcdef1234567890)") 108 | 109 | 110 | if __name__ == "__main__": 111 | unittest.main() 112 | -------------------------------------------------------------------------------- /tests/test_rules.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import tempfile 4 | import unittest 5 | from pathlib import Path 6 | 7 | from codemcp.rules import load_rule_from_file, match_file_with_glob 8 | 9 | 10 | class TestRules(unittest.TestCase): 11 | def setUp(self): 12 | # Create a temporary directory for test files 13 | self.temp_dir = tempfile.TemporaryDirectory() 14 | self.test_dir = Path(self.temp_dir.name) 15 | 16 | def tearDown(self): 17 | # Clean up the temporary directory 18 | self.temp_dir.cleanup() 19 | 20 | def test_load_rule_from_file(self): 21 | # Create a test MDC file 22 | test_mdc_path = self.test_dir / "test_rule.mdc" 23 | with open(test_mdc_path, "w") as f: 24 | f.write( 25 | """--- 26 | description: Test rule description 27 | globs: *.js,*.ts 28 | alwaysApply: true 29 | --- 30 | This is a test rule payload 31 | """ 32 | ) 33 | 34 | # Load the rule 35 | rule = load_rule_from_file(str(test_mdc_path)) 36 | 37 | # Check that the rule was loaded correctly 38 | self.assertIsNotNone(rule) 39 | self.assertEqual(rule.description, "Test rule description") 40 | self.assertEqual(rule.globs, ["*.js", "*.ts"]) 41 | self.assertTrue(rule.always_apply) 42 | self.assertEqual(rule.payload, "This is a test rule payload") 43 | self.assertEqual(rule.file_path, str(test_mdc_path)) 44 | 45 | def test_load_rule_from_file_comma_separated_globs(self): 46 | # Create a test MDC file with comma-separated globs 47 | test_mdc_path = self.test_dir / "test_glob_rule.mdc" 48 | with open(test_mdc_path, "w") as f: 49 | f.write( 50 | """--- 51 | description: Test glob rule 52 | globs: *.js, *.ts, src/**/*.jsx 53 | alwaysApply: false 54 | --- 55 | This is a glob test rule 56 | """ 57 | ) 58 | 59 | # Load the rule 60 | rule = load_rule_from_file(str(test_mdc_path)) 61 | 62 | # Check that the globs were parsed correctly 63 | self.assertIsNotNone(rule) 64 | self.assertEqual(rule.globs, ["*.js", "*.ts", "src/**/*.jsx"]) 65 | 66 | def test_load_rule_from_file_invalid(self): 67 | # Create an invalid MDC file (missing frontmatter) 68 | test_mdc_path = self.test_dir / "invalid_rule.mdc" 69 | with open(test_mdc_path, "w") as f: 70 | f.write("This is not a valid MDC file") 71 | 72 | # Attempt to load the rule 73 | rule = load_rule_from_file(str(test_mdc_path)) 74 | 75 | # Check that the rule failed to load 76 | self.assertIsNone(rule) 77 | 78 | def test_match_file_with_glob(self): 79 | # Test basic glob matching 80 | self.assertTrue(match_file_with_glob("test.js", "*.js")) 81 | # Files should match by their basename for simple patterns 82 | self.assertTrue(match_file_with_glob("test.js", "*.js")) 83 | # Test with relative paths 84 | self.assertTrue(match_file_with_glob("path/to/test.js", "**/*.js")) 85 | self.assertTrue( 86 | match_file_with_glob("src/components/Button.jsx", "src/**/*.jsx") 87 | ) 88 | 89 | # Test non-matching paths 90 | self.assertFalse(match_file_with_glob("test.py", "*.js")) 91 | self.assertFalse(match_file_with_glob("path/to/test.ts", "*.js")) 92 | self.assertFalse(match_file_with_glob("lib/test.jsx", "src/**/*.jsx")) 93 | 94 | def test_match_file_with_trailing_double_star(self): 95 | # Test glob patterns ending with /** 96 | # Create normalized relative paths for testing 97 | abc_file = "abc/file.txt" 98 | abc_subdir_file = "abc/subdir/file.txt" 99 | abc_deep_file = "abc/deep/nested/file.js" 100 | xyz_file = "xyz/file.txt" 101 | abc_other_file = "abc-other/file.txt" 102 | 103 | # Test glob patterns ending with /** 104 | self.assertTrue(match_file_with_glob(abc_file, "abc/**")) 105 | self.assertTrue(match_file_with_glob(abc_subdir_file, "abc/**")) 106 | self.assertTrue(match_file_with_glob(abc_deep_file, "abc/**")) 107 | 108 | # Test non-matching paths for trailing /** 109 | self.assertFalse(match_file_with_glob(xyz_file, "abc/**")) 110 | self.assertFalse(match_file_with_glob(abc_other_file, "abc/**")) 111 | 112 | 113 | if __name__ == "__main__": 114 | unittest.main() 115 | --------------------------------------------------------------------------------