├── .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 | 
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 |
--------------------------------------------------------------------------------