├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .python-version ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── doc ├── INSTALL.md ├── TUTORIAL.md ├── example.gif └── example2.gif ├── mcp_claude_code ├── __init__.py ├── cli.py ├── prompts │ ├── __init__.py │ ├── compact_conversation.py │ ├── create_release.py │ ├── project_system.py │ ├── project_todo_reminder.py │ └── utils.py ├── server.py └── tools │ ├── __init__.py │ ├── agent │ ├── __init__.py │ ├── agent_tool.py │ ├── prompt.py │ └── tool_adapter.py │ ├── common │ ├── __init__.py │ ├── base.py │ ├── batch_tool.py │ ├── context.py │ ├── permissions.py │ ├── thinking_tool.py │ └── validation.py │ ├── filesystem │ ├── __init__.py │ ├── base.py │ ├── content_replace.py │ ├── directory_tree.py │ ├── edit.py │ ├── grep.py │ ├── grep_ast_tool.py │ ├── multi_edit.py │ ├── read.py │ └── write.py │ ├── jupyter │ ├── __init__.py │ ├── base.py │ ├── notebook_edit.py │ └── notebook_read.py │ ├── shell │ ├── __init__.py │ ├── base.py │ ├── bash_session.py │ ├── bash_session_executor.py │ ├── command_executor.py │ ├── run_command.py │ ├── run_command_windows.py │ ├── session_manager.py │ └── session_storage.py │ └── todo │ ├── __init__.py │ ├── base.py │ ├── todo_read.py │ └── todo_write.py ├── pyproject.toml ├── tests ├── __init__.py ├── conftest.py ├── test_agent │ ├── .gitignore │ ├── __init__.py │ ├── check_openai_api.py │ ├── test_agent_tool.py │ ├── test_litellm_providers.py │ ├── test_model_capabilities.py │ ├── test_prompt.py │ └── test_tool_adapter.py ├── test_cli.py ├── test_common │ ├── __init__.py │ ├── test_batch_tool.py │ ├── test_context.py │ ├── test_hidden_files.py │ ├── test_mcp_description.py │ ├── test_permissions.py │ ├── test_thinking.py │ └── test_thinking_tool.py ├── test_filesystem │ ├── __init__.py │ ├── test_file_operations.py │ ├── test_fs_tools.py │ ├── test_grep.py │ └── test_multi_edit.py ├── test_jupyter │ ├── __init__.py │ ├── test_jupyter_base.py │ └── test_notebook_tools.py ├── test_prompts.py ├── test_server.py ├── test_shell │ ├── __init__.py │ ├── test_advanced_bash_session.py │ ├── test_command_executor.py │ ├── test_enhanced_shell_features.py │ ├── test_error_handling_integration.py │ ├── test_quote_escaping.py │ └── test_session_based_run_command.py ├── test_todo │ ├── __init__.py │ ├── test_new_functionality.py │ ├── test_project_todo_reminder.py │ ├── test_todo_integration.py │ ├── test_todo_read.py │ ├── test_todo_storage.py │ ├── test_todo_validation.py │ └── test_todo_write.py └── test_validation.py └── uv.lock /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release and Publish to PyPI 2 | 3 | permissions: 4 | contents: write 5 | packages: write 6 | 7 | on: 8 | push: 9 | tags: 10 | - 'v*' 11 | 12 | jobs: 13 | test: 14 | name: Run Tests 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.13' 24 | 25 | - name: Install dependencies 26 | run: make install-test 27 | 28 | - name: Run tests 29 | run: make test 30 | 31 | build: 32 | name: Build Distribution 33 | needs: test 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v4 38 | 39 | - name: Set up Python 40 | uses: actions/setup-python@v5 41 | with: 42 | python-version: '3.13' 43 | 44 | - name: Install build dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | python -m pip install build 48 | 49 | - name: Build distributions 50 | run: python -m build 51 | 52 | - name: Upload distribution artifacts 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: python-package-distributions 56 | path: dist/ 57 | 58 | publish-github: 59 | name: Publish GitHub Release 60 | needs: build 61 | runs-on: ubuntu-latest 62 | permissions: 63 | contents: write 64 | steps: 65 | - name: Checkout code 66 | uses: actions/checkout@v4 67 | with: 68 | fetch-depth: 0 69 | 70 | - name: Download distribution artifacts 71 | uses: actions/download-artifact@v4 72 | with: 73 | name: python-package-distributions 74 | path: dist/ 75 | 76 | - name: Set tag name 77 | id: tag 78 | run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 79 | 80 | - name: Extract release notes from CHANGELOG.md 81 | id: extract-release-notes 82 | run: | 83 | CHANGELOG=$(cat CHANGELOG.md) 84 | VERSION=$(echo ${{ steps.tag.outputs.tag }} | sed 's/^v//') 85 | 86 | echo "Extracting release notes for version $VERSION" 87 | 88 | # Extract the release notes for this version 89 | NOTES=$(awk -v ver="$VERSION" ' 90 | BEGIN { found=0; printing=0; } 91 | /^## \[[0-9]+\.[0-9]+\.[0-9]+\]/ { 92 | if (printing) { printing=0; exit; } 93 | if ($0 ~ ("## \\[" ver "\\]")) { printing=1; found=1; next; } 94 | } 95 | printing { print; } 96 | END { if (!found) print "No release notes found for version " ver; } 97 | ' CHANGELOG.md) 98 | 99 | # Save to file 100 | if [ -z "$NOTES" ]; then 101 | echo "No release notes found for version $VERSION or extraction failed." 102 | echo "**Release $VERSION**\n\nPlease check the [CHANGELOG.md](https://github.com/SDGLBL/mcp-claude-code/blob/main/CHANGELOG.md) for more details." > release_notes.md 103 | else 104 | echo "$NOTES" > release_notes.md 105 | echo "Successfully extracted release notes for version $VERSION" 106 | fi 107 | 108 | # Display content of release notes file for debugging 109 | echo "Content of release_notes.md:" 110 | cat release_notes.md 111 | 112 | - name: Create GitHub Release 113 | uses: softprops/action-gh-release@v1 114 | with: 115 | tag_name: ${{ steps.tag.outputs.tag }} 116 | name: MCP Claude Code ${{ steps.tag.outputs.tag }} 117 | body_path: release_notes.md 118 | files: dist/* 119 | env: 120 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 121 | 122 | publish-pypi: 123 | name: Publish to PyPI 124 | needs: [build, publish-github] 125 | runs-on: ubuntu-latest 126 | permissions: 127 | id-token: write 128 | steps: 129 | - name: Download distribution artifacts 130 | uses: actions/download-artifact@v4 131 | with: 132 | name: python-package-distributions 133 | path: dist/ 134 | 135 | - name: Set up Python 136 | uses: actions/setup-python@v5 137 | with: 138 | python-version: '3.13' 139 | 140 | - name: Install publishing dependencies 141 | run: | 142 | python -m pip install --upgrade pip 143 | pip install twine 144 | 145 | - name: Publish to PyPI 146 | env: 147 | TWINE_USERNAME: __token__ 148 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 149 | run: | 150 | twine upload --skip-existing dist/* -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.13' 23 | 24 | - name: Install dependencies 25 | run: | 26 | make install-test 27 | 28 | - name: Run tests 29 | run: make test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #--------------------------------------------------# 2 | # The following was generated with gitignore.nvim: # 3 | #--------------------------------------------------# 4 | # Gitignore for the following technologies: Python 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # poetry 103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 107 | #poetry.lock 108 | 109 | # pdm 110 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 111 | #pdm.lock 112 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 113 | # in version control. 114 | # https://pdm.fming.dev/#use-with-ide 115 | .pdm.toml 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # LSP config files 174 | pyrightconfig.json 175 | 176 | .llm-context 177 | 178 | KODING.md 179 | ClaudeCode.md 180 | uv.lock 181 | run*test.sh 182 | 183 | *aider* 184 | CLAUDE.md 185 | 186 | claude-code-doc 187 | replace.sh 188 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 SDGLBL 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install test lint clean dev venv 2 | 3 | # Virtual environment detection and activation 4 | VENV_NAME ?= .venv 5 | 6 | # Detect OS for proper path handling 7 | ifeq ($(OS),Windows_NT) 8 | VENV_ACTIVATE = $(VENV_NAME)\Scripts\activate 9 | VENV_TEST = $(VENV_NAME)\Scripts\pytest.exe 10 | VENV_PYTHON = $(VENV_NAME)\Scripts\python.exe 11 | RM_CMD = rmdir /s /q 12 | CP = copy 13 | SEP = \\ 14 | ACTIVATE_CMD = call 15 | else 16 | VENV_ACTIVATE = $(VENV_NAME)/bin/activate 17 | VENV_TEST = $(VENV_NAME)/bin/pytest 18 | VENV_PYTHON = $(VENV_NAME)/bin/python 19 | RM_CMD = rm -rf 20 | CP = cp 21 | SEP = / 22 | ACTIVATE_CMD = . 23 | endif 24 | 25 | # Python interpreter 26 | PYTHON = python 27 | # Path to package manager (uv or pip) 28 | # Check if uv is available, otherwise use plain pip 29 | UV := $(shell command -v uv 2> /dev/null) 30 | ifeq ($(UV),) 31 | PACKAGE_CMD = pip install 32 | else 33 | PACKAGE_CMD = $(UV) pip install 34 | endif 35 | 36 | # Project paths 37 | SRC_DIR = mcp_claude_code 38 | TEST_DIR = tests 39 | 40 | # Create virtual environment 41 | venv: 42 | $(PYTHON) -m venv $(VENV_NAME) 43 | ifeq ($(OS),Windows_NT) 44 | @echo "Virtual environment created. Run '$(VENV_ACTIVATE)' to activate it." 45 | else 46 | @echo "Virtual environment created. Run 'source $(VENV_ACTIVATE)' to activate it." 47 | endif 48 | 49 | install: venv-check 50 | $(ACTIVATE_CMD) $(VENV_ACTIVATE) && $(PACKAGE_CMD) -e "." 51 | 52 | uninstall: venv-check 53 | $(ACTIVATE_CMD) $(VENV_ACTIVATE) && $(PYTHON) -m pip uninstall -y mcp-claude-code 54 | 55 | reinstall: uninstall install 56 | 57 | install-dev: venv-check 58 | $(ACTIVATE_CMD) $(VENV_ACTIVATE) && $(PACKAGE_CMD) -e ".[dev]" 59 | 60 | install-test: venv-check 61 | $(ACTIVATE_CMD) $(VENV_ACTIVATE) && $(PACKAGE_CMD) -e ".[test]" 62 | 63 | test: venv-check 64 | $(ACTIVATE_CMD) $(VENV_ACTIVATE) && python -m pytest $(TEST_DIR) --disable-warnings 65 | 66 | test-cov: venv-check 67 | $(ACTIVATE_CMD) $(VENV_ACTIVATE) && python -m pytest --cov=$(SRC_DIR) $(TEST_DIR) 68 | 69 | lint: venv-check 70 | $(ACTIVATE_CMD) $(VENV_ACTIVATE) && ruff check $(SRC_DIR) $(TEST_DIR) 71 | 72 | format: venv-check 73 | $(ACTIVATE_CMD) $(VENV_ACTIVATE) && ruff format $(SRC_DIR) $(TEST_DIR) 74 | 75 | clean: 76 | $(RM_CMD) .pytest_cache htmlcov .coverage 2>nul || true 77 | ifeq ($(OS),Windows_NT) 78 | for /d /r . %d in (__pycache__) do @if exist "%d" rd /s /q "%d" 79 | else 80 | find . -name "__pycache__" -type d -exec rm -rf {} + 81 | endif 82 | 83 | # Helper to check for virtual environment 84 | venv-check: 85 | @if [ ! -f $(VENV_ACTIVATE) ]; then \ 86 | echo "Virtual environment not found. Creating one..." ; \ 87 | $(MAKE) venv ; \ 88 | fi 89 | 90 | # Default target 91 | all: test 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Claude Code 2 | 3 | An implementation of Claude Code capabilities using the Model Context Protocol (MCP). 4 | 5 | ## Overview 6 | 7 | This project provides an MCP server that implements Claude Code-like functionality, allowing Claude to directly execute instructions for modifying and improving project files. By leveraging the Model Context Protocol, this implementation enables seamless integration with various MCP clients including Claude Desktop. 8 | 9 | ![example](./doc/example2.gif) 10 | 11 | ## Features 12 | 13 | - **Code Understanding**: Analyze and understand codebases through file access and pattern searching 14 | - **Code Modification**: Make targeted edits to files with proper permission handling 15 | - **Enhanced Command Execution**: Run commands and scripts in various languages with improved error handling and shell support 16 | - **File Operations**: Manage files with proper security controls through shell commands 17 | - **Code Discovery**: Find relevant files and code patterns across your project with high-performance searching 18 | - **Agent Delegation**: Delegate complex tasks to specialized sub-agents that can work concurrently 19 | - **Multiple LLM Provider Support**: Configure any LiteLLM-compatible model for agent operations 20 | - **Jupyter Notebook Support**: Read and edit Jupyter notebooks with full cell and output handling 21 | 22 | ## Tools Implemented 23 | 24 | | Tool | Description | 25 | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------- | 26 | | `read` | Read file contents with line numbers, offset, and limit capabilities | 27 | | `write` | Create or overwrite files | 28 | | `edit` | Make line-based edits to text files | 29 | | `multi_edit` | Make multiple precise text replacements in a single file operation with atomic transactions | 30 | | `directory_tree` | Get a recursive tree view of directories | 31 | | `grep` | Fast pattern search in files with ripgrep integration for best performance ([docs](./doc/migration_SearchContentTool_to_Grep.md)) | 32 | | `content_replace` | Replace patterns in file contents | 33 | | `grep_ast` | Search code with AST context showing matches within functions, classes, and other structures | 34 | | `run_command` | Execute shell commands (also used for directory creation, file moving, and directory listing) | 35 | | `notebook_read` | Extract and read source code from all cells in a Jupyter notebook with outputs | 36 | | `notebook_edit` | Edit, insert, or delete cells in a Jupyter notebook | 37 | | `think` | Structured space for complex reasoning and analysis without making changes | 38 | | `dispatch_agent` | Launch one or more agents that can perform tasks using read-only tools concurrently | 39 | | `batch` | Execute multiple tool invocations in parallel or serially in a single request | 40 | | `todo_write` | Create and manage a structured task list | 41 | | `todo_read` | Read a structured task list | 42 | 43 | ## Getting Started 44 | 45 | For detailed installation and configuration instructions, please refer to [INSTALL.md](./doc/INSTALL.md). 46 | 47 | For detailed tutorial of 0.3 version, please refer to [TUTORIAL.md](./doc/TUTORIAL.md) 48 | 49 | ## Security 50 | 51 | This implementation follows best practices for securing access to your filesystem: 52 | 53 | - Permission prompts for file modifications and command execution 54 | - Restricted access to specified directories only 55 | - Input validation and sanitization 56 | - Proper error handling and reporting 57 | 58 | ## Development 59 | 60 | To contribute to this project: 61 | 62 | 1. Fork the repository 63 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 64 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 65 | 4. Push to the branch (`git push origin feature/amazing-feature`) 66 | 5. Open a Pull Request 67 | 68 | ## License 69 | 70 | This project is licensed under the MIT License - see the LICENSE file for details. 71 | -------------------------------------------------------------------------------- /doc/TUTORIAL.md: -------------------------------------------------------------------------------- 1 | # Tutorial: Advanced Prompting and Workflow Management 2 | 3 | Welcome to the advanced tutorial for MCP-Claude-Code! This guide will walk you through the new features I've designed to enhance your interaction with Claude 4, focusing on automated prompt generation, project-specific system prompts, and cross-conversational task continuity. 4 | 5 | ## 1. Introduction: Leveraging Claude 4's Power 6 | 7 | The recent updates to this project are built upon the advanced capabilities of Claude 4. To fully harness its potential, I've re-engineered my prompting strategies and tool integrations. This tutorial will show you how to: 8 | 9 | - Use dynamically applied system prompts tailored to your project directly from the Claude Desktop UI. 10 | - Employ automated commands to manage conversation context and TODO lists, also accessible via the UI. 11 | - Seamlessly continue tasks across different chat sessions. 12 | 13 | The core idea is to make your interaction with Claude more efficient, context-aware, and aligned with your development workflow. 14 | 15 | ## 2. Essential Setup: Applying Project-Specific System Prompts via UI 16 | 17 | The previous method of manually configuring a `doc/system_prompt` file is now deprecated. I've introduced a more dynamic and effective way to provide Claude with the necessary project context at the beginning of each conversation, directly through the Claude Desktop interface. 18 | 19 | **Why Project-Specific System Prompts?** 20 | A tailored system prompt gives Claude a better understanding of your project's structure, coding conventions, goals, and any specific constraints. This leads to more relevant and accurate responses. 21 | 22 | **How to Apply (Using Claude Desktop UI):** 23 | 24 | 1. **Select Your Project:** In Claude Desktop, choose the project you'll be working on (e.g., `mcp-claude-code`). 25 | 2. **Access Registered Prompts:** 26 | - Once inside your project's chat window, click the **`+`** button (often found near the message input area). 27 | - From the menu that appears, select the option corresponding to this tool suite (e.g., **"Add from claude-code"** if your MCP server is named `claude-code`). 28 | 3. **Apply the System Prompt:** 29 | - A submenu will display the available automated prompts I've registered. 30 | - Choose the **"System prompt for [your-project-name]"** option (e.g., "System prompt for mcp-claude-code"). 31 | - Selecting this will automatically apply the tailored system prompt for your current project to the conversation. 32 | 33 | **Best Practice:** Always start new project-related conversations by applying the project-specific system prompt using this UI method. This ensures Claude starts with the optimal context. 34 | 35 | ## 3. Automated In-Conversation Prompts & Commands (via UI) 36 | 37 | The same UI mechanism used for applying system prompts also provides access to several automated commands. These commands trigger specific actions related to context management and task tracking. 38 | 39 | To use them: 40 | 41 | 1. Click the **`+`** button in your project chat. 42 | 2. Select **"Add from claude-code"** (or your server's name). 43 | 3. Choose the desired automated command from the submenu. 44 | 45 | Here are the commands I've made available: 46 | 47 | ### 3.1. Compress Current Context 48 | 49 | - **Purpose:** When a conversation becomes very long, the context window can fill up. This command helps condense the existing conversation while retaining key information. 50 | - **How to Use (UI):** Select "Compact current conversation" from the submenu. 51 | - **Benefit:** Allows for longer, more focused interactions. 52 | 53 | ### 3.2. Continue Latest TODO 54 | 55 | - **Purpose:** This feature is crucial for task continuity. If a session ends or is interrupted before all tasks are completed, this command allows you to quickly pick up where you left off in a new session. 56 | - **How to Use (UI):** Select "Continue latest todo" from the submenu. 57 | - **Benefit:** Ensures that no tasks are forgotten and allows for seamless continuation of work. 58 | 59 | ### 3.3. Continue TODO by Session ID 60 | 61 | - **Purpose:** If you need to refer back to or continue tasks from a specific, known past session, and not just the most recent one. 62 | - **How to Use (UI):** Select "Continue todo by session id" from the submenu. Claude will likely then ask for the specific session ID. 63 | - **Benefit:** Provides granular control over accessing historical task lists. The `session_id` is a timestamp in seconds. 64 | 65 | ### 3.4. Create a New Release 66 | 67 | - **Purpose:** To assist in automating parts of the software release process, such as drafting release notes. 68 | - **How to Use (UI):** Select "Create a new release" from the submenu. 69 | - **Benefit:** Saves time and helps maintain consistency in your release documentation. 70 | 71 | ### 3.5. System Prompt for [Project] (Recap) 72 | 73 | - **Purpose:** As covered in Section 2, this applies the tailored system prompt for your project. 74 | - **How to Use (UI):** Select "System prompt for [your-project-name]" from the submenu. 75 | - **Benefit:** Ensures Claude starts with the best possible understanding of your project. 76 | 77 | ## 4. Workflow Scenarios: Putting It All Together 78 | 79 | Let's look at how these UI-driven features can be combined. 80 | 81 | ### Scenario A: Starting a New Task in a Project 82 | 83 | 1. In Claude Desktop, open your project (e.g., `mcp-claude-code`). 84 | 2. Click `+` -> "Add from claude-code" -> "System prompt for mcp-claude-code" to apply the system prompt. 85 | 3. **You (typing in chat):** "I need to implement a new feature: user authentication. Let's break this down into TODOs." 86 | 4. _(Work proceeds. As you define tasks, you can use the `todo_write` tool, or Claude might use it based on your discussion, to record them. The `CHANGELOG.md` mentions `todo_write` and `todo_read` tools )_ 87 | 88 | ### Scenario B: Continuing an Interrupted Task in a New Conversation 89 | 90 | _You had to stop working yesterday, and some TODOs were left unfinished._ 91 | 92 | 1. Open your project in Claude Desktop. 93 | 2. Apply the project-specific system prompt: Click `+` -> "Add from claude-code" -> "System prompt for mcp-claude-code". 94 | 3. Load unfinished tasks: Click `+` -> "Add from claude-code" -> "Continue latest todo". 95 | 4. _(Claude retrieves and displays the pending tasks from the previous session.)_ 96 | 5. **You (typing in chat):** "Okay, let's continue with the first item: 'Implement password hashing'." 97 | 98 | ### Scenario C: Managing a Very Long Conversation 99 | 100 | _You've been discussing a complex module for several hours._ 101 | 102 | 1. If the context feels too large, compress it: Click `+` -> "Add from claude-code" -> "Compact current conversation". 103 | 2. _(Claude processes and provides a summary or a compressed representation. )_ 104 | 3. **You (typing in chat):** "Great, now based on this, let's refine the API for the `OrderProcessor` module." 105 | 106 | ### Scenario D: Creating Release Notes 107 | 108 | _You've completed a sprint and are ready to release a new version._ 109 | 110 | 1. Open your project in Claude Desktop. 111 | 2. Apply the project-specific system prompt. 112 | 3. Initiate release creation: Click `+` -> "Add from claude-code" -> "Create a new release". 113 | 4. _(Claude might ask for specific commits or use completed TODOs from the recent sessions to draft the notes, based on the logic I implemented in `create_release.py` )_ 114 | 115 | ## 5. Understanding TODO Management Integration 116 | 117 | The automated prompt features, especially those related to TODO lists ("Continue latest todo," "Continue todo by session id"), are tightly integrated with the project's TODO management tools (`todo_write` and `todo_read`). 118 | 119 | - When you discuss tasks, create plans, or outline steps with Claude, these can be saved as TODO items associated with your current session using the `todo_write` tool (often invoked by Claude based on the conversation). 120 | - The UI commands for retrieving TODOs use `todo_read` to fetch these stored tasks. 121 | - This system ensures that tasks are tracked persistently and can be easily retrieved, even if your conversation with Claude is interrupted or spans multiple sessions. The underlying storage uses session IDs to keep things organized. 122 | 123 | ## 6. Conclusion 124 | 125 | By utilizing the project-specific system prompts and the suite of automated commands—all accessible through the Claude Desktop UI's `+` menu—you can significantly improve your productivity and the quality of interactions with Claude 4. I've designed these tools to make Claude a more intuitive and powerful assistant in your development lifecycle. 126 | 127 | Experiment with these features, and adapt them to your personal workflow. Happy coding! 128 | -------------------------------------------------------------------------------- /doc/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SDGLBL/mcp-claude-code/98a95f9efd77ccdd5fd8b7bc56cb7cf666a50504/doc/example.gif -------------------------------------------------------------------------------- /doc/example2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SDGLBL/mcp-claude-code/98a95f9efd77ccdd5fd8b7bc56cb7cf666a50504/doc/example2.gif -------------------------------------------------------------------------------- /mcp_claude_code/__init__.py: -------------------------------------------------------------------------------- 1 | """MCP Claude Code - Implementation of Claude Code capabilities using MCP.""" 2 | 3 | __version__ = "0.4.0" 4 | -------------------------------------------------------------------------------- /mcp_claude_code/prompts/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fastmcp import FastMCP 4 | 5 | from mcp_claude_code.prompts.compact_conversation import COMPACT_CONVERSATION_PROMPT 6 | from mcp_claude_code.prompts.create_release import CREATE_RELEASE_PROMPT 7 | from mcp_claude_code.prompts.project_system import PROJECT_SYSTEM_PROMPT 8 | from mcp_claude_code.prompts.project_todo_reminder import ( 9 | PROJECT_TODO_EMPTY_REMINDER, 10 | get_project_todo_reminder, 11 | ) 12 | from mcp_claude_code.prompts.utils import ( 13 | get_directory_structure, 14 | get_git_info, 15 | get_os_info, 16 | ) 17 | 18 | CONTINUE_FROM_LAST_SESSION_PROMPT = """ 19 | This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user. 20 | 21 | """ 22 | 23 | 24 | def create_project_system_prompt(project_path: str): 25 | """Factory function to create a project system prompt function.""" 26 | 27 | def project_system_prompt() -> str: 28 | """ 29 | Summarize the conversation so far for a specific project. 30 | """ 31 | working_directory = project_path 32 | is_git_repo = os.path.isdir(os.path.join(working_directory, ".git")) 33 | platform, _, os_version = get_os_info() 34 | 35 | # Get directory structure 36 | directory_structure = get_directory_structure( 37 | working_directory, max_depth=3, include_filtered=False 38 | ) 39 | 40 | # Get git information 41 | git_info = get_git_info(working_directory) 42 | current_branch = git_info.get("current_branch", "") 43 | main_branch = git_info.get("main_branch", "") 44 | git_status = git_info.get("git_status", "") 45 | recent_commits = git_info.get("recent_commits", "") 46 | 47 | return PROJECT_SYSTEM_PROMPT.format( 48 | working_directory=working_directory, 49 | is_git_repo=is_git_repo, 50 | platform=platform, 51 | os_version=os_version, 52 | directory_structure=directory_structure, 53 | current_branch=current_branch, 54 | main_branch=main_branch, 55 | git_status=git_status, 56 | recent_commits=recent_commits, 57 | ) 58 | 59 | return project_system_prompt 60 | 61 | 62 | def register_all_prompts( 63 | mcp_server: FastMCP, projects: list[str] | None = None 64 | ) -> None: 65 | @mcp_server.prompt(name="Compact current conversation") 66 | def compact() -> str: 67 | """ 68 | Summarize the conversation so far. 69 | """ 70 | return COMPACT_CONVERSATION_PROMPT 71 | 72 | @mcp_server.prompt(name="Create a new release") 73 | def create_release() -> str: 74 | """ 75 | Create a new release for my project. 76 | """ 77 | return CREATE_RELEASE_PROMPT 78 | 79 | @mcp_server.prompt(name="Continue todo by session id") 80 | def continue_todo_by_session_id(session_id: str) -> str: 81 | """ 82 | Continue from the last todo list for the current session. 83 | """ 84 | return get_project_todo_reminder(session_id) 85 | 86 | @mcp_server.prompt(name="Continue latest todo") 87 | def continue_latest_todo() -> str: 88 | """ 89 | Continue from the last todo list for the current session. 90 | """ 91 | return get_project_todo_reminder() 92 | 93 | @mcp_server.prompt(name="System prompt") 94 | def manual_project_system_prompt(project_path: str) -> str: 95 | """ 96 | Detailed system prompt include env,git etc information about the specified project. 97 | """ 98 | return create_project_system_prompt(project_path)() 99 | 100 | if projects is None: 101 | return 102 | 103 | for project in projects: 104 | # Register the prompt with the factory function 105 | mcp_server.prompt( 106 | name=f"System prompt for {os.path.basename(project)}", 107 | description=f"Detailed system prompt include env,git etc information about {project}", 108 | )(create_project_system_prompt(project)) 109 | 110 | return 111 | 112 | 113 | __all__ = [ 114 | "register_all_prompts", 115 | "get_project_todo_reminder", 116 | "PROJECT_TODO_EMPTY_REMINDER", 117 | ] 118 | -------------------------------------------------------------------------------- /mcp_claude_code/prompts/compact_conversation.py: -------------------------------------------------------------------------------- 1 | COMPACT_CONVERSATION_PROMPT = """Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. 2 | This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. 3 | 4 | Before providing your final summary, wrap your analysis in tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process: 5 | 6 | 1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify: 7 | - The user's explicit requests and intents 8 | - Your approach to addressing the user's requests 9 | - Key decisions, technical concepts and code patterns 10 | - Specific details like file names, full code snippets, function signatures, file edits, etc 11 | 2. Double-check for technical accuracy and completeness, addressing each required element thoroughly. 12 | 13 | Your summary should include the following sections: 14 | 15 | 1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail 16 | 2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed. 17 | 3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important. 18 | 4. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. 19 | 5. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on. 20 | 6. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. 21 | 7. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests without confirming with the user first. 22 | If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation. 23 | 24 | Here's an example of how your output should be structured: 25 | 26 | 27 | 28 | [Your thought process, ensuring all points are covered thoroughly and accurately] 29 | 30 | 31 | 32 | 1. Primary Request and Intent: 33 | [Detailed description] 34 | 35 | 2. Key Technical Concepts: 36 | - [Concept 1] 37 | - [Concept 2] 38 | - [...] 39 | 40 | 3. Files and Code Sections: 41 | - [File Name 1] 42 | - [Summary of why this file is important] 43 | - [Summary of the changes made to this file, if any] 44 | - [Important Code Snippet] 45 | - [File Name 2] 46 | - [Important Code Snippet] 47 | - [...] 48 | 49 | 4. Problem Solving: 50 | [Description of solved problems and ongoing troubleshooting] 51 | 52 | 5. Pending Tasks: 53 | - [Task 1] 54 | - [Task 2] 55 | - [...] 56 | 57 | 6. Current Work: 58 | [Precise description of current work] 59 | 60 | 7. Optional Next Step: 61 | [Optional Next step to take] 62 | 63 | 64 | 65 | 66 | Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. 67 | 68 | There may be additional summarization instructions provided in the included context. If so, remember to follow these instructions when creating the above summary. Examples of instructions include: 69 | 70 | ## Compact Instructions 71 | When summarizing the conversation focus on typescript code changes and also remember the mistakes you made and how you fixed them. 72 | 73 | 74 | 75 | # Summary instructions 76 | When you are using compact - please focus on test output and code changes. Include file reads verbatim. 77 | """ 78 | -------------------------------------------------------------------------------- /mcp_claude_code/prompts/create_release.py: -------------------------------------------------------------------------------- 1 | CREATE_RELEASE_PROMPT = """Help me create a new release for my project. Follow these steps: 2 | 3 | ## Initial Analysis 4 | 1. Examine the project version files (typically `__init__.py`, `package.json`, `pyproject.toml`, etc.) 5 | 2. Review the current CHANGELOG.md format and previous releases 6 | 3. Check the release workflow configuration (GitHub Actions, CI/CD pipelines) 7 | 4. Review commits since the last release tag: 8 | ```bash 9 | git log ..HEAD --pretty=format:"%h %s%n%b" --name-status 10 | ``` 11 | 12 | ## Version Update 13 | 1. Identify all files containing version numbers 14 | 2. Update version numbers consistently across all files 15 | 3. Follow semantic versioning guidelines (MAJOR.MINOR.PATCH) 16 | 17 | ## Changelog Creation 18 | 1. Add a new section at the top of CHANGELOG.md with the new version and today's date 19 | 2. Group changes by type: Added, Changed, Fixed, Removed, etc. 20 | 3. Include commit hashes in parentheses for reference 21 | 4. Write clear, detailed descriptions for each change 22 | 5. Follow established project conventions for changelog format 23 | 24 | ## Release Commit and Tag 25 | 1. Commit the version and changelog updates: 26 | ```bash 27 | git add 28 | git commit -m "chore: bump version to X.Y.Z" 29 | ``` 30 | 2. Create an annotated tag: 31 | ```bash 32 | git tag -a "vX.Y.Z" -m "Release vX.Y.Z" 33 | ``` 34 | 3. Push the changes and tag: 35 | ```bash 36 | git push origin main 37 | git push origin vX.Y.Z 38 | ```""" 39 | -------------------------------------------------------------------------------- /mcp_claude_code/prompts/project_system.py: -------------------------------------------------------------------------------- 1 | PROJECT_SYSTEM_PROMPT = """Your are assisting me with a project. 2 | 3 | Here is useful information about the environment you are running in: 4 | 5 | 6 | Working directory: {working_directory} (You need cd to this directory by yourself) 7 | Is directory a git repo: {is_git_repo} 8 | Platform: {platform} 9 | OS Version: {os_version} 10 | 11 | 12 | 13 | directoryStructure: Below is a snapshot of this project's file structure at the start of the conversation. This snapshot will NOT update during the conversation. It skips over .gitignore patterns. 14 | 15 | {directory_structure} 16 | 17 | gitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation. 18 | 19 | Current branch: {current_branch} 20 | 21 | Main branch (you will usually use this for PRs): {main_branch} 22 | 23 | Status: 24 | 25 | {git_status} 26 | 27 | Recent commits: 28 | 29 | {recent_commits} 30 | 31 | 32 | 33 | 34 | IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the conversation. 35 | 36 | # Code References 37 | When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow me to easily navigate to the source code location. 38 | 39 | 40 | user: Where are errors from the client handled? 41 | assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. 42 | 43 | 44 | Do what has been asked; nothing more, nothing less. 45 | ALWAYS prefer editing an existing file to creating a new one. 46 | NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. 47 | 48 | # Proactiveness 49 | You are allowed to be proactive, but only when I ask you to do something. You should strive to strike a balance between: 50 | 1. Doing the right thing when asked, including taking actions and follow-up actions 51 | 2. Not surprising me with actions you take without asking 52 | For example, if I ask you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions. 53 | 3. Do not add additional code explanation summary unless requested by me. After working on a file, just stop, rather than providing an explanation of what you did. 54 | 55 | # Following conventions 56 | When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns. 57 | - NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language). 58 | - When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions. 59 | - When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic. 60 | - Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository. 61 | 62 | 63 | 64 | 65 | # Task Management 66 | You have access to the todo_write and todo_read tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving me visibility into your progress. 67 | These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. 68 | 69 | It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. 70 | 71 | Examples: 72 | 73 | user: Run the build and fix any type errors 74 | assistant: I'm going to use the todo_write tool to write the following items to the todo list: 75 | - Run the build 76 | - Fix any type errors 77 | 78 | I'm now going to run the build using Bash. 79 | 80 | Looks like I found 10 type errors. I'm going to use the todo_write tool to write 10 items to the todo list. 81 | 82 | marking the first todo as in_progress 83 | 84 | Let me start working on the first item... 85 | 86 | The first item has been fixed, let me mark the first todo as completed, and move on to the second item... 87 | 88 | 89 | In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. 90 | 91 | 92 | user: Help me write a new feature that allows users to track their usage metrics and export them to various formats 93 | 94 | assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the todo_write tool to plan this task. 95 | Adding the following todos to the todo list: 96 | 1. Research existing metrics tracking in the codebase 97 | 2. Design the metrics collection system 98 | 3. Implement core metrics tracking functionality 99 | 4. Create export functionality for different formats 100 | 101 | Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. 102 | 103 | I'm going to search for any existing metrics or telemetry code in the project. 104 | 105 | I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... 106 | 107 | [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] 108 | 109 | 110 | # Doing tasks 111 | I will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: 112 | - Use the todo_write tool to plan the task if required 113 | - Use the available search tools to understand the codebase and my query. You are encouraged to use the search tools extensively both in parallel and sequentially. 114 | - Implement the solution using all tools available to you 115 | - Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. 116 | - VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask me for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time. 117 | NEVER commit changes unless I explicitly ask you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise I will feel that you are being too proactive. 118 | 119 | - Tool results and user messages may include tags. tags contain useful information and reminders. 120 | """ 121 | -------------------------------------------------------------------------------- /mcp_claude_code/prompts/project_todo_reminder.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | # Import TodoStorage to access todo data 4 | from mcp_claude_code.tools.todo.base import TodoStorage 5 | 6 | 7 | PROJECT_TODO_EMPTY_REMINDER = """This is a reminder that your todo list is currently empty. DO NOT mention this to me explicitly because i have already aware. If you are working on tasks that would benefit from a todo list please use the todo_write tool to create one. If not, please feel free to ignore.""" 8 | 9 | 10 | PROJECT_TODO_REMINDER = """ 11 | This is a reminder that you have a to-do list for this project. The to-do list session ID is: {session_id}. You can use the todo_write tool to add new to-dos to the list. 12 | 13 | The to-do list is shown below, so you do not need to read it using the todo_read tool before your next time using the todo_write tool: 14 | 15 | {todo_list} 16 | 17 | """ 18 | 19 | 20 | def format_todo_list_concise(todos: list[dict[str, Any]]) -> str: 21 | """Format a todo list in a concise format for inclusion in prompts. 22 | 23 | Args: 24 | todos: List of todo items 25 | 26 | Returns: 27 | Formatted string representation of the todo list 28 | """ 29 | if not todos: 30 | return "No todos found." 31 | 32 | formatted_lines = [] 33 | for todo in todos: 34 | status = todo.get("status", "unknown") 35 | priority = todo.get("priority", "medium") 36 | content = todo.get("content", "No content") 37 | todo_id = todo.get("id", "no-id") 38 | 39 | # Handle empty strings as well as missing values 40 | if not content or not str(content).strip(): 41 | content = "No content" 42 | if not todo_id or not str(todo_id).strip(): 43 | todo_id = "no-id" 44 | 45 | # Create status indicator 46 | status_indicator = { 47 | "pending": "[ ]", 48 | "in_progress": "[~]", 49 | "completed": "[✓]", 50 | }.get(status, "[?]") 51 | 52 | # Create priority indicator 53 | priority_indicator = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get( 54 | priority, "⚪" 55 | ) 56 | 57 | formatted_lines.append( 58 | f"{status_indicator} {priority_indicator} {content} (id: {todo_id})" 59 | ) 60 | 61 | return "\n".join(formatted_lines) 62 | 63 | 64 | def has_unfinished_todos(todos: list[dict[str, Any]]) -> bool: 65 | """Check if there are any unfinished todos in the list. 66 | 67 | Args: 68 | todos: List of todo items 69 | 70 | Returns: 71 | True if there are unfinished todos, False otherwise 72 | """ 73 | if not todos: 74 | return False 75 | 76 | for todo in todos: 77 | status = todo.get("status", "pending") 78 | if status in ["pending", "in_progress"]: 79 | return True 80 | 81 | return False 82 | 83 | 84 | def get_project_todo_reminder(session_id: str | None = None) -> str: 85 | """Get the appropriate todo reminder for a session. 86 | 87 | Args: 88 | session_id: Session ID to check todos for. If None, finds the latest active session. 89 | 90 | Returns: 91 | Either PROJECT_TODO_EMPTY_REMINDER or PROJECT_TODO_REMINDER with formatted content 92 | """ 93 | # If no session_id provided, try to find the latest active session 94 | if session_id is None: 95 | session_id = TodoStorage.find_latest_active_session() 96 | if session_id is None: 97 | # No active sessions found 98 | return PROJECT_TODO_EMPTY_REMINDER 99 | 100 | # Get todos for the session 101 | todos = TodoStorage.get_todos(session_id) 102 | 103 | # Check if we have unfinished todos 104 | if not has_unfinished_todos(todos): 105 | return PROJECT_TODO_EMPTY_REMINDER 106 | 107 | # Format the todo list and return the reminder with content 108 | formatted_todos = format_todo_list_concise(todos) 109 | return PROJECT_TODO_REMINDER.format( 110 | session_id=session_id, todo_list=formatted_todos 111 | ) 112 | -------------------------------------------------------------------------------- /mcp_claude_code/server.py: -------------------------------------------------------------------------------- 1 | """MCP server implementing Claude Code capabilities.""" 2 | 3 | import atexit 4 | import signal 5 | import threading 6 | import time 7 | from typing import Literal, cast, final 8 | 9 | from fastmcp import FastMCP 10 | 11 | from mcp_claude_code.prompts import register_all_prompts 12 | from mcp_claude_code.tools import register_all_tools 13 | 14 | from mcp_claude_code.tools.common.permissions import PermissionManager 15 | from mcp_claude_code.tools.shell.session_storage import SessionStorage 16 | 17 | 18 | @final 19 | class ClaudeCodeServer: 20 | """MCP server implementing Claude Code capabilities.""" 21 | 22 | def __init__( 23 | self, 24 | name: str = "claude-code", 25 | allowed_paths: list[str] | None = None, 26 | project_paths: list[str] | None = None, 27 | mcp_instance: FastMCP | None = None, 28 | agent_model: str | None = None, 29 | agent_max_tokens: int | None = None, 30 | agent_api_key: str | None = None, 31 | agent_base_url: str | None = None, 32 | agent_max_iterations: int = 10, 33 | agent_max_tool_uses: int = 30, 34 | enable_agent_tool: bool = False, 35 | command_timeout: float = 120.0, 36 | ): 37 | """Initialize the Claude Code server. 38 | 39 | Args: 40 | name: The name of the server 41 | allowed_paths: list of paths that the server is allowed to access 42 | project_paths: list of project paths to generate prompts for 43 | mcp_instance: Optional FastMCP instance for testing 44 | agent_model: Optional model name for agent tool in LiteLLM format 45 | agent_max_tokens: Optional maximum tokens for agent responses 46 | agent_api_key: Optional API key for the LLM provider 47 | agent_base_url: Optional base URL for the LLM provider API endpoint 48 | agent_max_iterations: Maximum number of iterations for agent (default: 10) 49 | agent_max_tool_uses: Maximum number of total tool uses for agent (default: 30) 50 | enable_agent_tool: Whether to enable the agent tool (default: False) 51 | command_timeout: Default timeout for command execution in seconds (default: 120.0) 52 | """ 53 | self.mcp = mcp_instance if mcp_instance is not None else FastMCP(name) 54 | 55 | # Initialize permissions and command executor 56 | self.permission_manager = PermissionManager() 57 | 58 | # Add allowed paths 59 | if allowed_paths: 60 | for path in allowed_paths: 61 | self.permission_manager.add_allowed_path(path) 62 | 63 | # Store project paths 64 | self.project_paths = project_paths 65 | 66 | # Store agent options 67 | self.agent_model = agent_model 68 | self.agent_max_tokens = agent_max_tokens 69 | self.agent_api_key = agent_api_key 70 | self.agent_base_url = agent_base_url 71 | self.agent_max_iterations = agent_max_iterations 72 | self.agent_max_tool_uses = agent_max_tool_uses 73 | self.enable_agent_tool = enable_agent_tool 74 | self.command_timeout = command_timeout 75 | 76 | # Initialize cleanup tracking 77 | self._cleanup_thread: threading.Thread | None = None 78 | self._shutdown_event = threading.Event() 79 | self._cleanup_registered = False 80 | 81 | # Register all tools 82 | register_all_tools( 83 | mcp_server=self.mcp, 84 | permission_manager=self.permission_manager, 85 | agent_model=self.agent_model, 86 | agent_max_tokens=self.agent_max_tokens, 87 | agent_api_key=self.agent_api_key, 88 | agent_base_url=self.agent_base_url, 89 | agent_max_iterations=self.agent_max_iterations, 90 | agent_max_tool_uses=self.agent_max_tool_uses, 91 | enable_agent_tool=self.enable_agent_tool, 92 | ) 93 | 94 | register_all_prompts(mcp_server=self.mcp, projects=self.project_paths) 95 | 96 | def _setup_cleanup_handlers(self) -> None: 97 | """Set up signal handlers and background cleanup thread.""" 98 | if self._cleanup_registered: 99 | return 100 | 101 | # Register cleanup on normal exit 102 | atexit.register(self._cleanup_sessions) 103 | 104 | # Register signal handlers for graceful shutdown 105 | def signal_handler(signum, frame): 106 | self._cleanup_sessions() 107 | self._shutdown_event.set() 108 | 109 | signal.signal(signal.SIGTERM, signal_handler) 110 | signal.signal(signal.SIGINT, signal_handler) 111 | 112 | # Start background cleanup thread for periodic cleanup 113 | self._cleanup_thread = threading.Thread( 114 | target=self._background_cleanup, daemon=True 115 | ) 116 | self._cleanup_thread.start() 117 | 118 | self._cleanup_registered = True 119 | 120 | def _background_cleanup(self) -> None: 121 | """Background thread for periodic session cleanup.""" 122 | while not self._shutdown_event.is_set(): 123 | try: 124 | # Clean up expired sessions every 2 minutes 125 | # Using shorter TTL of 5 minutes (300 seconds) 126 | SessionStorage.cleanup_expired_sessions(max_age_seconds=300) 127 | 128 | # Wait for 2 minutes or until shutdown 129 | self._shutdown_event.wait(timeout=120) 130 | except Exception: 131 | # Ignore cleanup errors and continue 132 | pass 133 | 134 | def _cleanup_sessions(self) -> None: 135 | """Clean up all active sessions.""" 136 | try: 137 | cleared_count = SessionStorage.clear_all_sessions() 138 | if cleared_count > 0: 139 | print(f"Cleaned up {cleared_count} tmux sessions on shutdown") 140 | except Exception: 141 | # Ignore cleanup errors during shutdown 142 | pass 143 | 144 | def run(self, transport: str = "stdio", allowed_paths: list[str] | None = None): 145 | """Run the MCP server. 146 | 147 | Args: 148 | transport: The transport to use (stdio or sse) 149 | allowed_paths: list of paths that the server is allowed to access 150 | """ 151 | # Add allowed paths if provided 152 | allowed_paths_list = allowed_paths or [] 153 | for path in allowed_paths_list: 154 | self.permission_manager.add_allowed_path(path) 155 | 156 | # Set up cleanup handlers before running 157 | self._setup_cleanup_handlers() 158 | 159 | # Run the server 160 | transport_type = cast(Literal["stdio", "sse"], transport) 161 | self.mcp.run(transport=transport_type) 162 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools package for MCP Claude Code. 2 | 3 | This package contains all the tools for the MCP Claude Code server. 4 | It provides a unified interface for registering all tools with an MCP server. 5 | 6 | This includes a "think" tool implementation based on Anthropic's research showing 7 | improved performance for complex tool-based interactions when Claude has a dedicated 8 | space for structured thinking. It also includes an "agent" tool that enables Claude 9 | to delegate tasks to sub-agents for concurrent execution and specialized processing. 10 | """ 11 | 12 | from fastmcp import FastMCP 13 | 14 | from mcp_claude_code.tools.agent import register_agent_tools 15 | from mcp_claude_code.tools.common import register_batch_tool, register_thinking_tool 16 | from mcp_claude_code.tools.common.base import BaseTool 17 | 18 | from mcp_claude_code.tools.common.permissions import PermissionManager 19 | from mcp_claude_code.tools.filesystem import register_filesystem_tools 20 | from mcp_claude_code.tools.jupyter import register_jupyter_tools 21 | from mcp_claude_code.tools.shell import register_shell_tools 22 | from mcp_claude_code.tools.todo import register_todo_tools 23 | 24 | 25 | def register_all_tools( 26 | mcp_server: FastMCP, 27 | permission_manager: PermissionManager, 28 | agent_model: str | None = None, 29 | agent_max_tokens: int | None = None, 30 | agent_api_key: str | None = None, 31 | agent_base_url: str | None = None, 32 | agent_max_iterations: int = 10, 33 | agent_max_tool_uses: int = 30, 34 | enable_agent_tool: bool = False, 35 | ) -> None: 36 | """Register all Claude Code tools with the MCP server. 37 | 38 | Args: 39 | mcp_server: The FastMCP server instance 40 | permission_manager: Permission manager for access control 41 | agent_model: Optional model name for agent tool in LiteLLM format 42 | agent_max_tokens: Optional maximum tokens for agent responses 43 | agent_api_key: Optional API key for the LLM provider 44 | agent_base_url: Optional base URL for the LLM provider API endpoint 45 | agent_max_iterations: Maximum number of iterations for agent (default: 10) 46 | agent_max_tool_uses: Maximum number of total tool uses for agent (default: 30) 47 | enable_agent_tool: Whether to enable the agent tool (default: False) 48 | """ 49 | # Dictionary to store all registered tools 50 | all_tools: dict[str, BaseTool] = {} 51 | 52 | # Register all filesystem tools 53 | filesystem_tools = register_filesystem_tools(mcp_server, permission_manager) 54 | for tool in filesystem_tools: 55 | all_tools[tool.name] = tool 56 | 57 | # Register all jupyter tools 58 | jupyter_tools = register_jupyter_tools(mcp_server, permission_manager) 59 | for tool in jupyter_tools: 60 | all_tools[tool.name] = tool 61 | 62 | # Register shell tools 63 | shell_tools = register_shell_tools(mcp_server, permission_manager) 64 | for tool in shell_tools: 65 | all_tools[tool.name] = tool 66 | 67 | # Register agent tools only if enabled 68 | if enable_agent_tool: 69 | agent_tools = register_agent_tools( 70 | mcp_server, 71 | permission_manager, 72 | agent_model=agent_model, 73 | agent_max_tokens=agent_max_tokens, 74 | agent_api_key=agent_api_key, 75 | agent_base_url=agent_base_url, 76 | agent_max_iterations=agent_max_iterations, 77 | agent_max_tool_uses=agent_max_tool_uses, 78 | ) 79 | for tool in agent_tools: 80 | all_tools[tool.name] = tool 81 | 82 | # Register todo tools 83 | todo_tools = register_todo_tools(mcp_server) 84 | for tool in todo_tools: 85 | all_tools[tool.name] = tool 86 | 87 | # Initialize and register thinking tool 88 | thinking_tool = register_thinking_tool(mcp_server) 89 | for tool in thinking_tool: 90 | all_tools[tool.name] = tool 91 | 92 | # Register batch tool 93 | register_batch_tool(mcp_server, all_tools) 94 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/agent/__init__.py: -------------------------------------------------------------------------------- 1 | """Agent tools for MCP Claude Code. 2 | 3 | This module provides tools that allow Claude to delegate tasks to sub-agents, 4 | enabling concurrent execution of multiple operations and specialized processing. 5 | """ 6 | 7 | from fastmcp import FastMCP 8 | 9 | from mcp_claude_code.tools.agent.agent_tool import AgentTool 10 | from mcp_claude_code.tools.common.base import BaseTool, ToolRegistry 11 | 12 | from mcp_claude_code.tools.common.permissions import PermissionManager 13 | 14 | 15 | def register_agent_tools( 16 | mcp_server: FastMCP, 17 | permission_manager: PermissionManager, 18 | agent_model: str | None = None, 19 | agent_max_tokens: int | None = None, 20 | agent_api_key: str | None = None, 21 | agent_base_url: str | None = None, 22 | agent_max_iterations: int = 10, 23 | agent_max_tool_uses: int = 30, 24 | ) -> list[BaseTool]: 25 | """Register agent tools with the MCP server. 26 | 27 | Args: 28 | mcp_server: The FastMCP server instance 29 | 30 | permission_manager: Permission manager for access control 31 | agent_model: Optional model name for agent tool in LiteLLM format 32 | agent_max_tokens: Optional maximum tokens for agent responses 33 | agent_api_key: Optional API key for the LLM provider 34 | agent_base_url: Optional base URL for the LLM provider API endpoint 35 | agent_max_iterations: Maximum number of iterations for agent (default: 10) 36 | agent_max_tool_uses: Maximum number of total tool uses for agent (default: 30) 37 | 38 | Returns: 39 | List of registered tools 40 | """ 41 | # Create agent tool 42 | agent_tool = AgentTool( 43 | permission_manager=permission_manager, 44 | model=agent_model, 45 | api_key=agent_api_key, 46 | base_url=agent_base_url, 47 | max_tokens=agent_max_tokens, 48 | max_iterations=agent_max_iterations, 49 | max_tool_uses=agent_max_tool_uses, 50 | ) 51 | 52 | # Register agent tool 53 | ToolRegistry.register_tool(mcp_server, agent_tool) 54 | 55 | # Return list of registered tools 56 | return [agent_tool] 57 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/agent/prompt.py: -------------------------------------------------------------------------------- 1 | """Prompt generation utilities for agent tool. 2 | 3 | This module provides functions for generating effective prompts for sub-agents, 4 | including filtering tools based on permissions and formatting system instructions. 5 | """ 6 | 7 | import os 8 | from typing import Any 9 | 10 | from mcp_claude_code.tools.common.base import BaseTool 11 | from mcp_claude_code.tools.common.permissions import PermissionManager 12 | 13 | 14 | def get_allowed_agent_tools( 15 | tools: list[BaseTool], 16 | permission_manager: PermissionManager, 17 | ) -> list[BaseTool]: 18 | """Filter tools available to the agent based on permissions. 19 | 20 | Args: 21 | tools: List of available tools 22 | permission_manager: Permission manager for checking tool access 23 | 24 | Returns: 25 | Filtered list of tools available to the agent 26 | """ 27 | # Get all tools except for the agent tool itself (avoid recursion) 28 | filtered_tools = [tool for tool in tools if tool.name != "agent"] 29 | 30 | return filtered_tools 31 | 32 | 33 | def get_system_prompt( 34 | tools: list[BaseTool], 35 | permission_manager: PermissionManager, 36 | ) -> str: 37 | """Generate system prompt for the sub-agent. 38 | 39 | Args: 40 | tools: List of available tools 41 | permission_manager: Permission manager for checking tool access 42 | 43 | Returns: 44 | System prompt for the sub-agent 45 | """ 46 | # Get filtered tools 47 | filtered_tools = get_allowed_agent_tools(tools, permission_manager) 48 | 49 | # Extract tool names for display 50 | tool_names = ", ".join(f"`{tool.name}`" for tool in filtered_tools) 51 | 52 | # Base system prompt 53 | system_prompt = f"""You are a sub-agent assistant with access to these tools: {tool_names}. 54 | 55 | GUIDELINES: 56 | 1. You work autonomously - you cannot ask follow-up questions 57 | 2. You have access to read-only tools - you cannot modify files or execute commands 58 | 3. Your response is returned directly to the main assistant, not the user 59 | 4. Be concise and focus on the specific task assigned 60 | 5. When relevant, share file names and code snippets relevant to the query 61 | 6. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths. 62 | 7. CRITICAL: You can only work with the absolute paths provided in your task prompt. You cannot infer or guess other locations. 63 | 64 | RESPONSE FORMAT: 65 | - Begin with a summary of findings 66 | - Include relevant details and context 67 | - Organize information logically 68 | - End with clear conclusions 69 | """ 70 | 71 | return system_prompt 72 | 73 | 74 | def get_default_model(model_override: str | None = None) -> str: 75 | """Get the default model for agent execution. 76 | 77 | Args: 78 | model_override: Optional model override string in LiteLLM format (e.g., "openai/gpt-4o") 79 | 80 | Returns: 81 | Model identifier string with provider prefix 82 | """ 83 | # Use model override if provided 84 | if model_override: 85 | # If in testing mode and using a test model, return as-is 86 | if model_override.startswith("test-model") or "TEST_MODE" in os.environ: 87 | return model_override 88 | 89 | # If the model already has a provider prefix, return as-is 90 | if "/" in model_override: 91 | return model_override 92 | 93 | # Otherwise, add the default provider prefix 94 | provider = os.environ.get("AGENT_PROVIDER", "openai") 95 | return f"{provider}/{model_override}" 96 | 97 | # Fall back to environment variables 98 | model = os.environ.get("AGENT_MODEL", "gpt-4o") 99 | 100 | # Special cases for tests 101 | if ( 102 | model.startswith("test-model") 103 | or model == "gpt-4o" 104 | and "TEST_MODE" in os.environ 105 | ): 106 | return model 107 | 108 | provider = os.environ.get("AGENT_PROVIDER", "openai") 109 | 110 | # Only add provider prefix if it's not already in the model name 111 | if "/" not in model and provider != "openai": 112 | return f"{provider}/{model}" 113 | elif "/" not in model: 114 | return f"openai/{model}" 115 | else: 116 | # Model already has a provider prefix 117 | return model 118 | 119 | 120 | def get_model_parameters(max_tokens: int | None = None) -> dict[str, Any]: 121 | """Get model parameters from environment variables. 122 | 123 | Args: 124 | max_tokens: Optional maximum tokens parameter override 125 | 126 | Returns: 127 | Dictionary of model parameters 128 | """ 129 | params = { 130 | "temperature": float(os.environ.get("AGENT_TEMPERATURE", "0.7")), 131 | "timeout": int(os.environ.get("AGENT_API_TIMEOUT", "60")), 132 | } 133 | 134 | # Add max_tokens if provided or if set in environment variable 135 | if max_tokens is not None: 136 | params["max_tokens"] = max_tokens 137 | elif os.environ.get("AGENT_MAX_TOKENS"): 138 | params["max_tokens"] = int(os.environ.get("AGENT_MAX_TOKENS", "1000")) 139 | 140 | return params 141 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/agent/tool_adapter.py: -------------------------------------------------------------------------------- 1 | """Tool adapters for converting between MCP tools and OpenAI tools. 2 | 3 | This module handles conversion between MCP tool formats and OpenAI function 4 | formats, making MCP tools available to the OpenAI API, and processing tool inputs 5 | and outputs for agent execution. 6 | """ 7 | 8 | from openai.types import FunctionParameters 9 | from openai.types.chat import ChatCompletionToolParam 10 | import litellm 11 | 12 | from mcp_claude_code.tools.common.base import BaseTool 13 | 14 | 15 | def convert_tools_to_openai_functions( 16 | tools: list[BaseTool], 17 | ) -> list[ChatCompletionToolParam]: 18 | """Convert MCP tools to OpenAI function format. 19 | 20 | Args: 21 | tools: List of MCP tools 22 | 23 | Returns: 24 | List of tools formatted for OpenAI API 25 | """ 26 | openai_tools: list[ChatCompletionToolParam] = [] 27 | for tool in tools: 28 | openai_tool: ChatCompletionToolParam = { 29 | "type": "function", 30 | "function": { 31 | "name": tool.name, 32 | "description": tool.description, 33 | "parameters": convert_tool_parameters(tool), 34 | }, 35 | } 36 | openai_tools.append(openai_tool) 37 | return openai_tools 38 | 39 | 40 | def convert_tool_parameters(tool: BaseTool) -> FunctionParameters: 41 | """Convert tool parameters to OpenAI format. 42 | 43 | Args: 44 | tool: MCP tool 45 | 46 | Returns: 47 | Parameter schema in OpenAI format 48 | """ 49 | # Start with a copy of the parameters 50 | params = tool.parameters.copy() 51 | 52 | # Ensure the schema has the right format for OpenAI 53 | if "properties" not in params: 54 | params["properties"] = {} 55 | 56 | if "type" not in params: 57 | params["type"] = "object" 58 | 59 | if "required" not in params: 60 | params["required"] = tool.required 61 | 62 | return params 63 | 64 | 65 | def supports_parallel_function_calling(model: str) -> bool: 66 | """Check if a model supports parallel function calling. 67 | 68 | Args: 69 | model: Model identifier in LiteLLM format (e.g., "openai/gpt-4-turbo-preview") 70 | 71 | Returns: 72 | True if the model supports parallel function calling, False otherwise 73 | """ 74 | # Use litellm's built-in parallel function calling support check 75 | return litellm.supports_parallel_function_calling(model=model) 76 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/common/__init__.py: -------------------------------------------------------------------------------- 1 | """Common utilities for MCP Claude Code tools.""" 2 | 3 | from fastmcp import FastMCP 4 | 5 | from mcp_claude_code.tools.common.base import BaseTool, ToolRegistry 6 | from mcp_claude_code.tools.common.batch_tool import BatchTool 7 | from mcp_claude_code.tools.common.thinking_tool import ThinkingTool 8 | 9 | 10 | def register_thinking_tool( 11 | mcp_server: FastMCP, 12 | ) -> list[BaseTool]: 13 | """Register thinking tools with the MCP server. 14 | 15 | Args: 16 | mcp_server: The FastMCP server instance 17 | """ 18 | thinking_tool = ThinkingTool() 19 | ToolRegistry.register_tool(mcp_server, thinking_tool) 20 | return [thinking_tool] 21 | 22 | 23 | def register_batch_tool(mcp_server: FastMCP, tools: dict[str, BaseTool]) -> None: 24 | """Register batch tool with the MCP server. 25 | 26 | Args: 27 | mcp_server: The FastMCP server instance 28 | tools: Dictionary mapping tool names to tool instances 29 | """ 30 | batch_tool = BatchTool(tools) 31 | ToolRegistry.register_tool(mcp_server, batch_tool) 32 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/common/base.py: -------------------------------------------------------------------------------- 1 | """Base classes for MCP Claude Code tools. 2 | 3 | This module provides abstract base classes that define interfaces and common functionality 4 | for all tools used in MCP Claude Code. These abstractions help ensure consistent tool 5 | behavior and provide a foundation for tool registration and management. 6 | """ 7 | 8 | import functools 9 | from abc import ABC, abstractmethod 10 | from collections.abc import Awaitable 11 | from typing import Any, Callable, final 12 | 13 | from fastmcp import FastMCP 14 | from fastmcp import Context as MCPContext 15 | 16 | 17 | from mcp_claude_code.tools.common.permissions import PermissionManager 18 | from mcp_claude_code.tools.common.validation import ( 19 | ValidationResult, 20 | validate_path_parameter, 21 | ) 22 | 23 | 24 | def handle_connection_errors( 25 | func: Callable[..., Awaitable[str]], 26 | ) -> Callable[..., Awaitable[str]]: 27 | """Decorator to handle connection errors in MCP tool functions. 28 | 29 | This decorator wraps tool functions to catch ClosedResourceError and other 30 | connection-related exceptions that occur when the client disconnects. 31 | 32 | Args: 33 | func: The async tool function to wrap 34 | 35 | Returns: 36 | Wrapped function that handles connection errors gracefully 37 | """ 38 | 39 | @functools.wraps(func) 40 | async def wrapper(*args: Any, **kwargs: Any) -> str: 41 | try: 42 | return await func(*args, **kwargs) 43 | except Exception as e: 44 | # Check if this is a connection-related error 45 | error_name = type(e).__name__ 46 | if any( 47 | name in error_name 48 | for name in [ 49 | "ClosedResourceError", 50 | "ConnectionError", 51 | "BrokenPipeError", 52 | ] 53 | ): 54 | # Client has disconnected - log the error but don't crash 55 | # Return a simple error message (though it likely won't be received) 56 | return f"Client disconnected during operation: {error_name}" 57 | else: 58 | # Re-raise non-connection errors 59 | raise 60 | 61 | return wrapper 62 | 63 | 64 | class BaseTool(ABC): 65 | """Abstract base class for all MCP Claude Code tools. 66 | 67 | This class defines the core interface that all tools must implement, ensuring 68 | consistency in how tools are registered, documented, and called. 69 | """ 70 | 71 | @property 72 | @abstractmethod 73 | def name(self) -> str: 74 | """Get the tool name. 75 | 76 | Returns: 77 | The tool name as it will appear in the MCP server 78 | """ 79 | pass 80 | 81 | @property 82 | @abstractmethod 83 | def description(self) -> str: 84 | """Get the tool description. 85 | 86 | Returns: 87 | Detailed description of the tool's purpose and usage 88 | """ 89 | pass 90 | 91 | @abstractmethod 92 | async def call(self, ctx: MCPContext, **params: Any) -> Any: 93 | """Execute the tool with the given parameters. 94 | 95 | Args: 96 | ctx: MCP context for the tool call 97 | **params: Tool parameters provided by the caller 98 | 99 | Returns: 100 | Tool execution result as a string 101 | """ 102 | pass 103 | 104 | @abstractmethod 105 | def register(self, mcp_server: FastMCP) -> None: 106 | """Register this tool with the MCP server. 107 | 108 | This method must be implemented by each tool class to create a wrapper function 109 | with explicitly defined parameters that calls this tool's call method. 110 | The wrapper function is then registered with the MCP server. 111 | 112 | Args: 113 | mcp_server: The FastMCP server instance 114 | """ 115 | pass 116 | 117 | 118 | class FileSystemTool(BaseTool, ABC): 119 | """Base class for filesystem-related tools. 120 | 121 | Provides common functionality for working with files and directories, 122 | including permission checking and path validation. 123 | """ 124 | 125 | def __init__(self, permission_manager: PermissionManager) -> None: 126 | """Initialize filesystem tool. 127 | 128 | Args: 129 | permission_manager: Permission manager for access control 130 | """ 131 | self.permission_manager: PermissionManager = permission_manager 132 | 133 | def validate_path(self, path: str, param_name: str = "path") -> ValidationResult: 134 | """Validate a path parameter. 135 | 136 | Args: 137 | path: Path to validate 138 | param_name: Name of the parameter (for error messages) 139 | 140 | Returns: 141 | Validation result containing validation status and error message if any 142 | """ 143 | return validate_path_parameter(path, param_name) 144 | 145 | def is_path_allowed(self, path: str) -> bool: 146 | """Check if a path is allowed according to permission settings. 147 | 148 | Args: 149 | path: Path to check 150 | 151 | Returns: 152 | True if the path is allowed, False otherwise 153 | """ 154 | return self.permission_manager.is_path_allowed(path) 155 | 156 | 157 | @final 158 | class ToolRegistry: 159 | """Registry for MCP Claude Code tools. 160 | 161 | Provides functionality for registering tool implementations with an MCP server, 162 | handling the conversion between tool classes and MCP tool functions. 163 | """ 164 | 165 | @staticmethod 166 | def register_tool(mcp_server: FastMCP, tool: BaseTool) -> None: 167 | """Register a tool with the MCP server. 168 | 169 | Args: 170 | mcp_server: The FastMCP server instance 171 | tool: The tool to register 172 | """ 173 | # Use the tool's register method which handles all the details 174 | tool.register(mcp_server) 175 | 176 | @staticmethod 177 | def register_tools(mcp_server: FastMCP, tools: list[BaseTool]) -> None: 178 | """Register multiple tools with the MCP server. 179 | 180 | Args: 181 | mcp_server: The FastMCP server instance 182 | tools: List of tools to register 183 | """ 184 | for tool in tools: 185 | ToolRegistry.register_tool(mcp_server, tool) 186 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/common/context.py: -------------------------------------------------------------------------------- 1 | """Enhanced Context for MCP Claude Code tools. 2 | 3 | This module provides an enhanced Context class that wraps the MCP Context 4 | and adds additional functionality specific to Claude Code tools. 5 | """ 6 | 7 | import json 8 | import os 9 | import time 10 | from collections.abc import Iterable 11 | from pathlib import Path 12 | from typing import Any, ClassVar, final 13 | 14 | from fastmcp import Context as MCPContext 15 | from mcp.server.lowlevel.helper_types import ReadResourceContents 16 | 17 | 18 | @final 19 | class ToolContext: 20 | """Enhanced context for MCP Claude Code tools. 21 | 22 | This class wraps the MCP Context and adds additional functionality 23 | for tracking tool execution, progress reporting, and resource access. 24 | """ 25 | 26 | # Track all active contexts for debugging 27 | _active_contexts: ClassVar[set["ToolContext"]] = set() 28 | 29 | def __init__(self, mcp_context: MCPContext) -> None: 30 | """Initialize the tool context. 31 | 32 | Args: 33 | mcp_context: The underlying MCP Context 34 | """ 35 | self._mcp_context: MCPContext = mcp_context 36 | self._tool_name: str | None = None 37 | self._execution_id: str | None = None 38 | 39 | # Add to active contexts 40 | ToolContext._active_contexts.add(self) 41 | 42 | def __del__(self) -> None: 43 | """Clean up when the context is destroyed.""" 44 | # Remove from active contexts 45 | ToolContext._active_contexts.discard(self) 46 | 47 | @property 48 | def mcp_context(self) -> MCPContext: 49 | """Get the underlying MCP Context. 50 | 51 | Returns: 52 | The MCP Context 53 | """ 54 | return self._mcp_context 55 | 56 | @property 57 | def request_id(self) -> str: 58 | """Get the request ID from the MCP context. 59 | 60 | Returns: 61 | The request ID 62 | """ 63 | return self._mcp_context.request_id 64 | 65 | @property 66 | def client_id(self) -> str | None: 67 | """Get the client ID from the MCP context. 68 | 69 | Returns: 70 | The client ID 71 | """ 72 | return self._mcp_context.client_id 73 | 74 | def set_tool_info(self, tool_name: str, execution_id: str | None = None) -> None: 75 | """Set information about the currently executing tool. 76 | 77 | Args: 78 | tool_name: The name of the tool being executed 79 | execution_id: Optional unique execution ID 80 | """ 81 | self._tool_name = tool_name 82 | self._execution_id = execution_id 83 | 84 | async def info(self, message: str) -> None: 85 | """Log an informational message. 86 | 87 | Args: 88 | message: The message to log 89 | """ 90 | try: 91 | await self._mcp_context.info(self._format_message(message)) 92 | except Exception: 93 | # Silently ignore errors when client has disconnected 94 | pass 95 | 96 | async def debug(self, message: str) -> None: 97 | """Log a debug message. 98 | 99 | Args: 100 | message: The message to log 101 | """ 102 | try: 103 | await self._mcp_context.debug(self._format_message(message)) 104 | except Exception: 105 | # Silently ignore errors when client has disconnected 106 | pass 107 | 108 | async def warning(self, message: str) -> None: 109 | """Log a warning message. 110 | 111 | Args: 112 | message: The message to log 113 | """ 114 | try: 115 | await self._mcp_context.warning(self._format_message(message)) 116 | except Exception: 117 | # Silently ignore errors when client has disconnected 118 | pass 119 | 120 | async def error(self, message: str) -> None: 121 | """Log an error message. 122 | 123 | Args: 124 | message: The message to log 125 | """ 126 | try: 127 | await self._mcp_context.error(self._format_message(message)) 128 | except Exception: 129 | # Silently ignore errors when client has disconnected 130 | pass 131 | 132 | def _format_message(self, message: str) -> str: 133 | """Format a message with tool information if available. 134 | 135 | Args: 136 | message: The original message 137 | 138 | Returns: 139 | The formatted message 140 | """ 141 | if self._tool_name: 142 | if self._execution_id: 143 | return f"[{self._tool_name}:{self._execution_id}] {message}" 144 | return f"[{self._tool_name}] {message}" 145 | return message 146 | 147 | async def report_progress(self, current: int, total: int) -> None: 148 | """Report progress to the client. 149 | 150 | Args: 151 | current: Current progress value 152 | total: Total progress value 153 | """ 154 | try: 155 | await self._mcp_context.report_progress(current, total) 156 | except Exception: 157 | # Silently ignore errors when client has disconnected 158 | pass 159 | 160 | async def read_resource(self, uri: str) -> Iterable[ReadResourceContents]: 161 | """Read a resource via the MCP protocol. 162 | 163 | Args: 164 | uri: The resource URI 165 | 166 | Returns: 167 | A tuple of (content, mime_type) 168 | """ 169 | return await self._mcp_context.read_resource(uri) 170 | 171 | 172 | # Factory function to create a ToolContext from an MCP Context 173 | def create_tool_context(mcp_context: MCPContext) -> ToolContext: 174 | """Create a ToolContext from an MCP Context. 175 | 176 | Args: 177 | mcp_context: The MCP Context 178 | 179 | Returns: 180 | A new ToolContext 181 | """ 182 | return ToolContext(mcp_context) 183 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/common/permissions.py: -------------------------------------------------------------------------------- 1 | """Permission system for the MCP Claude Code server.""" 2 | 3 | import json 4 | import os 5 | import sys 6 | import tempfile 7 | from collections.abc import Awaitable, Callable 8 | from pathlib import Path 9 | from typing import Any, TypeVar, final 10 | 11 | # Define type variables for better type annotations 12 | T = TypeVar("T") 13 | P = TypeVar("P") 14 | 15 | 16 | @final 17 | class PermissionManager: 18 | """Manages permissions for file and command operations.""" 19 | 20 | def __init__(self) -> None: 21 | """Initialize the permission manager.""" 22 | # Allowed paths 23 | self.allowed_paths: set[Path] = set() 24 | 25 | # Allowed paths based on platform 26 | if sys.platform == "win32": # Windows 27 | self.allowed_paths.add(Path(tempfile.gettempdir()).resolve()) 28 | else: # Unix/Linux/Mac 29 | self.allowed_paths.add(Path("/tmp").resolve()) 30 | self.allowed_paths.add(Path("/var").resolve()) 31 | 32 | # Excluded paths 33 | self.excluded_paths: set[Path] = set() 34 | self.excluded_patterns: list[str] = [] 35 | 36 | # Default excluded patterns 37 | self._add_default_exclusions() 38 | 39 | def _add_default_exclusions(self) -> None: 40 | """Add default exclusions for sensitive files and directories.""" 41 | # Sensitive directories 42 | sensitive_dirs: list[str] = [ 43 | ".ssh", 44 | ".gnupg", 45 | "node_modules", 46 | "__pycache__", 47 | ".venv", 48 | "venv", 49 | "env", 50 | ".idea", 51 | ".DS_Store", 52 | ] 53 | self.excluded_patterns.extend(sensitive_dirs) 54 | 55 | # Sensitive file patterns 56 | sensitive_patterns: list[str] = [ 57 | ".env", 58 | "*.key", 59 | "*.pem", 60 | "*.crt", 61 | "*password*", 62 | "*secret*", 63 | "*.sqlite", 64 | "*.db", 65 | "*.sqlite3", 66 | "*.log", 67 | ] 68 | self.excluded_patterns.extend(sensitive_patterns) 69 | 70 | def add_allowed_path(self, path: str) -> None: 71 | """Add a path to the allowed paths. 72 | 73 | Args: 74 | path: The path to allow 75 | """ 76 | resolved_path: Path = Path(path).resolve() 77 | self.allowed_paths.add(resolved_path) 78 | 79 | def remove_allowed_path(self, path: str) -> None: 80 | """Remove a path from the allowed paths. 81 | 82 | Args: 83 | path: The path to remove 84 | """ 85 | resolved_path: Path = Path(path).resolve() 86 | if resolved_path in self.allowed_paths: 87 | self.allowed_paths.remove(resolved_path) 88 | 89 | def exclude_path(self, path: str) -> None: 90 | """Exclude a path from allowed operations. 91 | 92 | Args: 93 | path: The path to exclude 94 | """ 95 | resolved_path: Path = Path(path).resolve() 96 | self.excluded_paths.add(resolved_path) 97 | 98 | def add_exclusion_pattern(self, pattern: str) -> None: 99 | """Add an exclusion pattern. 100 | 101 | Args: 102 | pattern: The pattern to exclude 103 | """ 104 | self.excluded_patterns.append(pattern) 105 | 106 | def is_path_allowed(self, path: str) -> bool: 107 | """Check if a path is allowed. 108 | 109 | Args: 110 | path: The path to check 111 | 112 | Returns: 113 | True if the path is allowed, False otherwise 114 | """ 115 | resolved_path: Path = Path(path).resolve() 116 | 117 | # Check exclusions first 118 | if self._is_path_excluded(resolved_path): 119 | return False 120 | 121 | # Check if the path is within any allowed path 122 | for allowed_path in self.allowed_paths: 123 | try: 124 | resolved_path.relative_to(allowed_path) 125 | return True 126 | except ValueError: 127 | continue 128 | 129 | return False 130 | 131 | def _is_path_excluded(self, path: Path) -> bool: 132 | """Check if a path is excluded. 133 | 134 | Args: 135 | path: The path to check 136 | 137 | Returns: 138 | True if the path is excluded, False otherwise 139 | """ 140 | 141 | # Check exact excluded paths 142 | if path in self.excluded_paths: 143 | return True 144 | 145 | # Check excluded patterns 146 | path_str: str = str(path) 147 | 148 | # Get path parts to check for exact directory/file name matches 149 | path_parts = path_str.split(os.sep) 150 | 151 | for pattern in self.excluded_patterns: 152 | # Handle wildcard patterns (e.g., "*.log") 153 | if pattern.startswith("*"): 154 | if path_str.endswith(pattern[1:]): 155 | return True 156 | else: 157 | # For non-wildcard patterns, check if any path component matches exactly 158 | if pattern in path_parts: 159 | return True 160 | 161 | return False 162 | 163 | def to_json(self) -> str: 164 | """Convert the permission manager to a JSON string. 165 | 166 | Returns: 167 | A JSON string representation of the permission manager 168 | """ 169 | data: dict[str, Any] = { 170 | "allowed_paths": [str(p) for p in self.allowed_paths], 171 | "excluded_paths": [str(p) for p in self.excluded_paths], 172 | "excluded_patterns": self.excluded_patterns, 173 | } 174 | 175 | return json.dumps(data) 176 | 177 | @classmethod 178 | def from_json(cls, json_str: str) -> "PermissionManager": 179 | """Create a permission manager from a JSON string. 180 | 181 | Args: 182 | json_str: The JSON string 183 | 184 | Returns: 185 | A new PermissionManager instance 186 | """ 187 | data: dict[str, Any] = json.loads(json_str) 188 | 189 | manager = cls() 190 | 191 | for path in data.get("allowed_paths", []): 192 | manager.add_allowed_path(path) 193 | 194 | for path in data.get("excluded_paths", []): 195 | manager.exclude_path(path) 196 | 197 | manager.excluded_patterns = data.get("excluded_patterns", []) 198 | 199 | return manager 200 | 201 | 202 | class PermissibleOperation: 203 | """A decorator for operations that require permission.""" 204 | 205 | def __init__( 206 | self, 207 | permission_manager: PermissionManager, 208 | operation: str, 209 | get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = None, 210 | ) -> None: 211 | """Initialize the permissible operation. 212 | 213 | Args: 214 | permission_manager: The permission manager 215 | operation: The operation type (read, write, execute, etc.) 216 | get_path_fn: Optional function to extract the path from args and kwargs 217 | """ 218 | self.permission_manager: PermissionManager = permission_manager 219 | self.operation: str = operation 220 | self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = ( 221 | get_path_fn 222 | ) 223 | 224 | def __call__( 225 | self, func: Callable[..., Awaitable[T]] 226 | ) -> Callable[..., Awaitable[T]]: 227 | """Decorate the function. 228 | 229 | Args: 230 | func: The function to decorate 231 | 232 | Returns: 233 | The decorated function 234 | """ 235 | 236 | async def wrapper(*args: Any, **kwargs: Any) -> T: 237 | # Extract the path 238 | if self.get_path_fn: 239 | # Pass args as a list and kwargs as a dict to the path function 240 | path = self.get_path_fn(list(args), kwargs) 241 | else: 242 | # Default to first argument 243 | path = args[0] if args else next(iter(kwargs.values()), None) 244 | 245 | if not isinstance(path, str): 246 | raise ValueError(f"Invalid path type: {type(path)}") 247 | 248 | # Check permission 249 | if not self.permission_manager.is_path_allowed(path): 250 | raise PermissionError( 251 | f"Operation '{self.operation}' not allowed for path: {path}" 252 | ) 253 | 254 | # Call the function 255 | return await func(*args, **kwargs) 256 | 257 | return wrapper 258 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/common/thinking_tool.py: -------------------------------------------------------------------------------- 1 | """Thinking tool implementation. 2 | 3 | This module provides the ThinkingTool for Claude to engage in structured thinking. 4 | """ 5 | 6 | from typing import Annotated, TypedDict, Unpack, final, override 7 | 8 | from fastmcp import Context as MCPContext 9 | from fastmcp import FastMCP 10 | from fastmcp.server.dependencies import get_context 11 | from pydantic import Field 12 | 13 | from mcp_claude_code.tools.common.base import BaseTool 14 | from mcp_claude_code.tools.common.context import create_tool_context 15 | 16 | 17 | Thought = Annotated[ 18 | str, 19 | Field( 20 | description="The detailed thought process to record", 21 | min_length=1, 22 | ), 23 | ] 24 | 25 | 26 | class ThinkingToolParams(TypedDict): 27 | """Parameters for the ThinkingTool. 28 | 29 | Attributes: 30 | thought: The detailed thought process to record 31 | """ 32 | 33 | thought: Thought 34 | 35 | 36 | @final 37 | class ThinkingTool(BaseTool): 38 | """Tool for Claude to engage in structured thinking.""" 39 | 40 | @property 41 | @override 42 | def name(self) -> str: 43 | """Get the tool name. 44 | 45 | Returns: 46 | Tool name 47 | """ 48 | return "think" 49 | 50 | @property 51 | @override 52 | def description(self) -> str: 53 | """Get the tool description. 54 | 55 | Returns: 56 | Tool description 57 | """ 58 | return """Use the tool to think about something. It will not obtain new information or make any changes to the repository, but just log the thought. Use it when complex reasoning or brainstorming is needed. 59 | Ensure thinking content is concise and accurate, without needing to include code details 60 | 61 | Common use cases: 62 | 1. When exploring a repository and discovering the source of a bug, call this tool to brainstorm several unique ways of fixing the bug, and assess which change(s) are likely to be simplest and most effective 63 | 2. After receiving test results, use this tool to brainstorm ways to fix failing tests 64 | 3. When planning a complex refactoring, use this tool to outline different approaches and their tradeoffs 65 | 4. When designing a new feature, use this tool to think through architecture decisions and implementation details 66 | 5. When debugging a complex issue, use this tool to organize your thoughts and hypotheses 67 | 6. When considering changes to the plan or shifts in thinking that the user has not previously mentioned, consider whether it is necessary to confirm with the user. 68 | 69 | 70 | Feature Implementation Planning 71 | - New code search feature requirements: 72 | * Search for code patterns across multiple files 73 | * Identify function usages and references 74 | * Analyze import relationships 75 | * Generate summary of matching patterns 76 | - Implementation considerations: 77 | * Need to leverage existing search mechanisms 78 | * Should use regex for pattern matching 79 | * Results need consistent format with other search methods 80 | * Must handle large codebases efficiently 81 | - Design approach: 82 | 1. Create new CodeSearcher class that follows existing search patterns 83 | 2. Implement core pattern matching algorithm 84 | 3. Add result formatting methods 85 | 4. Integrate with file traversal system 86 | 5. Add caching for performance optimization 87 | - Testing strategy: 88 | * Unit tests for search accuracy 89 | * Integration tests with existing components 90 | * Performance tests with large codebases 91 | """ 92 | 93 | def __init__(self) -> None: 94 | """Initialize the thinking tool.""" 95 | pass 96 | 97 | @override 98 | async def call( 99 | self, 100 | ctx: MCPContext, 101 | **params: Unpack[ThinkingToolParams], 102 | ) -> str: 103 | """Execute the tool with the given parameters. 104 | 105 | Args: 106 | ctx: MCP context 107 | **params: Tool parameters 108 | 109 | Returns: 110 | Tool result 111 | """ 112 | tool_ctx = create_tool_context(ctx) 113 | tool_ctx.set_tool_info(self.name) 114 | 115 | # Extract parameters 116 | thought = params.get("thought") 117 | 118 | # Validate required thought parameter 119 | if not thought: 120 | await tool_ctx.error( 121 | "Parameter 'thought' is required but was None or empty" 122 | ) 123 | return "Error: Parameter 'thought' is required but was None or empty" 124 | 125 | if thought.strip() == "": 126 | await tool_ctx.error("Parameter 'thought' cannot be empty") 127 | return "Error: Parameter 'thought' cannot be empty" 128 | 129 | # Log the thought but don't take action 130 | await tool_ctx.info("Thinking process recorded") 131 | 132 | # Return confirmation 133 | return "I've recorded your thinking process. You can continue with your next action based on this analysis." 134 | 135 | @override 136 | def register(self, mcp_server: FastMCP) -> None: 137 | """Register this thinking tool with the MCP server. 138 | 139 | Creates a wrapper function with explicitly defined parameters that match 140 | the tool's parameter schema and registers it with the MCP server. 141 | 142 | Args: 143 | mcp_server: The FastMCP server instance 144 | """ 145 | tool_self = self # Create a reference to self for use in the closure 146 | 147 | @mcp_server.tool(name=self.name, description=self.description) 148 | async def think( 149 | ctx: MCPContext, 150 | thought: Thought, 151 | ) -> str: 152 | ctx = get_context() 153 | return await tool_self.call(ctx, thought=thought) 154 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/common/validation.py: -------------------------------------------------------------------------------- 1 | """Parameter validation utilities for MCP Claude Code tools. 2 | 3 | This module provides utilities for validating parameters in tool functions. 4 | """ 5 | 6 | from typing import TypeVar, final 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | @final 12 | class ValidationResult: 13 | """Result of a parameter validation.""" 14 | 15 | def __init__(self, is_valid: bool, error_message: str = ""): 16 | """Initialize a validation result. 17 | 18 | Args: 19 | is_valid: Whether the parameter is valid 20 | error_message: Optional error message for invalid parameters 21 | """ 22 | self.is_valid: bool = is_valid 23 | self.error_message: str = error_message 24 | 25 | @property 26 | def is_error(self) -> bool: 27 | """Check if the validation resulted in an error. 28 | 29 | Returns: 30 | True if there was a validation error, False otherwise 31 | """ 32 | return not self.is_valid 33 | 34 | 35 | def validate_path_parameter( 36 | path: str | None, parameter_name: str = "path" 37 | ) -> ValidationResult: 38 | """Validate a path parameter. 39 | 40 | Args: 41 | path: The path parameter to validate 42 | parameter_name: The name of the parameter (for error messages) 43 | 44 | Returns: 45 | A ValidationResult indicating whether the parameter is valid 46 | """ 47 | # Check for None 48 | if path is None: 49 | return ValidationResult( 50 | is_valid=False, 51 | error_message=f"Path parameter '{parameter_name}' is required but was None", 52 | ) 53 | 54 | # Check for empty path 55 | if path.strip() == "": 56 | return ValidationResult( 57 | is_valid=False, 58 | error_message=f"Path parameter '{parameter_name}' is required but was empty string", 59 | ) 60 | 61 | # Path is valid 62 | return ValidationResult(is_valid=True) 63 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/filesystem/__init__.py: -------------------------------------------------------------------------------- 1 | """Filesystem tools package for MCP Claude Code. 2 | 3 | This package provides tools for interacting with the filesystem, including reading, writing, 4 | and editing files, directory navigation, and content searching. 5 | """ 6 | 7 | from fastmcp import FastMCP 8 | 9 | from mcp_claude_code.tools.common.base import BaseTool, ToolRegistry 10 | 11 | from mcp_claude_code.tools.common.permissions import PermissionManager 12 | from mcp_claude_code.tools.filesystem.content_replace import ContentReplaceTool 13 | from mcp_claude_code.tools.filesystem.directory_tree import DirectoryTreeTool 14 | from mcp_claude_code.tools.filesystem.edit import Edit 15 | from mcp_claude_code.tools.filesystem.grep import Grep 16 | from mcp_claude_code.tools.filesystem.grep_ast_tool import GrepAstTool 17 | from mcp_claude_code.tools.filesystem.multi_edit import MultiEdit 18 | from mcp_claude_code.tools.filesystem.read import ReadTool 19 | from mcp_claude_code.tools.filesystem.write import Write 20 | 21 | # Export all tool classes 22 | __all__ = [ 23 | "ReadTool", 24 | "Write", 25 | "Edit", 26 | "MultiEdit", 27 | "DirectoryTreeTool", 28 | "Grep", 29 | "ContentReplaceTool", 30 | "GrepAstTool", 31 | "get_filesystem_tools", 32 | "register_filesystem_tools", 33 | ] 34 | 35 | 36 | def get_read_only_filesystem_tools( 37 | permission_manager: PermissionManager, 38 | ) -> list[BaseTool]: 39 | """Create instances of read-only filesystem tools. 40 | 41 | Args: 42 | permission_manager: Permission manager for access control 43 | 44 | Returns: 45 | List of read-only filesystem tool instances 46 | """ 47 | return [ 48 | ReadTool(permission_manager), 49 | DirectoryTreeTool(permission_manager), 50 | Grep(permission_manager), 51 | GrepAstTool(permission_manager), 52 | ] 53 | 54 | 55 | def get_filesystem_tools(permission_manager: PermissionManager) -> list[BaseTool]: 56 | """Create instances of all filesystem tools. 57 | 58 | Args: 59 | permission_manager: Permission manager for access control 60 | 61 | Returns: 62 | List of filesystem tool instances 63 | """ 64 | return [ 65 | ReadTool(permission_manager), 66 | Write(permission_manager), 67 | Edit(permission_manager), 68 | MultiEdit(permission_manager), 69 | DirectoryTreeTool(permission_manager), 70 | Grep(permission_manager), 71 | ContentReplaceTool(permission_manager), 72 | GrepAstTool(permission_manager), 73 | ] 74 | 75 | 76 | def register_filesystem_tools( 77 | mcp_server: FastMCP, 78 | permission_manager: PermissionManager, 79 | ) -> list[BaseTool]: 80 | """Register all filesystem tools with the MCP server. 81 | 82 | Args: 83 | mcp_server: The FastMCP server instance 84 | permission_manager: Permission manager for access control 85 | 86 | Returns: 87 | List of registered tools 88 | """ 89 | tools = get_filesystem_tools(permission_manager) 90 | ToolRegistry.register_tools(mcp_server, tools) 91 | return tools 92 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/filesystem/base.py: -------------------------------------------------------------------------------- 1 | """Base functionality for filesystem tools. 2 | 3 | This module provides common functionality for filesystem tools including path handling, 4 | error formatting, and shared utilities for file operations. 5 | """ 6 | 7 | from abc import ABC 8 | from pathlib import Path 9 | from typing import Any 10 | 11 | from fastmcp import Context as MCPContext 12 | 13 | from mcp_claude_code.tools.common.base import FileSystemTool 14 | from mcp_claude_code.tools.common.context import ToolContext, create_tool_context 15 | 16 | 17 | class FilesystemBaseTool(FileSystemTool, ABC): 18 | """Enhanced base class for all filesystem tools. 19 | 20 | Provides additional utilities specific to filesystem operations beyond 21 | the base functionality in FileSystemTool. 22 | """ 23 | 24 | async def check_path_allowed( 25 | self, path: str, tool_ctx: Any, error_prefix: str = "Error" 26 | ) -> tuple[bool, str]: 27 | """Check if a path is allowed and log an error if not. 28 | 29 | Args: 30 | path: Path to check 31 | tool_ctx: Tool context for logging 32 | error_prefix: Prefix for error messages 33 | 34 | Returns: 35 | tuple of (is_allowed, error_message) 36 | """ 37 | if not self.is_path_allowed(path): 38 | message = f"Access denied - path outside allowed directories: {path}" 39 | await tool_ctx.error(message) 40 | return False, f"{error_prefix}: {message}" 41 | return True, "" 42 | 43 | async def check_path_exists( 44 | self, path: str, tool_ctx: Any, error_prefix: str = "Error" 45 | ) -> tuple[bool, str]: 46 | """Check if a path exists and log an error if not. 47 | 48 | Args: 49 | path: Path to check 50 | tool_ctx: Tool context for logging 51 | error_prefix: Prefix for error messages 52 | 53 | Returns: 54 | tuple of (exists, error_message) 55 | """ 56 | file_path = Path(path) 57 | if not file_path.exists(): 58 | message = f"Path does not exist: {path}" 59 | await tool_ctx.error(message) 60 | return False, f"{error_prefix}: {message}" 61 | return True, "" 62 | 63 | async def check_is_file( 64 | self, path: str, tool_ctx: Any, error_prefix: str = "Error" 65 | ) -> tuple[bool, str]: 66 | """Check if a path is a file and log an error if not. 67 | 68 | Args: 69 | path: Path to check 70 | tool_ctx: Tool context for logging 71 | error_prefix: Prefix for error messages 72 | 73 | Returns: 74 | tuple of (is_file, error_message) 75 | """ 76 | file_path = Path(path) 77 | if not file_path.is_file(): 78 | message = f"Path is not a file: {path}" 79 | await tool_ctx.error(message) 80 | return False, f"{error_prefix}: {message}" 81 | return True, "" 82 | 83 | async def check_is_directory( 84 | self, path: str, tool_ctx: Any, error_prefix: str = "Error" 85 | ) -> tuple[bool, str]: 86 | """Check if a path is a directory and log an error if not. 87 | 88 | Args: 89 | path: Path to check 90 | tool_ctx: Tool context for logging 91 | error_prefix: Prefix for error messages 92 | 93 | Returns: 94 | tuple of (is_directory, error_message) 95 | """ 96 | dir_path = Path(path) 97 | if not dir_path.is_dir(): 98 | message = f"Path is not a directory: {path}" 99 | await tool_ctx.error(message) 100 | return False, f"{error_prefix}: {message}" 101 | return True, "" 102 | 103 | def create_tool_context(self, ctx: MCPContext) -> ToolContext: 104 | """Create a tool context with the tool name. 105 | 106 | Args: 107 | ctx: MCP context 108 | 109 | Returns: 110 | Tool context 111 | """ 112 | tool_ctx = create_tool_context(ctx) 113 | return tool_ctx 114 | 115 | def set_tool_context_info(self, tool_ctx: ToolContext) -> None: 116 | """Set the tool info on the context. 117 | 118 | Args: 119 | tool_ctx: Tool context 120 | """ 121 | tool_ctx.set_tool_info(self.name) 122 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/filesystem/write.py: -------------------------------------------------------------------------------- 1 | """Write file tool implementation. 2 | 3 | This module provides the Write tool for creating or overwriting files. 4 | """ 5 | 6 | from pathlib import Path 7 | from typing import Annotated, TypedDict, Unpack, final, override 8 | 9 | from fastmcp import Context as MCPContext 10 | from fastmcp import FastMCP 11 | from fastmcp.server.dependencies import get_context 12 | from pydantic import Field 13 | 14 | from mcp_claude_code.tools.filesystem.base import FilesystemBaseTool 15 | 16 | FilePath = Annotated[ 17 | str, 18 | Field( 19 | description="The absolute path to the file to write (must be absolute, not relative)", 20 | min_length=1, 21 | ), 22 | ] 23 | 24 | Content = Annotated[ 25 | str, 26 | Field( 27 | description="The content to write to the file", 28 | min_length=1, 29 | ), 30 | ] 31 | 32 | 33 | class WriteToolParams(TypedDict): 34 | """Parameters for the Write tool. 35 | 36 | Attributes: 37 | file_path: The absolute path to the file to write (must be absolute, not relative) 38 | content: The content to write to the file 39 | """ 40 | 41 | file_path: FilePath 42 | content: Content 43 | 44 | 45 | @final 46 | class Write(FilesystemBaseTool): 47 | """Tool for writing file contents.""" 48 | 49 | @property 50 | @override 51 | def name(self) -> str: 52 | """Get the tool name. 53 | 54 | Returns: 55 | Tool name 56 | """ 57 | return "write" 58 | 59 | @property 60 | @override 61 | def description(self) -> str: 62 | """Get the tool description. 63 | 64 | Returns: 65 | Tool description 66 | """ 67 | return """Writes a file to the local filesystem. 68 | 69 | Usage: 70 | - This tool will overwrite the existing file if there is one at the provided path. 71 | - If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. 72 | - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. 73 | - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.""" 74 | 75 | @override 76 | async def call( 77 | self, 78 | ctx: MCPContext, 79 | **params: Unpack[WriteToolParams], 80 | ) -> str: 81 | """Execute the tool with the given parameters. 82 | 83 | Args: 84 | ctx: MCP context 85 | **params: Tool parameters 86 | 87 | Returns: 88 | Tool result 89 | """ 90 | tool_ctx = self.create_tool_context(ctx) 91 | self.set_tool_context_info(tool_ctx) 92 | 93 | # Extract parameters 94 | file_path: FilePath = params["file_path"] 95 | content: Content = params["content"] 96 | 97 | # Validate parameters 98 | path_validation = self.validate_path(file_path) 99 | if path_validation.is_error: 100 | await tool_ctx.error(path_validation.error_message) 101 | return f"Error: {path_validation.error_message}" 102 | 103 | await tool_ctx.info(f"Writing file: {file_path}") 104 | 105 | # Check if file is allowed to be written 106 | allowed, error_msg = await self.check_path_allowed(file_path, tool_ctx) 107 | if not allowed: 108 | return error_msg 109 | 110 | # Additional check already verified by is_path_allowed above 111 | await tool_ctx.info(f"Writing file: {file_path}") 112 | 113 | try: 114 | path_obj = Path(file_path) 115 | 116 | # Check if parent directory is allowed 117 | parent_dir = str(path_obj.parent) 118 | if not self.is_path_allowed(parent_dir): 119 | await tool_ctx.error(f"Parent directory not allowed: {parent_dir}") 120 | return f"Error: Parent directory not allowed: {parent_dir}" 121 | 122 | # Create parent directories if they don't exist 123 | path_obj.parent.mkdir(parents=True, exist_ok=True) 124 | 125 | # Write the file 126 | with open(path_obj, "w", encoding="utf-8") as f: 127 | f.write(content) 128 | 129 | await tool_ctx.info( 130 | f"Successfully wrote file: {file_path} ({len(content)} bytes)" 131 | ) 132 | return f"Successfully wrote file: {file_path} ({len(content)} bytes)" 133 | except Exception as e: 134 | await tool_ctx.error(f"Error writing file: {str(e)}") 135 | return f"Error writing file: {str(e)}" 136 | 137 | @override 138 | def register(self, mcp_server: FastMCP) -> None: 139 | """Register this tool with the MCP server. 140 | 141 | Creates a wrapper function with explicitly defined parameters that match 142 | the tool's parameter schema and registers it with the MCP server. 143 | 144 | Args: 145 | mcp_server: The FastMCP server instance 146 | """ 147 | tool_self = self # Create a reference to self for use in the closure 148 | 149 | @mcp_server.tool(name=self.name, description=self.description) 150 | async def write( 151 | ctx: MCPContext, 152 | file_path: FilePath, 153 | content: Content, 154 | ) -> str: 155 | ctx = get_context() 156 | return await tool_self.call(ctx, file_path=file_path, content=content) 157 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/jupyter/__init__.py: -------------------------------------------------------------------------------- 1 | """Jupyter notebook tools package for MCP Claude Code. 2 | 3 | This package provides tools for working with Jupyter notebooks (.ipynb files), 4 | including reading and editing notebook cells. 5 | """ 6 | 7 | from fastmcp import FastMCP 8 | 9 | from mcp_claude_code.tools.common.base import BaseTool, ToolRegistry 10 | from mcp_claude_code.tools.common.permissions import PermissionManager 11 | from mcp_claude_code.tools.jupyter.notebook_edit import NoteBookEditTool 12 | from mcp_claude_code.tools.jupyter.notebook_read import NotebookReadTool 13 | 14 | # Export all tool classes 15 | __all__ = [ 16 | "NotebookReadTool", 17 | "NoteBookEditTool", 18 | "get_jupyter_tools", 19 | "register_jupyter_tools", 20 | ] 21 | 22 | 23 | def get_read_only_jupyter_tools( 24 | permission_manager: PermissionManager, 25 | ) -> list[BaseTool]: 26 | """Create instances of read only Jupyter notebook tools. 27 | 28 | Args: 29 | permission_manager: Permission manager for access control 30 | 31 | Returns: 32 | List of Jupyter notebook tool instances 33 | """ 34 | return [ 35 | NotebookReadTool(permission_manager), 36 | ] 37 | 38 | 39 | def get_jupyter_tools(permission_manager: PermissionManager) -> list[BaseTool]: 40 | """Create instances of all Jupyter notebook tools. 41 | 42 | Args: 43 | permission_manager: Permission manager for access control 44 | 45 | Returns: 46 | List of Jupyter notebook tool instances 47 | """ 48 | return [ 49 | NotebookReadTool(permission_manager), 50 | NoteBookEditTool(permission_manager), 51 | ] 52 | 53 | 54 | def register_jupyter_tools( 55 | mcp_server: FastMCP, 56 | permission_manager: PermissionManager, 57 | ) -> list[BaseTool]: 58 | """Register all Jupyter notebook tools with the MCP server. 59 | 60 | Args: 61 | mcp_server: The FastMCP server instance 62 | permission_manager: Permission manager for access control 63 | 64 | Returns: 65 | List of registered tools 66 | """ 67 | tools = get_jupyter_tools(permission_manager) 68 | ToolRegistry.register_tools(mcp_server, tools) 69 | return tools 70 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/jupyter/notebook_read.py: -------------------------------------------------------------------------------- 1 | """Read notebook tool implementation. 2 | 3 | This module provides the NotebookReadTool for reading Jupyter notebook files. 4 | """ 5 | 6 | import json 7 | from pathlib import Path 8 | from typing import Annotated, TypedDict, Unpack, final, override 9 | 10 | from fastmcp import Context as MCPContext 11 | from fastmcp import FastMCP 12 | from fastmcp.server.dependencies import get_context 13 | from pydantic import Field 14 | 15 | from mcp_claude_code.tools.jupyter.base import JupyterBaseTool 16 | 17 | NotebookPath = Annotated[ 18 | str, 19 | Field( 20 | description="The absolute path to the Jupyter notebook file to read (must be absolute, not relative)", 21 | ), 22 | ] 23 | 24 | 25 | class NotebookReadToolParams(TypedDict): 26 | """Parameters for the NotebookReadTool. 27 | 28 | Attributes: 29 | notebook_path: The absolute path to the Jupyter notebook file to read (must be absolute, not relative) 30 | """ 31 | 32 | notebook_path: NotebookPath 33 | 34 | 35 | @final 36 | class NotebookReadTool(JupyterBaseTool): 37 | """Tool for reading Jupyter notebook files.""" 38 | 39 | @property 40 | @override 41 | def name(self) -> str: 42 | """Get the tool name. 43 | 44 | Returns: 45 | Tool name 46 | """ 47 | return "notebook_read" 48 | 49 | @property 50 | @override 51 | def description(self) -> str: 52 | """Get the tool description. 53 | 54 | Returns: 55 | Tool description 56 | """ 57 | return "Reads a Jupyter notebook (.ipynb file) and returns all of the cells with their outputs. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path." 58 | 59 | @override 60 | async def call( 61 | self, 62 | ctx: MCPContext, 63 | **params: Unpack[NotebookReadToolParams], 64 | ) -> str: 65 | """Execute the tool with the given parameters. 66 | 67 | Args: 68 | ctx: MCP context 69 | **params: Tool parameters 70 | 71 | Returns: 72 | Tool result 73 | """ 74 | tool_ctx = self.create_tool_context(ctx) 75 | self.set_tool_context_info(tool_ctx) 76 | 77 | # Extract parameters 78 | notebook_path: NotebookPath = params["notebook_path"] 79 | 80 | # Validate path parameter 81 | path_validation = self.validate_path(notebook_path) 82 | if path_validation.is_error: 83 | await tool_ctx.error(path_validation.error_message) 84 | return f"Error: {path_validation.error_message}" 85 | 86 | await tool_ctx.info(f"Reading notebook: {notebook_path}") 87 | 88 | # Check if path is allowed 89 | if not self.is_path_allowed(notebook_path): 90 | await tool_ctx.error( 91 | f"Access denied - path outside allowed directories: {notebook_path}" 92 | ) 93 | return f"Error: Access denied - path outside allowed directories: {notebook_path}" 94 | 95 | try: 96 | file_path = Path(notebook_path) 97 | 98 | if not file_path.exists(): 99 | await tool_ctx.error(f"File does not exist: {notebook_path}") 100 | return f"Error: File does not exist: {notebook_path}" 101 | 102 | if not file_path.is_file(): 103 | await tool_ctx.error(f"Path is not a file: {notebook_path}") 104 | return f"Error: Path is not a file: {notebook_path}" 105 | 106 | # Check file extension 107 | if file_path.suffix.lower() != ".ipynb": 108 | await tool_ctx.error(f"File is not a Jupyter notebook: {notebook_path}") 109 | return f"Error: File is not a Jupyter notebook: {notebook_path}" 110 | 111 | # Read and parse the notebook 112 | try: 113 | # This will read the file, so we don't need to read it separately 114 | _, processed_cells = await self.parse_notebook(file_path) 115 | 116 | # Format the notebook content as a readable string 117 | result = self.format_notebook_cells(processed_cells) 118 | 119 | await tool_ctx.info( 120 | f"Successfully read notebook: {notebook_path} ({len(processed_cells)} cells)" 121 | ) 122 | return result 123 | except json.JSONDecodeError: 124 | await tool_ctx.error(f"Invalid notebook format: {notebook_path}") 125 | return f"Error: Invalid notebook format: {notebook_path}" 126 | except UnicodeDecodeError: 127 | await tool_ctx.error(f"Cannot read notebook file: {notebook_path}") 128 | return f"Error: Cannot read notebook file: {notebook_path}" 129 | except Exception as e: 130 | await tool_ctx.error(f"Error reading notebook: {str(e)}") 131 | return f"Error reading notebook: {str(e)}" 132 | 133 | @override 134 | def register(self, mcp_server: FastMCP) -> None: 135 | """Register this read notebook tool with the MCP server. 136 | 137 | Creates a wrapper function with explicitly defined parameters that match 138 | the tool's parameter schema and registers it with the MCP server. 139 | 140 | Args: 141 | mcp_server: The FastMCP server instance 142 | """ 143 | 144 | tool_self = self # Create a reference to self for use in the closure 145 | 146 | @mcp_server.tool(name=self.name, description=self.description) 147 | async def notebook_read( 148 | ctx: MCPContext, 149 | notebook_path: NotebookPath, 150 | ) -> str: 151 | ctx = get_context() 152 | return await tool_self.call(ctx, notebook_path=notebook_path) 153 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/shell/__init__.py: -------------------------------------------------------------------------------- 1 | """Shell tools package for MCP Claude Code. 2 | 3 | This package provides tools for executing shell commands and scripts. 4 | """ 5 | 6 | import shutil 7 | 8 | from fastmcp import FastMCP 9 | 10 | from mcp_claude_code.tools.common.base import BaseTool, ToolRegistry 11 | from mcp_claude_code.tools.common.permissions import PermissionManager 12 | from mcp_claude_code.tools.shell.bash_session_executor import BashSessionExecutor 13 | from mcp_claude_code.tools.shell.command_executor import CommandExecutor 14 | 15 | # Export all tool classes 16 | __all__ = [ 17 | "get_shell_tools", 18 | "register_shell_tools", 19 | ] 20 | 21 | 22 | def get_shell_tools( 23 | permission_manager: PermissionManager, 24 | ) -> list[BaseTool]: 25 | """Create instances of all shell tools. 26 | 27 | Args: 28 | permission_manager: Permission manager for access control 29 | 30 | Returns: 31 | List of shell tool instances 32 | """ 33 | # Detect tmux availability and choose appropriate implementation 34 | if shutil.which("tmux") is not None: 35 | # Use tmux-based implementation for interactive sessions 36 | from mcp_claude_code.tools.shell.run_command import RunCommandTool 37 | 38 | command_executor = BashSessionExecutor(permission_manager) 39 | return [ 40 | RunCommandTool(permission_manager, command_executor), 41 | ] 42 | else: 43 | # Use Windows-compatible implementation 44 | from mcp_claude_code.tools.shell.run_command_windows import RunCommandTool 45 | 46 | command_executor = CommandExecutor(permission_manager) 47 | return [ 48 | RunCommandTool(permission_manager, command_executor), 49 | ] 50 | 51 | 52 | def register_shell_tools( 53 | mcp_server: FastMCP, 54 | permission_manager: PermissionManager, 55 | ) -> list[BaseTool]: 56 | """Register all shell tools with the MCP server. 57 | 58 | Args: 59 | mcp_server: The FastMCP server instance 60 | permission_manager: Permission manager for access control 61 | 62 | Returns: 63 | List of registered tools 64 | """ 65 | tools = get_shell_tools(permission_manager) 66 | ToolRegistry.register_tools(mcp_server, tools) 67 | return tools 68 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/shell/base.py: -------------------------------------------------------------------------------- 1 | """Base classes for shell tools. 2 | 3 | This module provides abstract base classes and utilities for shell tools, 4 | including command execution, script running, and process management. 5 | """ 6 | 7 | from abc import ABC, abstractmethod 8 | from enum import Enum 9 | from typing import Any, Self, final 10 | 11 | from fastmcp import Context as MCPContext 12 | 13 | from mcp_claude_code.tools.common.base import BaseTool 14 | from mcp_claude_code.tools.common.permissions import PermissionManager 15 | 16 | 17 | class BashCommandStatus(Enum): 18 | """Status of bash command execution.""" 19 | 20 | CONTINUE = "continue" 21 | COMPLETED = "completed" 22 | NO_CHANGE_TIMEOUT = "no_change_timeout" 23 | HARD_TIMEOUT = "hard_timeout" 24 | 25 | 26 | @final 27 | class CommandResult: 28 | """Represents the result of a command execution with rich metadata.""" 29 | 30 | def __init__( 31 | self, 32 | return_code: int = 0, 33 | stdout: str = "", 34 | stderr: str = "", 35 | error_message: str | None = None, 36 | session_id: str | None = None, 37 | status: BashCommandStatus = BashCommandStatus.COMPLETED, 38 | command: str = "", 39 | ): 40 | """Initialize a command result. 41 | 42 | Args: 43 | return_code: The command's return code (0 for success) 44 | stdout: Standard output from the command 45 | stderr: Standard error from the command 46 | error_message: Optional error message for failure cases 47 | session_id: Optional session ID used for the command execution 48 | status: Command execution status 49 | command: The original command that was executed 50 | """ 51 | self.return_code: int = return_code 52 | self.stdout: str = stdout 53 | self.stderr: str = stderr 54 | self.error_message: str | None = error_message 55 | self.session_id: str | None = session_id 56 | self.status: BashCommandStatus = status 57 | self.command: str = command 58 | 59 | @property 60 | def is_success(self) -> bool: 61 | """Check if the command executed successfully. 62 | 63 | Returns: 64 | True if the command succeeded, False otherwise 65 | """ 66 | return ( 67 | self.return_code == 0 68 | and self.status == BashCommandStatus.COMPLETED 69 | and not self.error_message 70 | ) 71 | 72 | @property 73 | def is_running(self) -> bool: 74 | """Check if the command is still running. 75 | 76 | Returns: 77 | True if the command is still running, False otherwise 78 | """ 79 | return self.status in { 80 | BashCommandStatus.CONTINUE, 81 | BashCommandStatus.NO_CHANGE_TIMEOUT, 82 | BashCommandStatus.HARD_TIMEOUT, 83 | } 84 | 85 | @property 86 | def exit_code(self) -> int: 87 | """Get the exit code (alias for return_code for compatibility).""" 88 | return self.return_code 89 | 90 | @property 91 | def error(self) -> bool: 92 | """Check if there was an error.""" 93 | return not self.is_success 94 | 95 | @property 96 | def message(self) -> str: 97 | """Get a human-readable message about the command result.""" 98 | if self.error_message: 99 | return f"Command `{self.command}` failed: {self.error_message}" 100 | return f"Command `{self.command}` executed with exit code {self.return_code}." 101 | 102 | def format_output(self, include_exit_code: bool = True) -> str: 103 | """Format the command output as a string. 104 | 105 | Args: 106 | include_exit_code: Whether to include the exit code in the output 107 | 108 | Returns: 109 | Formatted output string 110 | """ 111 | result_parts: list[str] = [] 112 | 113 | # Add session ID if present 114 | if self.session_id: 115 | result_parts.append(f"Session ID: {self.session_id}") 116 | 117 | # Add command status 118 | if self.status != BashCommandStatus.COMPLETED: 119 | result_parts.append(f"Status: {self.status.value}") 120 | 121 | # Add error message if present 122 | if self.error_message: 123 | result_parts.append(f"Error: {self.error_message}") 124 | 125 | # Add exit code if requested and not zero (for non-errors) 126 | if include_exit_code and (self.return_code != 0 or not self.error_message): 127 | result_parts.append(f"Exit code: {self.return_code}") 128 | 129 | # Add stdout if present 130 | if self.stdout: 131 | result_parts.append(f"STDOUT:\n{self.stdout}") 132 | 133 | # Add stderr if present 134 | if self.stderr: 135 | result_parts.append(f"STDERR:\n{self.stderr}") 136 | 137 | # Join with newlines 138 | return "\n\n".join(result_parts) 139 | 140 | def to_agent_observation(self) -> str: 141 | """Format the result for agent consumption.""" 142 | content = self.stdout 143 | 144 | additional_info: list[str] = [] 145 | if self.session_id: 146 | additional_info.append(f"[Session ID: {self.session_id}]") 147 | 148 | if additional_info: 149 | content += "\n" + "\n".join(additional_info) 150 | 151 | return content 152 | 153 | 154 | class ShellBaseTool(BaseTool, ABC): 155 | """Base class for shell-related tools. 156 | 157 | Provides common functionality for executing commands and scripts, 158 | including permissions checking. 159 | """ 160 | 161 | def __init__(self, permission_manager: PermissionManager) -> None: 162 | """Initialize the shell base tool. 163 | 164 | Args: 165 | permission_manager: Permission manager for access control 166 | """ 167 | self.permission_manager: PermissionManager = permission_manager 168 | 169 | def is_path_allowed(self, path: str) -> bool: 170 | """Check if a path is allowed according to permission settings. 171 | 172 | Args: 173 | path: Path to check 174 | 175 | Returns: 176 | True if the path is allowed, False otherwise 177 | """ 178 | return self.permission_manager.is_path_allowed(path) 179 | 180 | @abstractmethod 181 | async def prepare_tool_context(self, ctx: MCPContext) -> Any: 182 | """Create and prepare the tool context. 183 | 184 | Args: 185 | ctx: MCP context 186 | 187 | Returns: 188 | Prepared tool context 189 | """ 190 | pass 191 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/shell/session_manager.py: -------------------------------------------------------------------------------- 1 | """Session manager for coordinating bash sessions. 2 | 3 | This module provides the SessionManager class which manages the lifecycle 4 | of BashSession instances, handling creation, retrieval, and cleanup. 5 | """ 6 | 7 | import shutil 8 | import threading 9 | from typing import Self, final 10 | 11 | from mcp_claude_code.tools.shell.bash_session import BashSession 12 | from mcp_claude_code.tools.shell.session_storage import SessionStorage 13 | 14 | 15 | @final 16 | class SessionManager: 17 | """Manager for bash sessions with tmux support. 18 | 19 | This class manages the creation, retrieval, and cleanup 20 | of persistent bash sessions. By default, it uses a singleton pattern, 21 | but can be instantiated directly for dependency injection scenarios. 22 | """ 23 | 24 | _instance: Self | None = None 25 | _lock = threading.Lock() 26 | 27 | def __new__( 28 | cls, use_singleton: bool = True, session_storage: SessionStorage | None = None 29 | ) -> "SessionManager": 30 | """Create SessionManager instance. 31 | 32 | Args: 33 | use_singleton: If True, use singleton pattern. If False, create new instance. 34 | """ 35 | if not use_singleton: 36 | # Create a new instance without singleton behavior 37 | instance = super().__new__(cls) 38 | instance._initialized = False 39 | return instance 40 | 41 | if cls._instance is None: 42 | with cls._lock: 43 | if cls._instance is None: 44 | cls._instance = super().__new__(cls) 45 | cls._instance._initialized = False 46 | return cls._instance 47 | 48 | def __init__( 49 | self, use_singleton: bool = True, session_storage: SessionStorage | None = None 50 | ) -> None: 51 | """Initialize the session manager. 52 | 53 | Args: 54 | use_singleton: If True, use singleton pattern (for backward compatibility) 55 | session_storage: Optional session storage instance for dependency injection 56 | """ 57 | if hasattr(self, "_initialized") and self._initialized: 58 | return 59 | self._initialized = True 60 | self.default_timeout_seconds = 30 61 | self.default_session_timeout = 1800 # 30 minutes 62 | 63 | # Allow dependency injection of session storage for isolation 64 | if session_storage is not None: 65 | self._session_storage = session_storage 66 | elif use_singleton: 67 | # Use the default global SessionStorage for singleton instances 68 | from mcp_claude_code.tools.shell.session_storage import SessionStorage 69 | 70 | self._session_storage = SessionStorage 71 | else: 72 | # Use isolated instance storage for non-singleton instances 73 | from mcp_claude_code.tools.shell.session_storage import ( 74 | SessionStorageInstance, 75 | ) 76 | 77 | self._session_storage = SessionStorageInstance() 78 | 79 | def is_tmux_available(self) -> bool: 80 | """Check if tmux is available on the system. 81 | 82 | Returns: 83 | True if tmux is available, False otherwise 84 | """ 85 | return shutil.which("tmux") is not None 86 | 87 | def get_or_create_session( 88 | self, 89 | session_id: str, 90 | work_dir: str, 91 | username: str | None = None, 92 | no_change_timeout_seconds: int | None = None, 93 | max_memory_mb: int | None = None, 94 | poll_interval: float | None = None, 95 | ) -> BashSession: 96 | """Get an existing session or create a new one. 97 | 98 | Args: 99 | session_id: Unique identifier for the session 100 | work_dir: Working directory for the session 101 | username: Username to run commands as 102 | no_change_timeout_seconds: Timeout for commands with no output changes 103 | max_memory_mb: Memory limit for the session 104 | poll_interval: Polling interval in seconds (default 0.5, use 0.1 for tests) 105 | 106 | Returns: 107 | BashSession instance 108 | 109 | Raises: 110 | RuntimeError: If tmux is not available 111 | """ 112 | # Check if tmux is available 113 | if not self.is_tmux_available(): 114 | raise RuntimeError( 115 | "tmux is not available on this system. Please install tmux to use session-based command execution." 116 | ) 117 | 118 | # Try to get existing session 119 | session = self._session_storage.get_session(session_id) 120 | if session is not None: 121 | return session 122 | 123 | # Create new session 124 | timeout = no_change_timeout_seconds or self.default_timeout_seconds 125 | interval = poll_interval if poll_interval is not None else 0.5 126 | session = BashSession( 127 | id=session_id, 128 | work_dir=work_dir, 129 | username=username, 130 | no_change_timeout_seconds=timeout, 131 | max_memory_mb=max_memory_mb, 132 | poll_interval=interval, 133 | ) 134 | 135 | # Store the session 136 | self._session_storage.set_session(session_id, session) 137 | 138 | return session 139 | 140 | def get_session(self, session_id: str) -> BashSession | None: 141 | """Get an existing session. 142 | 143 | Args: 144 | session_id: Unique identifier for the session 145 | 146 | Returns: 147 | BashSession instance if found, None otherwise 148 | """ 149 | return self._session_storage.get_session(session_id) 150 | 151 | def remove_session(self, session_id: str) -> bool: 152 | """Remove a session. 153 | 154 | Args: 155 | session_id: Unique identifier for the session 156 | 157 | Returns: 158 | True if session was removed, False if not found 159 | """ 160 | return self._session_storage.remove_session(session_id) 161 | 162 | def cleanup_expired_sessions(self, max_age_seconds: int | None = None) -> int: 163 | """Clean up sessions that haven't been accessed recently. 164 | 165 | Args: 166 | max_age_seconds: Maximum age in seconds before cleanup 167 | 168 | Returns: 169 | Number of sessions cleaned up 170 | """ 171 | max_age = max_age_seconds or self.default_session_timeout 172 | return self._session_storage.cleanup_expired_sessions(max_age) 173 | 174 | def get_session_count(self) -> int: 175 | """Get the number of active sessions. 176 | 177 | Returns: 178 | Number of active sessions 179 | """ 180 | return self._session_storage.get_session_count() 181 | 182 | def get_all_session_ids(self) -> list[str]: 183 | """Get all active session IDs. 184 | 185 | Returns: 186 | List of active session IDs 187 | """ 188 | return self._session_storage.get_all_session_ids() 189 | 190 | def clear_all_sessions(self) -> int: 191 | """Clear all sessions. 192 | 193 | Returns: 194 | Number of sessions cleared 195 | """ 196 | return self._session_storage.clear_all_sessions() 197 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/todo/__init__.py: -------------------------------------------------------------------------------- 1 | """Todo tools package for MCP Claude Code. 2 | 3 | This package provides tools for managing todo lists across different Claude Desktop sessions, 4 | using in-memory storage to maintain separate task lists for each conversation. 5 | """ 6 | 7 | from fastmcp import FastMCP 8 | 9 | from mcp_claude_code.tools.common.base import BaseTool, ToolRegistry 10 | from mcp_claude_code.tools.todo.todo_read import TodoReadTool 11 | from mcp_claude_code.tools.todo.todo_write import TodoWriteTool 12 | 13 | # Export all tool classes 14 | __all__ = [ 15 | "TodoReadTool", 16 | "TodoWriteTool", 17 | "get_todo_tools", 18 | "register_todo_tools", 19 | ] 20 | 21 | 22 | def get_todo_tools() -> list[BaseTool]: 23 | """Create instances of all todo tools. 24 | 25 | Returns: 26 | List of todo tool instances 27 | """ 28 | return [ 29 | TodoReadTool(), 30 | TodoWriteTool(), 31 | ] 32 | 33 | 34 | def register_todo_tools(mcp_server: FastMCP) -> list[BaseTool]: 35 | """Register all todo tools with the MCP server. 36 | 37 | Args: 38 | mcp_server: The FastMCP server instance 39 | 40 | Returns: 41 | List of registered tools 42 | """ 43 | tools = get_todo_tools() 44 | ToolRegistry.register_tools(mcp_server, tools) 45 | return tools 46 | -------------------------------------------------------------------------------- /mcp_claude_code/tools/todo/todo_read.py: -------------------------------------------------------------------------------- 1 | """TodoRead tool implementation. 2 | 3 | This module provides the TodoRead tool for reading the current todo list for a session. 4 | """ 5 | 6 | import json 7 | from typing import Annotated, TypedDict, Unpack, final, override 8 | 9 | from fastmcp import Context as MCPContext 10 | from fastmcp import FastMCP 11 | from fastmcp.server.dependencies import get_context 12 | from pydantic import Field 13 | 14 | from mcp_claude_code.tools.todo.base import TodoBaseTool, TodoStorage 15 | 16 | SessionId = Annotated[ 17 | str | int | float, 18 | Field( 19 | description="Unique identifier for the Claude Desktop session (generate using timestamp command)" 20 | ), 21 | ] 22 | 23 | 24 | class TodoReadToolParams(TypedDict): 25 | """Parameters for the TodoReadTool. 26 | 27 | Attributes: 28 | session_id: Unique identifier for the Claude Desktop session (generate using timestamp command) 29 | """ 30 | 31 | session_id: SessionId 32 | 33 | 34 | @final 35 | class TodoReadTool(TodoBaseTool): 36 | """Tool for reading the current todo list for a session.""" 37 | 38 | @property 39 | @override 40 | def name(self) -> str: 41 | """Get the tool name. 42 | 43 | Returns: 44 | Tool name 45 | """ 46 | return "todo_read" 47 | 48 | @property 49 | @override 50 | def description(self) -> str: 51 | """Get the tool description. 52 | 53 | Returns: 54 | Tool description 55 | """ 56 | return """Use this tool to read the current to-do list for the session. This tool should be used proactively and frequently to ensure that you are aware of 57 | the status of the current task list. You should make use of this tool as often as possible, especially in the following situations: 58 | - At the beginning of conversations to see what's pending 59 | - Before starting new tasks to prioritize work 60 | - When the user asks about previous tasks or plans 61 | - Whenever you're uncertain about what to do next 62 | - After completing tasks to update your understanding of remaining work 63 | - After every few messages to ensure you're on track 64 | 65 | Usage: 66 | - This tool requires a session_id parameter to identify the Claude Desktop conversation 67 | - Returns a list of todo items with their status, priority, and content 68 | - Use this information to track progress and plan next steps 69 | - If no todos exist yet for the session, an empty list will be returned""" 70 | 71 | @override 72 | async def call( 73 | self, 74 | ctx: MCPContext, 75 | **params: Unpack[TodoReadToolParams], 76 | ) -> str: 77 | """Execute the tool with the given parameters. 78 | 79 | Args: 80 | ctx: MCP context 81 | **params: Tool parameters 82 | 83 | Returns: 84 | Tool result 85 | """ 86 | tool_ctx = self.create_tool_context(ctx) 87 | self.set_tool_context_info(tool_ctx) 88 | 89 | # Extract parameters 90 | session_id = params.get("session_id") 91 | 92 | # Validate required parameters for direct calls (not through MCP framework) 93 | if session_id is None: 94 | await tool_ctx.error("Parameter 'session_id' is required but was None") 95 | return "Error: Parameter 'session_id' is required but was None" 96 | 97 | session_id = str(session_id) 98 | 99 | # Validate session ID 100 | is_valid, error_msg = self.validate_session_id(session_id) 101 | if not is_valid: 102 | await tool_ctx.error(f"Invalid session_id: {error_msg}") 103 | return f"Error: Invalid session_id: {error_msg}" 104 | 105 | await tool_ctx.info(f"Reading todos for session: {session_id}") 106 | 107 | try: 108 | # Get todos from storage 109 | todos = TodoStorage.get_todos(session_id) 110 | 111 | # Log status 112 | if todos: 113 | await tool_ctx.info( 114 | f"Found {len(todos)} todos for session {session_id}" 115 | ) 116 | else: 117 | await tool_ctx.info( 118 | f"No todos found for session {session_id} (returning empty list)" 119 | ) 120 | 121 | # Return todos as JSON string 122 | result = json.dumps(todos, indent=2) 123 | 124 | return result 125 | 126 | except Exception as e: 127 | await tool_ctx.error(f"Error reading todos: {str(e)}") 128 | return f"Error reading todos: {str(e)}" 129 | 130 | @override 131 | def register(self, mcp_server: FastMCP) -> None: 132 | """Register this todo read tool with the MCP server. 133 | 134 | Creates a wrapper function with explicitly defined parameters that match 135 | the tool's parameter schema and registers it with the MCP server. 136 | 137 | Args: 138 | mcp_server: The FastMCP server instance 139 | """ 140 | tool_self = self # Create a reference to self for use in the closure 141 | 142 | @mcp_server.tool(name=self.name, description=self.description) 143 | async def todo_read( 144 | ctx: MCPContext, 145 | session_id: SessionId, 146 | ) -> str: 147 | ctx = get_context() 148 | return await tool_self.call(ctx, session_id=session_id) 149 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mcp-claude-code" 7 | version = "0.4.0" 8 | description = "MCP implementation of Claude Code capabilities" 9 | readme = "README.md" 10 | requires-python = ">=3.12" 11 | license = { text = "MIT" } 12 | authors = [{ name = "SDGLBL", email = "sdglbl.me@gmail.com" }] 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | ] 18 | dependencies = [ 19 | "httpx>=0.27.0", 20 | "uvicorn>=0.23.1", 21 | "openai>=1.50.0", 22 | "python-dotenv>=1.0.0", 23 | "litellm<=1.67.1", 24 | "grep-ast>=0.8.1", 25 | "GitPython>=3.1.0", 26 | "fastmcp>=2.5.1", 27 | "libtmux>=0.15.0", 28 | "bashlex>=0.16", 29 | "pytest-asyncio>=0.26.0", 30 | ] 31 | 32 | [project.optional-dependencies] 33 | dev = ["pytest>=7.0.0", "pytest-cov>=4.1.0", "ruff>=0.1.0", "black>=23.3.0"] 34 | test = [ 35 | "pytest>=7.0.0", 36 | "pytest-cov>=4.1.0", 37 | "pytest-mock>=3.10.0", 38 | "pytest-asyncio>=0.25.3", 39 | "twisted", 40 | ] 41 | performance = ["ujson>=5.7.0", "orjson>=3.9.0"] 42 | 43 | [project.scripts] 44 | claudecode = "mcp_claude_code.cli:main" 45 | 46 | [tool.setuptools.packages.find] 47 | where = ["."] 48 | include = ["mcp_claude_code*"] 49 | 50 | 51 | [tool.basedpyright] 52 | include = ["mcp_claude_code"] 53 | 54 | exclude = [ 55 | "**/node_modules", 56 | "**/__pycache__", 57 | "src/experimental", 58 | "src/typestubs", 59 | ] 60 | 61 | pythonVersion = "3.13" 62 | reportUnknownVariableType = false 63 | reportUnknownMemberType = false 64 | reportUnknownParameterType = false 65 | reportUnusedCallResult = false 66 | reportIgnoreCommentWithoutRule = false 67 | reportUnusedParameter = false 68 | reportAny = false 69 | reportRedeclaration = false 70 | reportMissingTypeArgument = false 71 | reportExplicitAny = false 72 | reportUnusedFunction = false 73 | 74 | [tool.pytest.ini_options] 75 | asyncio_mode = "strict" 76 | asyncio_default_fixture_loop_scope = "function" 77 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests package for MCP Claude Code.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test fixtures for the MCP Claude Code project.""" 2 | 3 | import tempfile 4 | from pathlib import Path 5 | from unittest.mock import AsyncMock, MagicMock 6 | 7 | import pytest 8 | 9 | from mcp_claude_code.tools.common.context import ToolContext 10 | from mcp_claude_code.tools.common.permissions import PermissionManager 11 | from mcp_claude_code.tools.shell.bash_session_executor import BashSessionExecutor 12 | 13 | 14 | @pytest.fixture 15 | def temp_dir(): 16 | """Create a temporary directory for testing.""" 17 | with tempfile.TemporaryDirectory() as temp_dir: 18 | yield temp_dir 19 | 20 | 21 | @pytest.fixture 22 | def test_file(temp_dir): 23 | """Create a test file in the temporary directory.""" 24 | test_file_path = Path(temp_dir) / "test_file.txt" 25 | test_content = "This is a test file content.\nWith multiple lines.\n" 26 | 27 | with open(test_file_path, "w") as f: 28 | f.write(test_content) 29 | 30 | return str(test_file_path) 31 | 32 | 33 | @pytest.fixture 34 | def test_project_dir(temp_dir): 35 | """Create a simple test project structure.""" 36 | project_dir = Path(temp_dir) / "test_project" 37 | project_dir.mkdir() 38 | 39 | # Create Python files 40 | py_dir = project_dir / "src" / "module" 41 | py_dir.mkdir(parents=True) 42 | 43 | with open(py_dir / "__init__.py", "w") as f: 44 | f.write("# Module init\n") 45 | 46 | with open(py_dir / "main.py", "w") as f: 47 | f.write("import os\n\ndef main():\n print('Hello, world!')\n") 48 | 49 | # Create requirements.txt 50 | with open(project_dir / "requirements.txt", "w") as f: 51 | f.write("pytest==7.3.1\nmcp>=1.3.0\nhttpx>=0.27\n") 52 | 53 | # Create README 54 | with open(project_dir / "README.md", "w") as f: 55 | f.write("# Test Project\n\nThis is a test project for testing.\n") 56 | 57 | return str(project_dir) 58 | 59 | 60 | @pytest.fixture 61 | def permission_manager(): 62 | """Create a permission manager with a test path allowed.""" 63 | manager = PermissionManager() 64 | return manager 65 | 66 | 67 | @pytest.fixture 68 | def mcp_context(): 69 | """Mock MCP context for testing.""" 70 | mock_context = MagicMock() 71 | mock_context.info = AsyncMock() 72 | mock_context.error = AsyncMock() 73 | mock_context.warning = AsyncMock() 74 | mock_context.debug = AsyncMock() 75 | mock_context.report_progress = AsyncMock() 76 | mock_context.read_resource = AsyncMock() 77 | mock_context.request_id = "test-request-id" 78 | mock_context.client_id = "test-client-id" 79 | return mock_context 80 | 81 | 82 | @pytest.fixture 83 | def tool_context(mcp_context): 84 | """Create a tool context for testing.""" 85 | return ToolContext(mcp_context) 86 | 87 | 88 | @pytest.fixture 89 | def command_executor(permission_manager): 90 | """Create a command executor for testing.""" 91 | return BashSessionExecutor(permission_manager) 92 | -------------------------------------------------------------------------------- /tests/test_agent/.gitignore: -------------------------------------------------------------------------------- 1 | # Exclude OpenAI API tests that require API key 2 | test_openai_request.py 3 | 4 | # Exclude script that contains API key 5 | run_openai_test.sh 6 | -------------------------------------------------------------------------------- /tests/test_agent/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for agent tools.""" 2 | -------------------------------------------------------------------------------- /tests/test_agent/check_openai_api.py: -------------------------------------------------------------------------------- 1 | """Simple script to test OpenAI API connectivity.""" 2 | 3 | import sys 4 | from openai import OpenAI 5 | 6 | # Use the same API key from the script 7 | api_key = "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" 8 | 9 | 10 | def check_api_connection(): 11 | """Test a simple OpenAI API connection.""" 12 | print("Testing OpenAI API connection...") 13 | 14 | try: 15 | # Initialize the client with the API key 16 | client = OpenAI(api_key=api_key) 17 | 18 | # Make a simple request 19 | response = client.chat.completions.create( 20 | model="gpt-3.5-turbo", # Use a simpler model 21 | messages=[ 22 | {"role": "system", "content": "You are a helpful assistant."}, 23 | {"role": "user", "content": "Say hello!"}, 24 | ], 25 | max_tokens=10, # Request minimal tokens for a quick test 26 | ) 27 | 28 | # If we get here, the connection worked 29 | print("SUCCESS: API connection established!") 30 | print(f"Response: {response.choices[0].message.content}") 31 | return True 32 | 33 | except Exception as e: 34 | # Print detailed error information 35 | print(f"ERROR: {type(e).__name__}: {str(e)}") 36 | 37 | # Check for common error types and provide more helpful messages 38 | if "invalid_api_key" in str(e).lower() or "authentication" in str(e).lower(): 39 | print("\nPossible cause: The API key may be invalid or expired.") 40 | print("Solution: Obtain a new API key from the OpenAI dashboard.") 41 | 42 | elif "insufficient_quota" in str(e).lower(): 43 | print("\nPossible cause: Your OpenAI account may be out of credits.") 44 | print( 45 | "Solution: Check your usage and billing information in the OpenAI dashboard." 46 | ) 47 | 48 | elif "connection" in str(e).lower(): 49 | print("\nPossible cause: Network connectivity issue.") 50 | print("Solution: Check your internet connection and firewall settings.") 51 | print(" Some networks block OpenAI API calls.") 52 | print(" Try using a different network or VPN.") 53 | 54 | return False 55 | 56 | 57 | if __name__ == "__main__": 58 | success = check_api_connection() 59 | sys.exit(0 if success else 1) 60 | -------------------------------------------------------------------------------- /tests/test_agent/test_litellm_providers.py: -------------------------------------------------------------------------------- 1 | """Test LiteLLM with different providers.""" 2 | 3 | import os 4 | 5 | import pytest 6 | import litellm 7 | 8 | from mcp_claude_code.tools.agent.tool_adapter import convert_tools_to_openai_functions 9 | from mcp_claude_code.tools.common.base import BaseTool 10 | 11 | 12 | class EchoTool(BaseTool): 13 | """A simple tool that echoes back the input.""" 14 | 15 | @property 16 | def name(self) -> str: 17 | """Get the tool name.""" 18 | return "echo" 19 | 20 | @property 21 | def description(self) -> str: 22 | """Get the tool description.""" 23 | return "Echo back the input message." 24 | 25 | @property 26 | def parameters(self) -> dict: 27 | """Get the parameter specifications for the tool.""" 28 | return { 29 | "properties": { 30 | "message": { 31 | "type": "string", 32 | "description": "Message to echo back", 33 | }, 34 | }, 35 | "required": ["message"], 36 | "type": "object", 37 | } 38 | 39 | @property 40 | def required(self) -> list[str]: 41 | """Get the list of required parameter names.""" 42 | return ["message"] 43 | 44 | def register(self, ctx): 45 | """Register the tool with the context.""" 46 | # This is a required abstract method from BaseTool 47 | pass 48 | 49 | async def call(self, ctx, **params): 50 | """Execute the tool with the given parameters.""" 51 | message = params.get("message", "") 52 | return f"Echo: {message}" 53 | 54 | 55 | @pytest.fixture 56 | def echo_tool(): 57 | """Fixture for the EchoTool.""" 58 | return EchoTool() 59 | 60 | 61 | @pytest.mark.skipif( 62 | not os.environ.get("OPENAI_API_KEY"), 63 | reason="OPENAI_API_KEY environment variable not set", 64 | ) 65 | def test_convert_echo_tool_to_openai_functions(echo_tool): 66 | """Test convert_tools_to_openai_functions with echo_tool.""" 67 | openai_functions = convert_tools_to_openai_functions([echo_tool]) 68 | 69 | assert len(openai_functions) == 1 70 | assert openai_functions[0]["type"] == "function" 71 | assert openai_functions[0]["function"]["name"] == "echo" 72 | assert ( 73 | openai_functions[0]["function"]["description"] == "Echo back the input message." 74 | ) 75 | assert "parameters" in openai_functions[0]["function"] 76 | 77 | 78 | @pytest.mark.skipif( 79 | not os.environ.get("OPENAI_API_KEY"), 80 | reason="OPENAI_API_KEY environment variable not set", 81 | ) 82 | def test_litellm_openai_provider(): 83 | """Test LiteLLM with OpenAI provider.""" 84 | messages = [{"role": "user", "content": "Hello, how are you?"}] 85 | 86 | try: 87 | # Call OpenAI model with provider prefix 88 | response = litellm.completion( 89 | model="openai/gpt-3.5-turbo", 90 | messages=messages, 91 | ) 92 | 93 | assert response.choices[0].message.content is not None 94 | print(f"OpenAI response: {response.choices[0].message.content}") 95 | except Exception as e: 96 | pytest.skip(f"OpenAI API connection failed: {type(e).__name__} - {str(e)}") 97 | 98 | 99 | @pytest.mark.skipif( 100 | not os.environ.get("ANTHROPIC_API_KEY"), 101 | reason="ANTHROPIC_API_KEY environment variable not set", 102 | ) 103 | def test_litellm_anthropic_provider(): 104 | """Test LiteLLM with Anthropic provider.""" 105 | messages = [{"role": "user", "content": "Hello, how are you?"}] 106 | 107 | try: 108 | # Call Anthropic model with provider prefix 109 | response = litellm.completion( 110 | model="anthropic/claude-3-haiku-20240307", 111 | messages=messages, 112 | ) 113 | 114 | assert response.choices[0].message.content is not None 115 | print(f"Anthropic response: {response.choices[0].message.content}") 116 | except Exception as e: 117 | pytest.skip(f"Anthropic API connection failed: {type(e).__name__} - {str(e)}") 118 | 119 | 120 | # Only run this test if explicitly requested with pytest -xvs tests/test_agent/test_litellm_providers.py 121 | if __name__ == "__main__": 122 | pytest.main(["-xvs", __file__]) 123 | -------------------------------------------------------------------------------- /tests/test_agent/test_model_capabilities.py: -------------------------------------------------------------------------------- 1 | """Tests for model capability checking functions.""" 2 | 3 | from unittest.mock import patch 4 | 5 | from mcp_claude_code.tools.agent.tool_adapter import ( 6 | supports_parallel_function_calling, 7 | ) 8 | 9 | 10 | class TestModelCapabilities: 11 | """Tests for model capability checking functions.""" 12 | 13 | @patch("litellm.supports_parallel_function_calling") 14 | def test_supports_parallel_function_calling(self, mock_litellm_supports_parallel): 15 | """Test that supports_parallel_function_calling properly calls litellm.""" 16 | # Set up the mock 17 | mock_litellm_supports_parallel.return_value = True 18 | 19 | # Test with a model that supports parallel function calling 20 | assert supports_parallel_function_calling("gpt-4-turbo-preview") is True 21 | mock_litellm_supports_parallel.assert_called_with(model="gpt-4-turbo-preview") 22 | 23 | # Test with a provider-prefixed model 24 | mock_litellm_supports_parallel.reset_mock() 25 | mock_litellm_supports_parallel.return_value = True 26 | assert supports_parallel_function_calling("openai/gpt-4-turbo-preview") is True 27 | mock_litellm_supports_parallel.assert_called_with( 28 | model="openai/gpt-4-turbo-preview" 29 | ) 30 | 31 | # Test with a model that doesn't support parallel function calling 32 | mock_litellm_supports_parallel.reset_mock() 33 | mock_litellm_supports_parallel.return_value = False 34 | assert supports_parallel_function_calling("gpt-4") is False 35 | mock_litellm_supports_parallel.assert_called_with(model="gpt-4") 36 | -------------------------------------------------------------------------------- /tests/test_agent/test_prompt.py: -------------------------------------------------------------------------------- 1 | """Tests for the agent tool prompt module.""" 2 | 3 | import os 4 | from unittest.mock import MagicMock 5 | 6 | import pytest 7 | 8 | from mcp_claude_code.tools.agent.prompt import ( 9 | get_allowed_agent_tools, 10 | get_default_model, 11 | get_model_parameters, 12 | get_system_prompt, 13 | ) 14 | from mcp_claude_code.tools.common.base import BaseTool 15 | from mcp_claude_code.tools.common.permissions import PermissionManager 16 | 17 | 18 | class TestPrompt: 19 | """Test cases for the agent tool prompt module.""" 20 | 21 | @pytest.fixture 22 | def permission_manager(self): 23 | """Create a test permission manager.""" 24 | return MagicMock(spec=PermissionManager) 25 | 26 | @pytest.fixture 27 | def mock_tools(self): 28 | """Create a list of mock tools.""" 29 | tools = [] 30 | 31 | # Create a read-only tool 32 | read_tool = MagicMock(spec=BaseTool) 33 | read_tool.name = "read" 34 | read_tool.description = "Read files" 35 | read_tool.isReadOnly = MagicMock(return_value=True) 36 | read_tool.needsPermissions = MagicMock(return_value=False) 37 | tools.append(read_tool) 38 | 39 | # Create a non-read-only tool 40 | write_tool = MagicMock(spec=BaseTool) 41 | write_tool.name = "write" 42 | write_tool.description = "Write to files" 43 | write_tool.isReadOnly = MagicMock(return_value=False) 44 | write_tool.needsPermissions = MagicMock(return_value=True) 45 | tools.append(write_tool) 46 | 47 | # Create a tool that needs permissions 48 | cmd_tool = MagicMock(spec=BaseTool) 49 | cmd_tool.name = "run_command" 50 | cmd_tool.description = "Run shell commands" 51 | cmd_tool.isReadOnly = MagicMock(return_value=False) 52 | cmd_tool.needsPermissions = MagicMock(return_value=True) 53 | tools.append(cmd_tool) 54 | 55 | # Create an agent tool (should be filtered out to prevent recursion) 56 | agent_tool = MagicMock(spec=BaseTool) 57 | agent_tool.name = "agent" 58 | agent_tool.description = "Launch agent" 59 | agent_tool.isReadOnly = MagicMock(return_value=True) 60 | agent_tool.needsPermissions = MagicMock(return_value=False) 61 | tools.append(agent_tool) 62 | 63 | return tools 64 | 65 | def test_get_allowed_agent_tools(self, mock_tools, permission_manager): 66 | """Test get_allowed_agent_tools only filters out the agent tool.""" 67 | # Get allowed tools 68 | allowed_tools = get_allowed_agent_tools(mock_tools, permission_manager) 69 | 70 | # Should include all tools except for agent 71 | assert len(allowed_tools) == 3 72 | assert "read" in [tool.name for tool in allowed_tools] 73 | assert "write" in [tool.name for tool in allowed_tools] 74 | assert "run_command" in [tool.name for tool in allowed_tools] 75 | assert "agent" not in [tool.name for tool in allowed_tools] 76 | 77 | def test_get_system_prompt(self, mock_tools, permission_manager): 78 | """Test get_system_prompt includes all tools except agent.""" 79 | # Get system prompt 80 | system_prompt = get_system_prompt(mock_tools, permission_manager) 81 | 82 | # Should mention all tools except agent 83 | assert "`read`" in system_prompt 84 | assert "`write`" in system_prompt 85 | assert "`run_command`" in system_prompt 86 | assert "`agent`" not in system_prompt 87 | 88 | # Should mention read-only limitation 89 | assert "read-only tools" in system_prompt 90 | assert "you cannot modify files or execute commands" in system_prompt 91 | 92 | def test_get_default_model(self): 93 | """Test get_default_model.""" 94 | # Test with environment variable 95 | os.environ["AGENT_MODEL"] = "test-model-123" 96 | assert get_default_model() == "test-model-123" 97 | 98 | # Test with model override - explicitly with TEST_MODE to avoid provider prefix 99 | os.environ["TEST_MODE"] = "1" 100 | assert get_default_model("openai/gpt-4o") == "openai/gpt-4o" 101 | assert ( 102 | get_default_model("gpt-4o-mini") == "gpt-4o-mini" 103 | ) # In test mode, no prefix added 104 | assert ( 105 | get_default_model("anthropic/claude-3-sonnet") 106 | == "anthropic/claude-3-sonnet" 107 | ) 108 | 109 | # Test with provider prefixing in non-test mode 110 | del os.environ["TEST_MODE"] 111 | assert get_default_model("gpt-4") == "openai/gpt-4" 112 | 113 | # Test default 114 | del os.environ["AGENT_MODEL"] 115 | assert get_default_model() == "openai/gpt-4o" 116 | 117 | def test_get_model_parameters(self): 118 | """Test get_model_parameters.""" 119 | # Test with environment variables 120 | os.environ["AGENT_TEMPERATURE"] = "0.5" 121 | os.environ["AGENT_API_TIMEOUT"] = "30" 122 | os.environ["AGENT_MAX_TOKENS"] = "2000" 123 | 124 | params = get_model_parameters() 125 | assert params["temperature"] == 0.5 126 | assert params["timeout"] == 30 127 | assert params["max_tokens"] == 2000 128 | 129 | # Test with max_tokens override 130 | params = get_model_parameters(max_tokens=1500) 131 | assert params["temperature"] == 0.5 132 | assert params["timeout"] == 30 133 | assert params["max_tokens"] == 1500 # Override takes precedence 134 | 135 | # Test defaults 136 | del os.environ["AGENT_TEMPERATURE"] 137 | del os.environ["AGENT_API_TIMEOUT"] 138 | del os.environ["AGENT_MAX_TOKENS"] 139 | 140 | params = get_model_parameters() 141 | assert params["temperature"] == 0.7 142 | assert params["timeout"] == 60 143 | assert "max_tokens" not in params # Not set when not provided 144 | 145 | # Test with only max_tokens override 146 | params = get_model_parameters(max_tokens=1000) 147 | assert params["temperature"] == 0.7 148 | assert params["timeout"] == 60 149 | assert params["max_tokens"] == 1000 150 | -------------------------------------------------------------------------------- /tests/test_agent/test_tool_adapter.py: -------------------------------------------------------------------------------- 1 | """Tests for the agent tool adapter module.""" 2 | 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | 7 | from mcp_claude_code.tools.agent.tool_adapter import ( 8 | convert_tool_parameters, 9 | convert_tools_to_openai_functions, 10 | ) 11 | from mcp_claude_code.tools.common.base import BaseTool 12 | 13 | 14 | class TestToolAdapter: 15 | """Test cases for the agent tool adapter module.""" 16 | 17 | @pytest.fixture 18 | def mock_tool(self): 19 | """Create a mock tool.""" 20 | tool = MagicMock(spec=BaseTool) 21 | tool.name = "read" 22 | tool.description = "Read files from the file system" 23 | tool.parameters = { 24 | "properties": { 25 | "file_path": { 26 | "anyOf": [ 27 | {"items": {"type": "string"}, "type": "array"}, 28 | {"type": "string"}, 29 | ], 30 | "title": "File Path", 31 | }, 32 | }, 33 | "required": ["file_path"], 34 | "title": "readArguments", 35 | "type": "object", 36 | } 37 | tool.required = ["file_path"] 38 | return tool 39 | 40 | @pytest.fixture 41 | def mock_simple_tool(self): 42 | """Create a mock tool with minimal parameters.""" 43 | tool = MagicMock(spec=BaseTool) 44 | tool.name = "think" 45 | tool.description = "Think about something" 46 | tool.parameters = { 47 | "properties": { 48 | "thought": { 49 | "title": "Thought", 50 | "type": "string", 51 | }, 52 | }, 53 | } 54 | tool.required = ["thought"] 55 | return tool 56 | 57 | def test_convert_tools_to_openai_functions(self, mock_tool, mock_simple_tool): 58 | """Test convert_tools_to_openai_functions.""" 59 | # Convert tools 60 | openai_functions = convert_tools_to_openai_functions( 61 | [mock_tool, mock_simple_tool] 62 | ) 63 | 64 | # Verify result 65 | assert len(openai_functions) == 2 66 | 67 | # Check first tool 68 | assert openai_functions[0]["type"] == "function" 69 | assert openai_functions[0]["function"]["name"] == "read" 70 | assert ( 71 | openai_functions[0]["function"]["description"] 72 | == "Read files from the file system" 73 | ) 74 | assert "parameters" in openai_functions[0]["function"] 75 | 76 | # Check second tool 77 | assert openai_functions[1]["type"] == "function" 78 | assert openai_functions[1]["function"]["name"] == "think" 79 | assert openai_functions[1]["function"]["description"] == "Think about something" 80 | assert "parameters" in openai_functions[1]["function"] 81 | 82 | def test_convert_tool_parameters_complete(self, mock_tool): 83 | """Test convert_tool_parameters with complete parameters.""" 84 | # Convert parameters 85 | params = convert_tool_parameters(mock_tool) 86 | 87 | # Verify result 88 | assert params["type"] == "object" 89 | assert "properties" in params 90 | assert "file_path" in params["properties"] 91 | assert params["required"] == ["file_path"] 92 | 93 | def test_convert_tool_parameters_minimal(self, mock_simple_tool): 94 | """Test convert_tool_parameters with minimal parameters.""" 95 | # Convert parameters 96 | params = convert_tool_parameters(mock_simple_tool) 97 | 98 | # Verify result 99 | assert params["type"] == "object" 100 | assert "properties" in params 101 | assert "thought" in params["properties"] 102 | assert params["required"] == ["thought"] 103 | -------------------------------------------------------------------------------- /tests/test_common/__init__.py: -------------------------------------------------------------------------------- 1 | """Common test modules package.""" 2 | -------------------------------------------------------------------------------- /tests/test_common/test_batch_tool.py: -------------------------------------------------------------------------------- 1 | """Test batch tool functionality.""" 2 | 3 | from unittest.mock import AsyncMock, MagicMock 4 | 5 | import pytest 6 | from fastmcp import Context as MCPContext 7 | 8 | from mcp_claude_code.tools.common.base import BaseTool 9 | from mcp_claude_code.tools.common.batch_tool import BatchTool 10 | 11 | 12 | class MockTool(BaseTool): 13 | """Mock tool for testing.""" 14 | 15 | def __init__(self, name, result="mock result"): 16 | self._name = name 17 | self._result = result 18 | self.call_count = 0 19 | 20 | @property 21 | def name(self) -> str: 22 | return self._name 23 | 24 | @property 25 | def description(self) -> str: 26 | return f"Mock {self._name} tool" 27 | 28 | @property 29 | def parameters(self) -> dict: 30 | return { 31 | "properties": {"param1": {"type": "string"}, "param2": {"type": "integer"}}, 32 | "required": ["param1"], 33 | "type": "object", 34 | } 35 | 36 | @property 37 | def required(self) -> list[str]: 38 | return ["param1"] 39 | 40 | async def call(self, ctx, **params): 41 | self.call_count += 1 42 | return f"{self._result}: {params}" 43 | 44 | def register(self, mcp_server): 45 | pass 46 | 47 | 48 | class FailingMockTool(MockTool): 49 | """Mock tool that fails when called.""" 50 | 51 | async def call(self, ctx, **params): 52 | self.call_count += 1 53 | raise ValueError("Mock error") 54 | 55 | 56 | @pytest.fixture 57 | def mock_context(): 58 | """Create a mock MCP context.""" 59 | context = MagicMock(spec=MCPContext) 60 | context.info = AsyncMock() 61 | context.error = AsyncMock() 62 | context.warning = AsyncMock() 63 | return context 64 | 65 | 66 | @pytest.fixture 67 | def tools_dict(): 68 | """Create a dictionary of mock tools.""" 69 | return { 70 | "tool1": MockTool("tool1", "tool1 result"), 71 | "tool2": MockTool("tool2", "tool2 result"), 72 | "failing_tool": FailingMockTool("failing_tool"), 73 | } 74 | 75 | 76 | @pytest.fixture 77 | def batch_tool(tools_dict): 78 | """Create a BatchTool instance with mock tools.""" 79 | return BatchTool(tools_dict) 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_batch_tool_execution(mock_context, batch_tool): 84 | """Test that batch tool executes all tools correctly.""" 85 | # Define batch parameters 86 | params = { 87 | "description": "Test batch", 88 | "invocations": [ 89 | {"tool_name": "tool1", "input": {"param1": "value1", "param2": 42}}, 90 | {"tool_name": "tool2", "input": {"param1": "value2"}}, 91 | ], 92 | } 93 | 94 | # Execute batch tool 95 | result = await batch_tool.call(mock_context, **params) 96 | 97 | # Check that all tools were called 98 | assert batch_tool.tools["tool1"].call_count == 1 99 | assert batch_tool.tools["tool2"].call_count == 1 100 | 101 | # Check that the result contains outputs from both tools 102 | assert "tool1 result" in result 103 | assert "tool2 result" in result 104 | assert "value1" in result 105 | assert "value2" in result 106 | 107 | 108 | @pytest.mark.asyncio 109 | async def test_batch_tool_with_missing_tool(mock_context, batch_tool): 110 | """Test batch tool with a non-existent tool.""" 111 | params = { 112 | "description": "Test batch with missing tool", 113 | "invocations": [ 114 | {"tool_name": "nonexistent_tool", "input": {"param1": "value1"}} 115 | ], 116 | } 117 | 118 | # Execute batch tool 119 | result = await batch_tool.call(mock_context, **params) 120 | 121 | # Check that the error message is in the result 122 | assert "Tool 'nonexistent_tool' not found" in result 123 | 124 | # Check that the error was logged 125 | mock_context.error.assert_called_once() 126 | 127 | 128 | @pytest.mark.asyncio 129 | async def test_batch_tool_with_failing_tool(mock_context, batch_tool): 130 | """Test batch tool with a tool that fails.""" 131 | params = { 132 | "description": "Test batch with failing tool", 133 | "invocations": [ 134 | {"tool_name": "tool1", "input": {"param1": "value1"}}, 135 | {"tool_name": "failing_tool", "input": {"param1": "value2"}}, 136 | ], 137 | } 138 | 139 | # Execute batch tool 140 | result = await batch_tool.call(mock_context, **params) 141 | 142 | # Check that both tools were called 143 | assert batch_tool.tools["tool1"].call_count == 1 144 | assert batch_tool.tools["failing_tool"].call_count == 1 145 | 146 | # Check that the successful tool's result is in the output 147 | assert "tool1 result" in result 148 | 149 | # Check that the error message is in the result 150 | assert "Error executing tool 'failing_tool'" in result 151 | assert "Mock error" in result 152 | 153 | 154 | @pytest.mark.asyncio 155 | async def test_batch_tool_with_invalid_params(mock_context, batch_tool): 156 | """Test batch tool with invalid parameters.""" 157 | # Test with missing description 158 | params1 = {"invocations": [{"tool_name": "tool1", "input": {"param1": "value1"}}]} 159 | 160 | result1 = await batch_tool.call(mock_context, **params1) 161 | assert "Parameter 'description' is required" in result1 162 | 163 | # Test with missing invocations 164 | params2 = {"description": "Test batch"} 165 | 166 | result2 = await batch_tool.call(mock_context, **params2) 167 | assert "Parameter 'invocations' is required" in result2 168 | 169 | # Test with empty invocations list 170 | params3 = {"description": "Test batch", "invocations": []} 171 | 172 | result3 = await batch_tool.call(mock_context, **params3) 173 | assert "Parameter 'invocations'" in result3 and "empty" in result3 174 | -------------------------------------------------------------------------------- /tests/test_common/test_context.py: -------------------------------------------------------------------------------- 1 | """Tests for the context module.""" 2 | 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | 7 | from mcp_claude_code.tools.common.context import ( 8 | ToolContext, 9 | create_tool_context, 10 | ) 11 | 12 | 13 | class TestToolContext: 14 | """Test the ToolContext class.""" 15 | 16 | def test_initialization(self, mcp_context: MagicMock): 17 | """Test initializing a ToolContext.""" 18 | tool_context = ToolContext(mcp_context) 19 | 20 | assert tool_context.mcp_context == mcp_context 21 | assert tool_context.request_id == mcp_context.request_id 22 | assert tool_context.client_id == mcp_context.client_id 23 | 24 | def test_set_tool_info(self, mcp_context: MagicMock): 25 | """Test setting tool info.""" 26 | tool_context = ToolContext(mcp_context) 27 | tool_name = "test_tool" 28 | execution_id = "123456" 29 | 30 | tool_context.set_tool_info(tool_name, execution_id) 31 | 32 | # Test internal state 33 | assert tool_context._tool_name == tool_name 34 | assert tool_context._execution_id == execution_id 35 | 36 | @pytest.mark.asyncio 37 | async def test_logging_methods(self, mcp_context: MagicMock): 38 | """Test logging methods.""" 39 | tool_context = ToolContext(mcp_context) 40 | tool_context.set_tool_info("test_tool") 41 | 42 | # Test info logging 43 | await tool_context.info("Test info") 44 | mcp_context.info.assert_called_once_with("[test_tool] Test info") 45 | 46 | # Test debug logging 47 | await tool_context.debug("Test debug") 48 | mcp_context.debug.assert_called_once_with("[test_tool] Test debug") 49 | 50 | # Test warning logging 51 | await tool_context.warning("Test warning") 52 | mcp_context.warning.assert_called_once_with("[test_tool] Test warning") 53 | 54 | # Test error logging 55 | await tool_context.error("Test error") 56 | mcp_context.error.assert_called_once_with("[test_tool] Test error") 57 | 58 | def test_format_message(self, mcp_context: MagicMock): 59 | """Test message formatting.""" 60 | tool_context = ToolContext(mcp_context) 61 | 62 | # No tool info 63 | message = tool_context._format_message("Test message") 64 | assert message == "Test message" 65 | 66 | # With tool name 67 | tool_context.set_tool_info("test_tool") 68 | message = tool_context._format_message("Test message") 69 | assert message == "[test_tool] Test message" 70 | 71 | # With tool name and execution id 72 | tool_context.set_tool_info("test_tool", "123456") 73 | message = tool_context._format_message("Test message") 74 | assert message == "[test_tool:123456] Test message" 75 | 76 | @pytest.mark.asyncio 77 | async def test_report_progress(self, mcp_context: MagicMock): 78 | """Test progress reporting.""" 79 | tool_context = ToolContext(mcp_context) 80 | 81 | await tool_context.report_progress(50, 100) 82 | mcp_context.report_progress.assert_called_once_with(50, 100) 83 | 84 | @pytest.mark.asyncio 85 | async def test_read_resource(self, mcp_context: MagicMock): 86 | """Test reading a resource.""" 87 | tool_context = ToolContext(mcp_context) 88 | 89 | await tool_context.read_resource("resource://test") 90 | mcp_context.read_resource.assert_called_once_with("resource://test") 91 | 92 | 93 | def test_create_tool_context(mcp_context: MagicMock): 94 | """Test creating a tool context.""" 95 | tool_context = create_tool_context(mcp_context) 96 | 97 | assert isinstance(tool_context, ToolContext) 98 | assert tool_context.mcp_context == mcp_context 99 | -------------------------------------------------------------------------------- /tests/test_common/test_hidden_files.py: -------------------------------------------------------------------------------- 1 | """Tests for hidden files (.dot files) in the permissions module.""" 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | 7 | from mcp_claude_code.tools.common.permissions import PermissionManager 8 | 9 | 10 | class TestHiddenFilePermissions: 11 | """Test permission handling for hidden files and directories.""" 12 | 13 | def test_dotfile_exclusion_behavior(self, temp_dir: str): 14 | """Test that dotfiles are properly handled in the permission system. 15 | 16 | This test verifies that files with dots in their names are not incorrectly 17 | excluded just because they contain a substring that matches an excluded pattern. 18 | """ 19 | manager = PermissionManager() 20 | manager.add_allowed_path(temp_dir) 21 | 22 | # Create test paths 23 | problem_path = os.path.join(temp_dir, ".github-workflow-example.yml") 24 | actual_github_dir = os.path.join(temp_dir, ".github", "workflows", "ci.yml") 25 | gitignore_file = os.path.join(temp_dir, ".gitignore") 26 | git_related_file = os.path.join(temp_dir, "git-tutorial.md") 27 | 28 | # Test paths with the fixed implementation 29 | # The workflow example and gitignore files should be allowed 30 | assert manager.is_path_allowed(problem_path), ( 31 | "Should allow .github-workflow-example.yml" 32 | ) 33 | assert manager.is_path_allowed(gitignore_file), "Should allow .gitignore" 34 | assert manager.is_path_allowed(git_related_file), "Should allow git-tutorial.md" 35 | 36 | # Since .git is now allowed by default, this should also be allowed 37 | assert manager.is_path_allowed(actual_github_dir), ( 38 | "Should allow actual .github directory" 39 | ) 40 | 41 | def test_various_hidden_files(self, temp_dir: str): 42 | """Test a variety of hidden files and paths to ensure correct behavior.""" 43 | manager = PermissionManager() 44 | manager.add_allowed_path(temp_dir) 45 | 46 | # Files that should be allowed (not matching default exclusions) 47 | allowed_paths = [ 48 | os.path.join(temp_dir, ".hidden_file.txt"), 49 | os.path.join(temp_dir, "subdir", ".config-example.yml"), 50 | os.path.join(temp_dir, ".env-sample"), 51 | os.path.join(temp_dir, ".gitconfig-user"), 52 | os.path.join(temp_dir, ".github-actions-example.json"), 53 | os.path.join(temp_dir, ".git", "config"), # .git now allowed 54 | ] 55 | 56 | # Files that should be excluded (matching default exclusions) 57 | excluded_paths = [ 58 | # os.path.join(temp_dir, ".git", "config"), # .git now allowed 59 | # os.path.join(temp_dir, ".vscode", "settings.json"), # .vscode now allowed 60 | os.path.join(temp_dir, ".env"), 61 | os.path.join(temp_dir, "logs", "app.log"), 62 | ] 63 | 64 | # Test allowed paths 65 | for path in allowed_paths: 66 | assert manager.is_path_allowed(path), f"Should allow: {path}" 67 | 68 | # Test excluded paths 69 | for path in excluded_paths: 70 | assert not manager.is_path_allowed(path), f"Should exclude: {path}" 71 | 72 | def test_path_component_matching(self, temp_dir: str): 73 | """Test that path component matching works correctly.""" 74 | manager = PermissionManager() 75 | manager.add_allowed_path(temp_dir) 76 | 77 | # Add a custom exclusion pattern 78 | manager.add_exclusion_pattern("exclude_me") 79 | 80 | # Paths with the pattern as a full component (should be excluded) 81 | full_component_paths = [ 82 | os.path.join(temp_dir, "exclude_me"), 83 | os.path.join(temp_dir, "exclude_me", "file.txt"), 84 | os.path.join(temp_dir, "subdir", "exclude_me", "config.json"), 85 | ] 86 | 87 | # Paths with the pattern as part of a component (should be allowed) 88 | partial_component_paths = [ 89 | os.path.join(temp_dir, "exclude_me_not.txt"), 90 | os.path.join(temp_dir, "not_exclude_me", "file.txt"), 91 | os.path.join(temp_dir, "prefix_exclude_me_suffix.json"), 92 | ] 93 | 94 | # Test full component paths (should be excluded) 95 | for path in full_component_paths: 96 | assert not manager.is_path_allowed(path), ( 97 | f"Should exclude full component: {path}" 98 | ) 99 | 100 | # Test partial component paths (should be allowed) 101 | for path in partial_component_paths: 102 | assert manager.is_path_allowed(path), ( 103 | f"Should allow partial component: {path}" 104 | ) 105 | 106 | def test_wildcard_patterns(self, temp_dir: str): 107 | """Test that wildcard patterns work correctly.""" 108 | manager = PermissionManager() 109 | manager.add_allowed_path(temp_dir) 110 | 111 | # Default patterns include "*.log", "*.key", etc. 112 | 113 | # Files matching wildcard patterns (should be excluded) 114 | wildcard_matches = [ 115 | os.path.join(temp_dir, "server.log"), 116 | os.path.join(temp_dir, "private.key"), 117 | os.path.join(temp_dir, "certificate.crt"), 118 | os.path.join(temp_dir, "database.sqlite"), 119 | ] 120 | 121 | # Files not matching wildcard patterns (should be allowed) 122 | wildcard_non_matches = [ 123 | os.path.join(temp_dir, "logfile.txt"), # Doesn't end with .log 124 | os.path.join(temp_dir, "key_material.txt"), # Doesn't end with .key 125 | os.path.join(temp_dir, "log_analysis.py"), # Doesn't end with .log 126 | ] 127 | 128 | # Test wildcard matching paths (should be excluded) 129 | for path in wildcard_matches: 130 | assert not manager.is_path_allowed(path), ( 131 | f"Should exclude wildcard match: {path}" 132 | ) 133 | 134 | # Test non-matching paths (should be allowed) 135 | for path in wildcard_non_matches: 136 | assert manager.is_path_allowed(path), f"Should allow non-matching: {path}" 137 | 138 | def test_real_world_project_paths(self, temp_dir: str): 139 | """Test with realistic project paths that might be problematic.""" 140 | manager = PermissionManager() 141 | base_dir = "/Users/lijie/project/mcp-claude-code" 142 | manager.add_allowed_path(base_dir) 143 | 144 | # These should all be allowed with the fixed implementation 145 | allowed_project_paths = [ 146 | f"{base_dir}/.github-workflow-example.yml", 147 | f"{base_dir}/.gitignore", 148 | f"{base_dir}/.python-version", 149 | f"{base_dir}/.editorconfig", 150 | f"{base_dir}/.pre-commit-config.yaml", 151 | f"{base_dir}/.env.sample", 152 | f"{base_dir}/.devcontainer/config.json", 153 | ] 154 | 155 | # These should still be excluded (matching system exclusions) 156 | excluded_project_paths = [ 157 | # f"{base_dir}/.git/HEAD", # .git now allowed 158 | # f"{base_dir}/.vscode/settings.json", # .vscode now allowed 159 | f"{base_dir}/.env", 160 | f"{base_dir}/logs/debug.log", 161 | f"{base_dir}/__pycache__/module.pyc", 162 | ] 163 | 164 | # Mock the permissions check to avoid actual filesystem access 165 | # This simulates what would happen with the real project paths 166 | def mock_is_allowed(path): 167 | path_obj = Path(path).resolve() 168 | # Skip the actual "is path in allowed_paths" check for testing 169 | return not manager._is_path_excluded(path_obj) 170 | 171 | # Test allowed project paths 172 | for path in allowed_project_paths: 173 | assert mock_is_allowed(path), f"Should allow project path: {path}" 174 | 175 | # Test excluded project paths 176 | for path in excluded_project_paths: 177 | assert not mock_is_allowed(path), f"Should exclude project path: {path}" 178 | -------------------------------------------------------------------------------- /tests/test_common/test_mcp_description.py: -------------------------------------------------------------------------------- 1 | """Tests for the BaseTool mcp_description method. 2 | 3 | This module contained tests for the mcp_description method of the BaseTool class, 4 | but that functionality has been removed in favor of pydantic Field annotations. 5 | These tests are now obsolete. 6 | """ 7 | 8 | from unittest.mock import MagicMock 9 | 10 | import pytest 11 | 12 | 13 | from mcp_claude_code.tools.common.permissions import PermissionManager 14 | from mcp_claude_code.tools.common.thinking_tool import ThinkingTool 15 | from mcp_claude_code.tools.filesystem.read import ReadTool 16 | 17 | 18 | class TestMCPDescription: 19 | """Test cases for the BaseTool.mcp_description method (OBSOLETE).""" 20 | 21 | @pytest.fixture 22 | def permission_manager(self): 23 | """Create a test permission manager.""" 24 | return MagicMock(spec=PermissionManager) 25 | 26 | @pytest.fixture 27 | def thinking_tool(self): 28 | """Create a thinking tool.""" 29 | return ThinkingTool() 30 | 31 | @pytest.fixture 32 | def read_files_tool(self, permission_manager): 33 | """Create a read files tool.""" 34 | return ReadTool(permission_manager) 35 | 36 | def test_tools_have_basic_properties(self, thinking_tool, read_files_tool): 37 | """Test that tools still have basic properties after refactor.""" 38 | # Verify basic tool properties still exist 39 | assert thinking_tool.name == "think" 40 | assert "think" in thinking_tool.description.lower() 41 | 42 | assert read_files_tool.name == "read" 43 | assert "read" in read_files_tool.description.lower() 44 | -------------------------------------------------------------------------------- /tests/test_common/test_permissions.py: -------------------------------------------------------------------------------- 1 | """Tests for the permissions module.""" 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from mcp_claude_code.tools.common.permissions import ( 9 | PermissibleOperation, 10 | PermissionManager, 11 | ) 12 | 13 | 14 | class TestPermissionManager: 15 | """Test the PermissionManager class.""" 16 | 17 | def test_add_allowed_path(self, temp_dir: str): 18 | """Test adding an allowed path.""" 19 | manager = PermissionManager() 20 | manager.add_allowed_path(temp_dir) 21 | 22 | assert Path(temp_dir).resolve() in manager.allowed_paths 23 | 24 | def test_remove_allowed_path(self, temp_dir: str): 25 | """Test removing an allowed path.""" 26 | manager = PermissionManager() 27 | manager.add_allowed_path(temp_dir) 28 | manager.remove_allowed_path(temp_dir) 29 | 30 | assert Path(temp_dir).resolve() not in manager.allowed_paths 31 | 32 | def test_exclude_path(self, temp_dir: str): 33 | """Test excluding a path.""" 34 | manager = PermissionManager() 35 | manager.exclude_path(temp_dir) 36 | 37 | assert Path(temp_dir).resolve() in manager.excluded_paths 38 | 39 | def test_add_exclusion_pattern(self): 40 | """Test adding an exclusion pattern.""" 41 | manager = PermissionManager() 42 | pattern = "secret_*" 43 | manager.add_exclusion_pattern(pattern) 44 | 45 | assert pattern in manager.excluded_patterns 46 | 47 | def test_is_path_allowed_with_allowed_path(self, temp_dir: str): 48 | """Test checking if an allowed path is allowed.""" 49 | manager = PermissionManager() 50 | manager.add_allowed_path(temp_dir) 51 | 52 | test_file = os.path.join(temp_dir, "test.txt") 53 | 54 | assert manager.is_path_allowed(test_file) 55 | 56 | def test_is_path_allowed_with_disallowed_path(self, temp_dir: str): 57 | """Test checking if a disallowed path is allowed.""" 58 | manager = PermissionManager() 59 | 60 | assert manager.is_path_allowed(temp_dir) 61 | 62 | def test_is_path_allowed_with_excluded_path(self, temp_dir: str): 63 | """Test checking if an excluded path is allowed.""" 64 | manager = PermissionManager() 65 | manager.add_allowed_path(temp_dir) 66 | manager.exclude_path(temp_dir) 67 | 68 | assert not manager.is_path_allowed(temp_dir) 69 | 70 | def test_is_path_allowed_with_excluded_pattern(self, temp_dir: str): 71 | """Test checking if a path matching an excluded pattern is allowed.""" 72 | manager = PermissionManager() 73 | manager.add_allowed_path(temp_dir) 74 | 75 | secret_file = os.path.join(temp_dir, "secret_data.txt") 76 | manager.add_exclusion_pattern("secret_") 77 | 78 | assert manager.is_path_allowed(secret_file) 79 | 80 | def test_to_json(self, temp_dir: str): 81 | """Test converting the manager to JSON.""" 82 | manager = PermissionManager() 83 | manager.add_allowed_path(temp_dir) 84 | manager.exclude_path(temp_dir + "/excluded") 85 | manager.add_exclusion_pattern("secret_") 86 | 87 | json_str = manager.to_json() 88 | 89 | assert isinstance(json_str, str) 90 | 91 | # Parse the JSON and create a new manager from it 92 | new_manager = PermissionManager.from_json(json_str) 93 | temp_path = Path(temp_dir).resolve() 94 | excluded_path = Path(temp_dir + "/excluded").resolve() 95 | 96 | # Check if the new manager has the correct paths 97 | assert temp_path in new_manager.allowed_paths 98 | assert excluded_path in new_manager.excluded_paths 99 | assert "secret_" in new_manager.excluded_patterns 100 | 101 | def test_from_json(self, temp_dir: str): 102 | """Test creating a manager from JSON.""" 103 | original = PermissionManager() 104 | original.add_allowed_path(temp_dir) 105 | original.exclude_path(temp_dir + "/excluded") 106 | original.add_exclusion_pattern("secret_") 107 | 108 | json_str = original.to_json() 109 | reconstructed = PermissionManager.from_json(json_str) 110 | 111 | # Check that the reconstructed manager has the same state 112 | assert len(reconstructed.allowed_paths) == len(original.allowed_paths) 113 | assert len(reconstructed.excluded_paths) == len(original.excluded_paths) 114 | assert reconstructed.excluded_patterns == original.excluded_patterns 115 | 116 | 117 | class TestPermissibleOperation: 118 | """Test the PermissibleOperation decorator.""" 119 | 120 | @pytest.mark.asyncio 121 | async def test_permissible_operation_with_allowed_path(self, temp_dir: str): 122 | """Test the decorator with an allowed path.""" 123 | manager = PermissionManager() 124 | manager.add_allowed_path(temp_dir) 125 | 126 | # Create a decorated function 127 | @PermissibleOperation(manager, "read") 128 | async def test_func(path): 129 | return f"Read {path}" 130 | 131 | # Call the function 132 | result = await test_func(temp_dir) 133 | 134 | assert result == f"Read {temp_dir}" 135 | 136 | @pytest.mark.asyncio 137 | async def test_permissible_operation_with_custom_path_fn(self, temp_dir: str): 138 | """Test the decorator with a custom path function.""" 139 | manager = PermissionManager() 140 | manager.add_allowed_path(temp_dir) 141 | 142 | # Custom path function 143 | def get_path(args, kwargs): 144 | return kwargs.get("filepath", args[0] if args else None) 145 | 146 | # Create a decorated function 147 | @PermissibleOperation(manager, "read", get_path_fn=get_path) 148 | async def test_func(data, filepath=None): 149 | return f"Read {filepath}" 150 | 151 | # Call the function with a kwarg 152 | result = await test_func("dummy", filepath=temp_dir) 153 | 154 | assert result == f"Read {temp_dir}" 155 | 156 | @pytest.mark.asyncio 157 | async def test_permissible_operation_with_invalid_path(self, temp_dir: str): 158 | """Test the decorator with an invalid path type.""" 159 | manager = PermissionManager() 160 | 161 | # Create a decorated function 162 | @PermissibleOperation(manager, "read") 163 | async def test_func(path): 164 | return f"Read {path}" 165 | 166 | # Call the function with an invalid path type 167 | with pytest.raises(ValueError): 168 | await test_func(123) # Not a string 169 | -------------------------------------------------------------------------------- /tests/test_common/test_thinking.py: -------------------------------------------------------------------------------- 1 | """Tests for the thinking tool.""" 2 | 3 | import pytest 4 | from unittest.mock import AsyncMock, MagicMock, patch 5 | 6 | from mcp_claude_code.tools.common.thinking_tool import ThinkingTool 7 | from mcp_claude_code.tools.common.base import ToolRegistry 8 | 9 | 10 | @pytest.fixture 11 | def mcp_server(): 12 | """Create a mock MCP server.""" 13 | server = MagicMock() 14 | server.tool = MagicMock(return_value=lambda func: func) 15 | return server 16 | 17 | 18 | @pytest.fixture 19 | def thinking_tool(): 20 | """Create a ThinkingTool instance.""" 21 | return ThinkingTool() 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_think_tool_registration(mcp_server, thinking_tool): 26 | """Test that the think tool is registered correctly.""" 27 | # Test registration using ToolRegistry 28 | ToolRegistry.register_tool(mcp_server, thinking_tool) 29 | # Check if tool was registered 30 | assert mcp_server.tool.called 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_think_with_valid_thought(thinking_tool, mcp_context): 35 | """Test the think tool with a valid thought.""" 36 | # Mock context calls 37 | tool_ctx = MagicMock() 38 | tool_ctx.info = AsyncMock() 39 | tool_ctx.error = AsyncMock() 40 | tool_ctx.set_tool_info = AsyncMock() # Make sure this is AsyncMock 41 | tool_ctx.prepare_tool_context = AsyncMock() 42 | 43 | # Patch the create_tool_context function 44 | with patch( 45 | "mcp_claude_code.tools.common.thinking_tool.create_tool_context", 46 | return_value=tool_ctx, 47 | ): 48 | # Test the tool's call method directly 49 | thought = "I should check if the file exists before trying to read it." 50 | result = await thinking_tool.call(ctx=mcp_context, thought=thought) 51 | 52 | # Check that the function behaved correctly 53 | tool_ctx.set_tool_info.assert_called_once_with("think") 54 | tool_ctx.info.assert_called_once_with("Thinking process recorded") 55 | assert "I've recorded your thinking process" in result 56 | 57 | 58 | @pytest.mark.asyncio 59 | async def test_think_with_empty_thought(thinking_tool, mcp_context): 60 | """Test the think tool with an empty thought.""" 61 | # Mock context calls 62 | tool_ctx = MagicMock() 63 | tool_ctx.info = AsyncMock() 64 | tool_ctx.error = AsyncMock() 65 | tool_ctx.set_tool_info = AsyncMock() # Make sure this is AsyncMock 66 | tool_ctx.prepare_tool_context = AsyncMock() 67 | 68 | # Patch the create_tool_context function 69 | with patch( 70 | "mcp_claude_code.tools.common.thinking_tool.create_tool_context", 71 | return_value=tool_ctx, 72 | ): 73 | # Test with None thought 74 | result_none = await thinking_tool.call(ctx=mcp_context, thought=None) 75 | assert "Error" in result_none 76 | 77 | # Test with empty string thought 78 | result_empty = await thinking_tool.call(ctx=mcp_context, thought="") 79 | assert "Error" in result_empty 80 | 81 | # Test with whitespace-only thought 82 | result_whitespace = await thinking_tool.call(ctx=mcp_context, thought=" ") 83 | assert "Error" in result_whitespace 84 | -------------------------------------------------------------------------------- /tests/test_common/test_thinking_tool.py: -------------------------------------------------------------------------------- 1 | """Tests for the refactored ThinkingTool.""" 2 | 3 | from unittest.mock import AsyncMock, MagicMock, patch 4 | 5 | import pytest 6 | 7 | from mcp_claude_code.tools.common.thinking_tool import ThinkingTool 8 | 9 | 10 | class TestThinkingTool: 11 | """Test the refactored ThinkingTool.""" 12 | 13 | @pytest.fixture 14 | def thinking_tool(self): 15 | """Create a ThinkingTool instance for testing.""" 16 | return ThinkingTool() 17 | 18 | def test_initialization(self, thinking_tool: ThinkingTool): 19 | """Test initializing ThinkingTool.""" 20 | assert thinking_tool.name == "think" 21 | assert "Use the tool to think about something" in thinking_tool.description 22 | 23 | @pytest.mark.asyncio 24 | async def test_valid_thought( 25 | self, thinking_tool: ThinkingTool, mcp_context: MagicMock 26 | ): 27 | """Test the thinking tool with a valid thought.""" 28 | # Mock context calls 29 | tool_ctx = AsyncMock() 30 | with patch( 31 | "mcp_claude_code.tools.common.thinking_tool.create_tool_context", 32 | return_value=tool_ctx, 33 | ): 34 | # Call the tool directly 35 | thought = "This is a test thought process" 36 | result = await thinking_tool.call(ctx=mcp_context, thought=thought) 37 | 38 | # Verify result 39 | assert "I've recorded your thinking process" in result 40 | tool_ctx.info.assert_called_with("Thinking process recorded") 41 | 42 | @pytest.mark.asyncio 43 | async def test_empty_thought( 44 | self, thinking_tool: ThinkingTool, mcp_context: MagicMock 45 | ): 46 | """Test the thinking tool with an empty thought.""" 47 | # Mock context calls 48 | tool_ctx = AsyncMock() 49 | with patch( 50 | "mcp_claude_code.tools.common.thinking_tool.create_tool_context", 51 | return_value=tool_ctx, 52 | ): 53 | # Call the tool with an empty thought 54 | result = await thinking_tool.call(ctx=mcp_context, thought="") 55 | 56 | # Verify result 57 | assert ( 58 | "Error: Parameter 'thought' is required but was None or empty" in result 59 | ) 60 | tool_ctx.error.assert_called() 61 | -------------------------------------------------------------------------------- /tests/test_filesystem/__init__.py: -------------------------------------------------------------------------------- 1 | """Filesystem test modules package.""" 2 | -------------------------------------------------------------------------------- /tests/test_filesystem/test_fs_tools.py: -------------------------------------------------------------------------------- 1 | """Tests for the refactored filesystem tools.""" 2 | 3 | import os 4 | from typing import TYPE_CHECKING 5 | from unittest.mock import AsyncMock, MagicMock, patch 6 | 7 | import pytest 8 | 9 | if TYPE_CHECKING: 10 | from mcp_claude_code.tools.common.permissions import PermissionManager 11 | 12 | from mcp_claude_code.tools.filesystem import ( 13 | Edit, 14 | ReadTool, 15 | Write, 16 | get_filesystem_tools, 17 | ) 18 | 19 | 20 | class TestRefactoredFileTools: 21 | """Test the refactored filesystem tools.""" 22 | 23 | @pytest.fixture 24 | def fs_tools( 25 | self, 26 | permission_manager: "PermissionManager", 27 | ): 28 | """Create filesystem tool instances for testing.""" 29 | return get_filesystem_tools(permission_manager) 30 | 31 | @pytest.fixture 32 | def read_files_tool( 33 | self, 34 | permission_manager: "PermissionManager", 35 | ): 36 | """Create a ReadTool instance for testing.""" 37 | return ReadTool(permission_manager) 38 | 39 | @pytest.fixture 40 | def write_tool( 41 | self, 42 | permission_manager: "PermissionManager", 43 | ): 44 | """Create a Write instance for testing.""" 45 | return Write(permission_manager) 46 | 47 | @pytest.fixture 48 | def edit_file_tool( 49 | self, 50 | permission_manager: "PermissionManager", 51 | ): 52 | """Create an Edit instance for testing.""" 53 | return Edit(permission_manager) 54 | 55 | @pytest.fixture 56 | def setup_allowed_path( 57 | self, 58 | permission_manager: "PermissionManager", 59 | temp_dir: str, 60 | ): 61 | """Set up an allowed path for testing.""" 62 | permission_manager.add_allowed_path(temp_dir) 63 | return temp_dir 64 | 65 | @pytest.mark.asyncio 66 | async def test_read_files_single_allowed( 67 | self, 68 | read_files_tool: ReadTool, 69 | setup_allowed_path: str, 70 | test_file: str, 71 | mcp_context: MagicMock, 72 | ): 73 | """Test reading a single allowed file with the refactored tool.""" 74 | # Mock context calls 75 | tool_ctx = AsyncMock() 76 | with patch( 77 | "mcp_claude_code.tools.filesystem.base.create_tool_context", 78 | return_value=tool_ctx, 79 | ): 80 | # Call the tool directly 81 | result = await read_files_tool.call(ctx=mcp_context, file_path=test_file) 82 | 83 | # Verify result 84 | assert "This is a test file content" in result 85 | tool_ctx.info.assert_called() 86 | 87 | @pytest.mark.asyncio 88 | async def test_write( 89 | self, 90 | write_tool: Write, 91 | setup_allowed_path: str, 92 | mcp_context: MagicMock, 93 | ): 94 | """Test writing a file with the refactored tool.""" 95 | # Create a test path within allowed path 96 | test_path = os.path.join(setup_allowed_path, "write_test.txt") 97 | test_content = "Test content for writing" 98 | 99 | # Mock context calls 100 | tool_ctx = AsyncMock() 101 | with patch( 102 | "mcp_claude_code.tools.filesystem.base.create_tool_context", 103 | return_value=tool_ctx, 104 | ): 105 | # Call the tool directly 106 | result = await write_tool.call( 107 | ctx=mcp_context, file_path=test_path, content=test_content 108 | ) 109 | 110 | # Verify result 111 | assert "Successfully wrote file" in result 112 | tool_ctx.info.assert_called() 113 | 114 | # Verify file was written 115 | assert os.path.exists(test_path) 116 | with open(test_path, "r") as f: 117 | assert f.read() == test_content 118 | 119 | @pytest.mark.asyncio 120 | async def test_edit_file( 121 | self, 122 | edit_file_tool: Edit, 123 | setup_allowed_path: str, 124 | test_file: str, 125 | mcp_context: MagicMock, 126 | ): 127 | """Test editing a file with the refactored tool.""" 128 | # Set up edit parameters 129 | old_string = "This is a test file content." 130 | new_string = "This is modified content." 131 | 132 | # Mock context calls 133 | tool_ctx = AsyncMock() 134 | with patch( 135 | "mcp_claude_code.tools.filesystem.base.create_tool_context", 136 | return_value=tool_ctx, 137 | ): 138 | # Call the tool directly 139 | result = await edit_file_tool.call( 140 | ctx=mcp_context, 141 | file_path=test_file, 142 | old_string=old_string, 143 | new_string=new_string, 144 | expected_replacements=1, 145 | ) 146 | 147 | # Verify result 148 | assert "Successfully edited file" in result 149 | tool_ctx.info.assert_called() 150 | 151 | # Verify file was modified 152 | with open(test_file, "r") as f: 153 | content = f.read() 154 | assert "This is modified content." in content 155 | -------------------------------------------------------------------------------- /tests/test_jupyter/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for Jupyter notebook tools.""" 2 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | """Tests for the server module.""" 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | import pytest 6 | 7 | from mcp_claude_code.server import ClaudeCodeServer 8 | 9 | 10 | class TestClaudeCodeServer: 11 | """Test the ClaudeCodeServer class.""" 12 | 13 | @pytest.fixture 14 | def server(self) -> tuple[ClaudeCodeServer, MagicMock]: 15 | """Create a ClaudeCodeServer instance for testing.""" 16 | with patch("mcp.server.fastmcp.FastMCP") as mock_fastmcp: 17 | # Create a mock FastMCP instance 18 | mock_mcp = MagicMock() 19 | mock_fastmcp.return_value = mock_mcp 20 | 21 | # Create the server with the mock MCP 22 | server = ClaudeCodeServer(name="test-server", mcp_instance=mock_mcp) 23 | 24 | # Return both the server and the mock MCP 25 | yield server, mock_mcp 26 | 27 | def test_initialization(self, server: tuple[ClaudeCodeServer, MagicMock]) -> None: 28 | """Test initializing ClaudeCodeServer.""" 29 | server_instance, mock_mcp = server 30 | 31 | # Verify components were initialized 32 | assert server_instance.mcp is mock_mcp 33 | assert server_instance.permission_manager is not None 34 | 35 | def test_initialization_with_allowed_paths(self) -> None: 36 | """Test initializing with allowed paths.""" 37 | allowed_paths = ["/test/path1", "/test/path2"] 38 | 39 | with ( 40 | patch("mcp.server.fastmcp.FastMCP") as mock_fastmcp, 41 | patch("mcp_claude_code.tools.register_all_tools") as mock_register, 42 | ): 43 | # Create mock fastmcp 44 | mock_mcp = MagicMock() 45 | mock_fastmcp.return_value = mock_mcp 46 | 47 | # Direct mock of the permission manager 48 | perm_manager = MagicMock() 49 | 50 | # Create the server 51 | server = ClaudeCodeServer(name="test-server", mcp_instance=mock_mcp) 52 | 53 | # Inject our mock 54 | server.permission_manager = perm_manager 55 | 56 | # Manually call register_all_tools 57 | from mcp_claude_code.tools import register_all_tools 58 | 59 | register_all_tools( 60 | mcp_server=mock_mcp, 61 | permission_manager=perm_manager, 62 | agent_model=None, 63 | agent_max_tokens=None, 64 | agent_api_key=None, 65 | agent_base_url=None, 66 | agent_max_iterations=10, 67 | agent_max_tool_uses=30, 68 | enable_agent_tool=False, 69 | ) 70 | 71 | # Now call the code that would add the paths 72 | for path in allowed_paths: 73 | server.permission_manager.add_allowed_path(path) 74 | 75 | # Verify paths were added 76 | assert perm_manager.add_allowed_path.call_count == len(allowed_paths) 77 | 78 | # Verify each path was passed 79 | for path in allowed_paths: 80 | perm_manager.add_allowed_path.assert_any_call(path) 81 | 82 | # Verify tools were registered 83 | mock_register.assert_called_once() 84 | 85 | @pytest.mark.skip(reason="Cannot run stdio server in a test environment") 86 | def test_run(self, server: tuple[ClaudeCodeServer, MagicMock]) -> None: 87 | """Test running the server.""" 88 | server_instance, mock_mcp = server 89 | 90 | # Run the server 91 | server_instance.run() 92 | 93 | # Verify the MCP server was run 94 | mock_mcp.run.assert_called_once_with(transport="stdio") 95 | 96 | # def test_run_with_transport(self, server): 97 | # """Test running the server with a specific transport.""" 98 | # server_instance, mock_mcp = server 99 | # 100 | # # Run the server with SSE transport 101 | # server_instance.run(transport="sse") 102 | # 103 | # # Verify the MCP server was run with the specified transport 104 | # mock_mcp.run.assert_called_once_with(transport="sse") 105 | 106 | @pytest.mark.skip(reason="Cannot run stdio server in a test environment") 107 | def test_run_with_allowed_paths( 108 | self, server: tuple[ClaudeCodeServer, MagicMock] 109 | ) -> None: 110 | """Test running the server with additional allowed paths.""" 111 | server_instance, mock_mcp = server 112 | 113 | # Replace permission_manager with mock 114 | server_instance.permission_manager = MagicMock() 115 | 116 | # Run the server with allowed paths 117 | additional_paths = ["/additional/path1", "/additional/path2"] 118 | server_instance.run(allowed_paths=additional_paths) 119 | 120 | # Verify paths were added by checking call counts 121 | assert server_instance.permission_manager.add_allowed_path.call_count == len( 122 | additional_paths 123 | ) 124 | 125 | # Verify each path was passed to the add methods 126 | for path in additional_paths: 127 | server_instance.permission_manager.add_allowed_path.assert_any_call(path) 128 | 129 | # Verify the MCP server was run 130 | mock_mcp.run.assert_called_once() 131 | -------------------------------------------------------------------------------- /tests/test_shell/__init__.py: -------------------------------------------------------------------------------- 1 | """Shell commands test modules package.""" 2 | -------------------------------------------------------------------------------- /tests/test_shell/test_quote_escaping.py: -------------------------------------------------------------------------------- 1 | """Tests for single quote escaping in command executor.""" 2 | 3 | import os 4 | import sys 5 | import pytest 6 | 7 | from mcp_claude_code.tools.shell.base import CommandResult 8 | 9 | 10 | @pytest.mark.asyncio 11 | @pytest.mark.skipif( 12 | sys.platform == "win32", 13 | reason="Single quote escaping test only applies to Unix shells", 14 | ) 15 | async def test_execute_command_with_single_quotes(command_executor, temp_dir) -> None: 16 | """Test executing a command that contains single quotes.""" 17 | # Create a test file with a string containing single quotes 18 | test_file = os.path.join(temp_dir, "single_quote_test.txt") 19 | with open(test_file, "w") as f: 20 | f.write('file_path="/test/path"\nother_line') 21 | 22 | # Command with single quotes in the pattern (which previously would fail) 23 | command = f"grep -A1 'file_path=\"' {test_file}" 24 | 25 | # Execute the command (cwd parameter removed as it's handled by persistent sessions) 26 | result: CommandResult = await command_executor.execute_command(command) 27 | 28 | # Verify result 29 | assert result.is_success, f"Command failed with stderr: {result.stderr}" 30 | assert 'file_path="/test/path"' in result.stdout 31 | assert "other_line" in result.stdout 32 | 33 | 34 | @pytest.mark.asyncio 35 | @pytest.mark.skipif( 36 | sys.platform == "win32", 37 | reason="Single quote escaping test only applies to Unix shells", 38 | ) 39 | async def test_execute_command_with_complex_quotes(command_executor, temp_dir) -> None: 40 | """Test executing a command with complex quote patterns.""" 41 | # Create a test file with multiple quote patterns 42 | test_file = os.path.join(temp_dir, "complex_quote_test.txt") 43 | with open(test_file, "w") as f: 44 | f.write("line with 'single' quotes\n") 45 | f.write('line with "double" quotes\n') 46 | f.write("line with both 'single' and \"double\" quotes\n") 47 | f.write("line with file_path cannot be empty error\n") 48 | 49 | # Test commands with various quote patterns 50 | commands = [ 51 | f"grep 'single' {test_file}", 52 | f'grep "double" {test_file}', 53 | f'grep -n -B1 -A1 "file_path cannot be empty" {test_file}', 54 | ] 55 | 56 | for cmd in commands: 57 | # Execute the command (cwd parameter removed as it's handled by persistent sessions) 58 | result: CommandResult = await command_executor.execute_command(cmd) 59 | 60 | # Verify result 61 | assert result.is_success, f"Command failed: {cmd}\nStderr: {result.stderr}" 62 | assert result.stdout.strip(), f"Command returned empty output: {cmd}" 63 | -------------------------------------------------------------------------------- /tests/test_todo/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for todo tools.""" 2 | -------------------------------------------------------------------------------- /tests/test_todo/test_todo_storage.py: -------------------------------------------------------------------------------- 1 | """Tests for TodoStorage class.""" 2 | 3 | from mcp_claude_code.tools.todo.base import TodoStorage 4 | 5 | 6 | class TestTodoStorage: 7 | """Test the TodoStorage class.""" 8 | 9 | def setup_method(self): 10 | """Clear storage before each test.""" 11 | TodoStorage._sessions.clear() 12 | 13 | def teardown_method(self): 14 | """Clear storage after each test.""" 15 | TodoStorage._sessions.clear() 16 | 17 | def test_initial_state(self): 18 | """Test initial state of TodoStorage.""" 19 | assert TodoStorage.get_session_count() == 0 20 | assert TodoStorage.get_all_session_ids() == [] 21 | assert TodoStorage.get_todos("nonexistent") == [] 22 | 23 | def test_set_and_get_todos(self): 24 | """Test setting and getting todos.""" 25 | session_id = "test-session-123" 26 | todos = [ 27 | { 28 | "id": "task-1", 29 | "content": "Test task", 30 | "status": "pending", 31 | "priority": "high", 32 | } 33 | ] 34 | 35 | # Set todos 36 | TodoStorage.set_todos(session_id, todos) 37 | 38 | # Verify storage 39 | assert TodoStorage.get_session_count() == 1 40 | assert session_id in TodoStorage.get_all_session_ids() 41 | 42 | # Get todos 43 | retrieved = TodoStorage.get_todos(session_id) 44 | assert retrieved == todos 45 | assert len(retrieved) == 1 46 | assert retrieved[0]["id"] == "task-1" 47 | 48 | def test_multiple_sessions(self): 49 | """Test managing multiple sessions.""" 50 | session1 = "session-1" 51 | session2 = "session-2" 52 | 53 | todos1 = [ 54 | { 55 | "id": "task-1", 56 | "content": "Task 1", 57 | "status": "pending", 58 | "priority": "high", 59 | } 60 | ] 61 | todos2 = [ 62 | { 63 | "id": "task-2", 64 | "content": "Task 2", 65 | "status": "in_progress", 66 | "priority": "medium", 67 | }, 68 | { 69 | "id": "task-3", 70 | "content": "Task 3", 71 | "status": "completed", 72 | "priority": "low", 73 | }, 74 | ] 75 | 76 | # Set todos for both sessions 77 | TodoStorage.set_todos(session1, todos1) 78 | TodoStorage.set_todos(session2, todos2) 79 | 80 | # Verify session count 81 | assert TodoStorage.get_session_count() == 2 82 | 83 | # Verify session IDs 84 | session_ids = TodoStorage.get_all_session_ids() 85 | assert session1 in session_ids 86 | assert session2 in session_ids 87 | 88 | # Verify session separation 89 | assert TodoStorage.get_todos(session1) == todos1 90 | assert TodoStorage.get_todos(session2) == todos2 91 | assert len(TodoStorage.get_todos(session1)) == 1 92 | assert len(TodoStorage.get_todos(session2)) == 2 93 | 94 | def test_update_existing_session(self): 95 | """Test updating todos for an existing session.""" 96 | session_id = "update-test" 97 | 98 | # Initial todos 99 | initial_todos = [ 100 | { 101 | "id": "task-1", 102 | "content": "Task 1", 103 | "status": "pending", 104 | "priority": "high", 105 | } 106 | ] 107 | TodoStorage.set_todos(session_id, initial_todos) 108 | 109 | # Update todos 110 | updated_todos = [ 111 | { 112 | "id": "task-1", 113 | "content": "Task 1 Updated", 114 | "status": "completed", 115 | "priority": "high", 116 | }, 117 | { 118 | "id": "task-2", 119 | "content": "Task 2", 120 | "status": "pending", 121 | "priority": "medium", 122 | }, 123 | ] 124 | TodoStorage.set_todos(session_id, updated_todos) 125 | 126 | # Verify update 127 | assert TodoStorage.get_session_count() == 1 # Still only one session 128 | retrieved = TodoStorage.get_todos(session_id) 129 | assert retrieved == updated_todos 130 | assert len(retrieved) == 2 131 | assert retrieved[0]["content"] == "Task 1 Updated" 132 | assert retrieved[0]["status"] == "completed" 133 | 134 | def test_empty_todos_list(self): 135 | """Test setting empty todos list.""" 136 | session_id = "empty-test" 137 | 138 | # Set empty list 139 | TodoStorage.set_todos(session_id, []) 140 | 141 | # Verify 142 | assert TodoStorage.get_session_count() == 1 143 | assert TodoStorage.get_todos(session_id) == [] 144 | 145 | def test_delete_session(self): 146 | """Test deleting a session.""" 147 | session_id = "delete-test" 148 | todos = [ 149 | { 150 | "id": "task-1", 151 | "content": "Task 1", 152 | "status": "pending", 153 | "priority": "high", 154 | } 155 | ] 156 | 157 | # Create session 158 | TodoStorage.set_todos(session_id, todos) 159 | assert TodoStorage.get_session_count() == 1 160 | 161 | # Delete session 162 | result = TodoStorage.delete_session(session_id) 163 | assert result is True # Should return True when session exists 164 | assert TodoStorage.get_session_count() == 0 165 | assert TodoStorage.get_todos(session_id) == [] 166 | 167 | # Try to delete non-existent session 168 | result = TodoStorage.delete_session("nonexistent") 169 | assert result is False # Should return False when session doesn't exist 170 | 171 | def test_complex_todo_structure(self): 172 | """Test storing complex todo structures.""" 173 | session_id = "complex-test" 174 | 175 | # Complex todos with all fields 176 | todos = [ 177 | { 178 | "id": "task-1", 179 | "content": "Implement user authentication with OAuth2 integration", 180 | "status": "in_progress", 181 | "priority": "high", 182 | }, 183 | { 184 | "id": "task-2", 185 | "content": "Write comprehensive unit tests for API endpoints", 186 | "status": "pending", 187 | "priority": "medium", 188 | }, 189 | { 190 | "id": "task-3", 191 | "content": "Update documentation with new features", 192 | "status": "completed", 193 | "priority": "low", 194 | }, 195 | ] 196 | 197 | TodoStorage.set_todos(session_id, todos) 198 | retrieved = TodoStorage.get_todos(session_id) 199 | 200 | assert retrieved == todos 201 | assert len(retrieved) == 3 202 | 203 | # Verify each todo maintains its structure 204 | for i, todo in enumerate(retrieved): 205 | assert todo["id"] == todos[i]["id"] 206 | assert todo["content"] == todos[i]["content"] 207 | assert todo["status"] == todos[i]["status"] 208 | assert todo["priority"] == todos[i]["priority"] 209 | 210 | def test_session_isolation(self): 211 | """Test that sessions are properly isolated.""" 212 | # Create multiple sessions with similar todo IDs 213 | for i in range(3): 214 | session_id = f"session-{i}" 215 | todos = [ 216 | { 217 | "id": "task-1", # Same ID across sessions 218 | "content": f"Task for session {i}", 219 | "status": "pending", 220 | "priority": "high", 221 | } 222 | ] 223 | TodoStorage.set_todos(session_id, todos) 224 | 225 | # Verify isolation 226 | assert TodoStorage.get_session_count() == 3 227 | 228 | for i in range(3): 229 | session_id = f"session-{i}" 230 | todos = TodoStorage.get_todos(session_id) 231 | assert len(todos) == 1 232 | assert todos[0]["content"] == f"Task for session {i}" 233 | 234 | # Modify one session 235 | TodoStorage.set_todos("session-1", []) 236 | 237 | # Verify other sessions are unaffected 238 | assert len(TodoStorage.get_todos("session-0")) == 1 239 | assert len(TodoStorage.get_todos("session-1")) == 0 240 | assert len(TodoStorage.get_todos("session-2")) == 1 241 | -------------------------------------------------------------------------------- /tests/test_validation.py: -------------------------------------------------------------------------------- 1 | """Tests for parameter validation in MCP Claude Code tools.""" 2 | 3 | from mcp_claude_code.tools.common.validation import ( 4 | validate_path_parameter, 5 | ) 6 | 7 | 8 | def test_validate_path_parameter(): 9 | """Test validation of path parameters.""" 10 | # None path 11 | result = validate_path_parameter(None) 12 | assert result.is_error 13 | assert "path" in result.error_message.lower() # Default name is 'path' 14 | 15 | # Empty path 16 | result = validate_path_parameter("") 17 | assert result.is_error 18 | assert "empty string" in result.error_message 19 | 20 | # Whitespace only path 21 | result = validate_path_parameter(" ") 22 | assert result.is_error 23 | 24 | # Valid path 25 | result = validate_path_parameter("/valid/path") 26 | assert not result.is_error 27 | 28 | # Custom parameter name 29 | result = validate_path_parameter(None, "project_dir") 30 | assert result.is_error 31 | assert "project_dir" in result.error_message 32 | --------------------------------------------------------------------------------