Skip to content

Commit 0db21f5

Browse files
committed
Add initial project structure with .gitignore, LICENSE, pyproject.toml, README, upload script, and core server implementation
0 parents  commit 0db21f5

File tree

7 files changed

+404
-0
lines changed

7 files changed

+404
-0
lines changed

.gitignore

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
6+
# C extensions
7+
*.so
8+
9+
# Distribution / packaging
10+
.Python
11+
build/
12+
develop-eggs/
13+
dist/
14+
downloads/
15+
eggs/
16+
.eggs/
17+
lib/
18+
lib64/
19+
parts/
20+
sdist/
21+
var/
22+
*.egg-info/
23+
.installed.cfg
24+
*.egg
25+
26+
# PyInstaller
27+
# Usually these files are written by a python script from a template
28+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
29+
build/
30+
dist/
31+
*.manifest
32+
*.spec
33+
34+
# Installer logs
35+
debug.log
36+
pip-log.txt
37+
pip-delete-this-directory.txt
38+
39+
# Unit test / coverage reports
40+
htmlcov/
41+
.tox/
42+
.nox/
43+
.coverage
44+
.coverage.*
45+
.cache
46+
nosetests.xml
47+
coverage.xml
48+
*.cover
49+
.hypothesis/
50+
.pytest_cache/
51+
52+
# Jupyter Notebook
53+
.ipynb_checkpoints
54+
55+
# pyenv
56+
.python-version
57+
58+
# mypy
59+
.mypy_cache/
60+
.dmypy.json
61+
62+
# Pyre type checker
63+
.pyre/
64+
65+
# venv
66+
venv/
67+
68+
# Project memory file
69+
MEMORY.md

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 PYNESYS LLC.
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.

README.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Patch File MCP
2+
3+
An MCP Server to patch existing files using unified diff format. This allows AI agents (like Claude) to make precise changes to files in your projects.
4+
5+
## Overview
6+
7+
Patch File MCP provides a simple way to modify files by applying patches in unified diff format. The key benefits include:
8+
9+
- Makes targeted changes to specific parts of files without rewriting the entire content
10+
- Supports multiple patches to the same file
11+
- Safer than complete file rewrites since it only affects the specified sections
12+
- Better alternative to the `edit_block` tool from `desktop-commander` for most file editing tasks
13+
14+
## Installation
15+
16+
### Local installation
17+
18+
#### Prerequisites
19+
20+
- Python 3.11 or higher
21+
- Pip package manager
22+
23+
#### Install from PyPI
24+
25+
```bash
26+
pip install patch-file-mcp
27+
```
28+
29+
#### Install from Source
30+
31+
```bash
32+
git clone https://github.com/your-username/patch-file-mcp.git
33+
cd patch-file-mcp
34+
pip install -e .
35+
```
36+
37+
## Usage
38+
39+
The MCP server is started by the client (e.g., Claude Desktop) based on the configuration you provide. You don't need to start the server manually.
40+
41+
### Integration with Claude Desktop
42+
43+
To use this MCP server with Claude Desktop, you need to add it to your `claude_desktop_config.json` file:
44+
45+
#### Using uvx (Recommended)
46+
47+
This method uses `uvx` (from the `uv` Python package manager) to run the server without permanent installation:
48+
49+
```json
50+
{
51+
"mcpServers": {
52+
"patch-file": {
53+
"command": "uvx",
54+
"args": [
55+
"patch-file-mcp",
56+
"--allowed-dir", "/Users/your-username/projects",
57+
"--allowed-dir", "/Users/your-username/Documents/code"
58+
]
59+
}
60+
}
61+
}
62+
```
63+
64+
#### Using pip installed version
65+
66+
If you've installed the package with pip:
67+
68+
```json
69+
{
70+
"mcpServers": {
71+
"patch-file": {
72+
"command": "patch-file-mcp",
73+
"args": [
74+
"--allowed-dir", "/Users/your-username/projects",
75+
"--allowed-dir", "/Users/your-username/Documents/code"
76+
]
77+
}
78+
}
79+
}
80+
```
81+
82+
### Configuring Claude Desktop
83+
84+
1. Install Claude Desktop from the [official website](https://claude.ai/desktop)
85+
2. Open Claude Desktop
86+
3. From the menu, select Settings → Developer → Edit Config
87+
4. Add the MCP configuration above to your existing config (modify paths as needed)
88+
5. Save and restart Claude Desktop
89+
90+
## Tools
91+
92+
Patch File MCP provides one main tool:
93+
94+
### patch_file
95+
96+
Updates a file by applying a unified diff/patch to it.
97+
98+
```
99+
patch_file(file_path: str, patch_content: str)
100+
```
101+
102+
**Parameters:**
103+
- `file_path`: Path to the file to be patched
104+
- `patch_content`: Unified diff/patch content to apply to the file
105+
106+
**Notes:**
107+
- The file must exist and be within an allowed directory
108+
- The patch must be in valid unified diff format
109+
- If the patch fails to apply, an error is raised
110+
111+
## Example Workflow
112+
113+
1. Begin a conversation with Claude about modifying a file in your project
114+
2. Claude generates a unified diff/patch that makes the desired changes
115+
3. Claude uses `patch_file` to apply these changes to your file
116+
4. If the patch fails, Claude might suggest using `write_file` from another MCP as an alternative
117+
118+
## Creating Unified Diffs
119+
120+
A unified diff typically looks like:
121+
122+
```
123+
--- oldfile
124+
+++ newfile
125+
@@ -start,count +start,count @@
126+
context line
127+
-removed line
128+
+added line
129+
context line
130+
```
131+
132+
Claude can generate these diffs automatically when suggesting file changes.
133+
134+
## Recent Changes
135+
136+
### 2025-04-23 Bugfixes
137+
- Fixed an issue where the patch operation would fail with "Not a directory" error when trying to apply patches.
138+
- Updated the patching logic to use the parent directory as the root for patch application, rather than the file itself.
139+
140+
## Security Considerations
141+
142+
- All file operations are restricted to allowed directories
143+
- The tool only modifies specified sections of files
144+
- Each patch operation is validated before being applied
145+
146+
## Dependencies
147+
148+
- fastmcp (>=2.2.0, <3.0.0)
149+
- patch-ng (>=1.18.0, <2.0.0)
150+
151+
## License
152+
153+
MIT

pyproject.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[project]
2+
name = "patch-file-mcp"
3+
version = "0.1.0"
4+
description = "An MCP Server to patch existing files"
5+
authors = [{ name = "PYNESYS LLC" }]
6+
readme = "README.md"
7+
requires-python = ">=3.11"
8+
license = { text = "MIT" }
9+
10+
dependencies = ["fastmcp>=2.2.0, <3.0.0", "patch-ng>=1.18.0, <2.0.0"]
11+
12+
[project.scripts]
13+
patch-file-mcp = "patch_file_mcp.server:main"
14+
15+
[build-system]
16+
requires = ["setuptools>=68.0", "wheel"]
17+
build-backend = "setuptools.build_meta"
18+
19+
[tool.setuptools.packages.find]
20+
where = ["src"]

src/patch_file_mcp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

src/patch_file_mcp/server.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#! /usr/bin/env python3
2+
import sys
3+
import argparse
4+
import re
5+
from pathlib import Path
6+
7+
from fastmcp import FastMCP
8+
from pydantic.fields import Field
9+
import patch_ng
10+
11+
12+
mcp = FastMCP(
13+
name="Patch File MCP",
14+
instructions=f"""
15+
This MCP is for patching existing files.
16+
This can be used to patch files in projects, if project is specified, and the full path to the project
17+
is provided. This should be used most of the time instead of `edit_block` tool from `desktop-commander`.
18+
It can be used to patch multiple parts of the same file.
19+
"""
20+
)
21+
22+
allowed_directories = []
23+
24+
25+
def eprint(*args, **kwargs):
26+
print(*args, file=sys.stderr, **kwargs)
27+
28+
29+
def main():
30+
# Process command line arguments
31+
global allowed_directories
32+
parser = argparse.ArgumentParser(description="Project Memory MCP server")
33+
parser.add_argument(
34+
'--allowed-dir',
35+
action='append',
36+
dest='allowed_dirs',
37+
required=True,
38+
help='Allowed base directory for project paths (can be used multiple times)'
39+
)
40+
args = parser.parse_args()
41+
allowed_directories = [str(Path(d).resolve()) for d in args.allowed_dirs]
42+
43+
if not allowed_directories:
44+
allowed_directories = [str(Path.home().resolve())]
45+
46+
eprint(f"Allowed directories: {allowed_directories}")
47+
48+
# Run the MCP server
49+
mcp.run()
50+
51+
52+
if __name__ == "__main__":
53+
main()
54+
55+
56+
#
57+
# Tools
58+
#
59+
60+
@mcp.tool()
61+
def patch_file(
62+
file_path: str = Field(description="The path to the file to patch"),
63+
patch_content: str = Field(description="Unified diff/patch to apply to the file.")
64+
):
65+
"""
66+
Update the file by applying a unified diff/patch to it.
67+
The patch must be in unified diff format and will be applied to the current file content.
68+
"""
69+
pp = Path(file_path).resolve()
70+
if not pp.exists() or not pp.is_file():
71+
raise FileNotFoundError(f"File {file_path} does not exist")
72+
if not any(str(pp).startswith(base) for base in allowed_directories):
73+
raise PermissionError(f"File {file_path} is not in allowed directories")
74+
75+
# Extract all hunks (sections starting with @@)
76+
# First try to find all hunks in the patch content
77+
hunks = re.findall(r'@@[^@]*(?:\n(?!@@)[^\n]*)*', patch_content)
78+
79+
if not hunks:
80+
# If no complete hunks found, check if the patch itself is a single hunk
81+
if patch_content.strip().startswith("@@"):
82+
hunks = [patch_content.strip()]
83+
else:
84+
raise RuntimeError(
85+
"No valid patch hunks found. Make sure the patch contains @@ line markers.\n"
86+
"You can use `write_file` tool to write the whole file content instead."
87+
)
88+
89+
# Join all hunks and create a standardized patch with proper headers
90+
hunks_content = '\n'.join(hunks)
91+
filename = pp.name
92+
standardized_patch = f"--- {filename}\n+++ {filename}\n{hunks_content}"
93+
eprint(f"Created standardized patch for {filename}")
94+
95+
# Ensure patch_content is properly encoded
96+
encoded_content = standardized_patch.encode("utf-8")
97+
patchset = patch_ng.fromstring(encoded_content)
98+
if not patchset:
99+
raise RuntimeError(
100+
"Failed to parse patch string. You can use `write_file` tool to write the "
101+
"whole file content instead.\n"
102+
"Make sure the patch follows the unified diff format with @@ line markers."
103+
)
104+
105+
# Use the parent directory as root and the filename for patching
106+
parent_dir = str(pp.parent)
107+
success = patchset.apply(root=parent_dir)
108+
109+
if not success:
110+
raise RuntimeError(
111+
"Failed to apply patch to file. Use `write_file` tool to write the "
112+
"whole file content instead.\n"
113+
"Check that the patch lines match the target file content."
114+
)

0 commit comments

Comments
 (0)