mcp-server-code-assist 0.1.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- mcp_server_code_assist/__init__.py +24 -0
- mcp_server_code_assist/__main__.py +3 -0
- mcp_server_code_assist/schema.xsd +28 -0
- mcp_server_code_assist/server.py +240 -0
- mcp_server_code_assist/tools/__init__.py +0 -0
- mcp_server_code_assist/tools/file_tools.py +220 -0
- mcp_server_code_assist/tools/git_functions.py +25 -0
- mcp_server_code_assist/tools/invoke.py +7 -0
- mcp_server_code_assist/tools/models.py +48 -0
- mcp_server_code_assist/tools/repository_tools.py +76 -0
- mcp_server_code_assist/xml_parser.py +48 -0
- mcp_server_code_assist-0.1.0.dist-info/METADATA +11 -0
- mcp_server_code_assist-0.1.0.dist-info/RECORD +14 -0
- mcp_server_code_assist-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
import click
|
2
|
+
from pathlib import Path
|
3
|
+
import logging
|
4
|
+
import sys
|
5
|
+
from .server import serve
|
6
|
+
|
7
|
+
@click.command()
|
8
|
+
@click.option("--working-dir", "-w", type=Path, help="Working directory path")
|
9
|
+
@click.option("-v", "--verbose", count=True)
|
10
|
+
def main(working_dir: Path | None, verbose: bool) -> None:
|
11
|
+
"""MCP Code Assist Server - Code operations for MCP"""
|
12
|
+
import asyncio
|
13
|
+
|
14
|
+
logging_level = logging.WARN
|
15
|
+
if verbose == 1:
|
16
|
+
logging_level = logging.INFO
|
17
|
+
elif verbose >= 2:
|
18
|
+
logging_level = logging.DEBUG
|
19
|
+
|
20
|
+
logging.basicConfig(level=logging_level, stream=sys.stderr)
|
21
|
+
asyncio.run(serve(working_dir))
|
22
|
+
|
23
|
+
if __name__ == "__main__":
|
24
|
+
main()
|
@@ -0,0 +1,28 @@
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
2
|
+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
3
|
+
<xs:element name="instruction">
|
4
|
+
<xs:complexType>
|
5
|
+
<xs:sequence>
|
6
|
+
<xs:element name="function">
|
7
|
+
<xs:simpleType>
|
8
|
+
<xs:restriction base="xs:string">
|
9
|
+
<xs:enumeration value="create"/>
|
10
|
+
<xs:enumeration value="delete"/>
|
11
|
+
<xs:enumeration value="modify"/>
|
12
|
+
<xs:enumeration value="rewrite"/>
|
13
|
+
</xs:restriction>
|
14
|
+
</xs:simpleType>
|
15
|
+
</xs:element>
|
16
|
+
<xs:element name="path" type="xs:string"/>
|
17
|
+
<xs:element name="content" type="xs:string" minOccurs="0"/>
|
18
|
+
<xs:element name="replacements" minOccurs="0">
|
19
|
+
<xs:complexType>
|
20
|
+
<xs:sequence>
|
21
|
+
<xs:any processContents="skip" minOccurs="0" maxOccurs="unbounded"/>
|
22
|
+
</xs:sequence>
|
23
|
+
</xs:complexType>
|
24
|
+
</xs:element>
|
25
|
+
</xs:sequence>
|
26
|
+
</xs:complexType>
|
27
|
+
</xs:element>
|
28
|
+
</xs:schema>
|
@@ -0,0 +1,240 @@
|
|
1
|
+
import logging
|
2
|
+
import os
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Any
|
5
|
+
from enum import Enum
|
6
|
+
from mcp.server import Server
|
7
|
+
from mcp.server.session import ServerSession
|
8
|
+
from mcp.server.stdio import stdio_server
|
9
|
+
from mcp.types import ClientCapabilities, TextContent, Tool, ListRootsResult, RootsCapability
|
10
|
+
import git
|
11
|
+
from mcp_server_code_assist.tools.models import (
|
12
|
+
FileCreate, FileDelete, FileModify, FileRewrite,
|
13
|
+
GitBase, GitAdd, GitCommit, GitDiff, GitCreateBranch,
|
14
|
+
GitCheckout, GitShow, GitLog
|
15
|
+
)
|
16
|
+
from mcp_server_code_assist.tools.file_tools import FileTools
|
17
|
+
from mcp_server_code_assist.tools.git_functions import git_status, git_diff_unstaged, git_diff_staged, git_diff, git_log, git_show
|
18
|
+
|
19
|
+
class CodeAssistTools(str, Enum):
|
20
|
+
FILE_CREATE = "file_create"
|
21
|
+
FILE_DELETE = "file_delete"
|
22
|
+
FILE_MODIFY = "file_modify"
|
23
|
+
FILE_REWRITE = "file_rewrite"
|
24
|
+
GIT_STATUS = "git_status"
|
25
|
+
GIT_DIFF_UNSTAGED = "git_diff_unstaged"
|
26
|
+
GIT_DIFF_STAGED = "git_diff_staged"
|
27
|
+
GIT_DIFF = "git_diff"
|
28
|
+
GIT_COMMIT = "git_commit"
|
29
|
+
GIT_ADD = "git_add"
|
30
|
+
GIT_RESET = "git_reset"
|
31
|
+
GIT_LOG = "git_log"
|
32
|
+
GIT_CREATE_BRANCH = "git_create_branch"
|
33
|
+
GIT_CHECKOUT = "git_checkout"
|
34
|
+
GIT_SHOW = "git_show"
|
35
|
+
|
36
|
+
async def process_instruction(instruction: dict[str, Any], repo_path: Path) -> dict[str, Any]:
|
37
|
+
FileTools.init_allowed_paths([str(repo_path)])
|
38
|
+
try:
|
39
|
+
match instruction['type']:
|
40
|
+
case 'read_file':
|
41
|
+
path = str(repo_path / instruction['path'])
|
42
|
+
content = await FileTools.read_file(path)
|
43
|
+
return {'content': content}
|
44
|
+
|
45
|
+
case 'read_multiple':
|
46
|
+
paths = [str(repo_path / p) for p in instruction['paths']]
|
47
|
+
contents = await FileTools.read_multiple_files(paths)
|
48
|
+
contents_dict = {os.path.basename(k): v for k,v in contents.items()}
|
49
|
+
return {'contents': contents_dict}
|
50
|
+
|
51
|
+
case 'create_file':
|
52
|
+
path = str(repo_path / instruction['path'])
|
53
|
+
result = await FileTools.create_file(path, instruction.get('content', ''))
|
54
|
+
return {'result': result}
|
55
|
+
|
56
|
+
case 'modify_file':
|
57
|
+
path = str(repo_path / instruction['path'])
|
58
|
+
result = await FileTools.modify_file(path, instruction['replacements'])
|
59
|
+
return {'result': result}
|
60
|
+
|
61
|
+
case 'rewrite_file':
|
62
|
+
path = str(repo_path / instruction['path'])
|
63
|
+
result = await FileTools.rewrite_file(path, instruction['content'])
|
64
|
+
return {'result': result}
|
65
|
+
|
66
|
+
case 'delete_file':
|
67
|
+
path = str(repo_path / instruction['path'])
|
68
|
+
result = await FileTools.delete_file(path)
|
69
|
+
return {'result': result}
|
70
|
+
|
71
|
+
case _:
|
72
|
+
return {'error': 'Invalid instruction type'}
|
73
|
+
|
74
|
+
except Exception as e:
|
75
|
+
return {'error': str(e)}
|
76
|
+
|
77
|
+
async def serve(working_dir: Path | None) -> None:
|
78
|
+
logger = logging.getLogger(__name__)
|
79
|
+
server = Server("mcp-code-assist")
|
80
|
+
|
81
|
+
@server.list_tools()
|
82
|
+
async def list_tools() -> list[Tool]:
|
83
|
+
return [
|
84
|
+
Tool(
|
85
|
+
name=CodeAssistTools.FILE_CREATE,
|
86
|
+
description="Creates a new file with content",
|
87
|
+
inputSchema=FileCreate.schema(),
|
88
|
+
),
|
89
|
+
Tool(
|
90
|
+
name=CodeAssistTools.FILE_DELETE,
|
91
|
+
description="Deletes a file",
|
92
|
+
inputSchema=FileDelete.schema(),
|
93
|
+
),
|
94
|
+
Tool(
|
95
|
+
name=CodeAssistTools.FILE_MODIFY,
|
96
|
+
description="Modifies file content using search/replace",
|
97
|
+
inputSchema=FileModify.schema(),
|
98
|
+
),
|
99
|
+
Tool(
|
100
|
+
name=CodeAssistTools.FILE_REWRITE,
|
101
|
+
description="Rewrites entire file content",
|
102
|
+
inputSchema=FileRewrite.schema(),
|
103
|
+
),
|
104
|
+
Tool(
|
105
|
+
name=CodeAssistTools.GIT_STATUS,
|
106
|
+
description="Shows the working tree status",
|
107
|
+
inputSchema=GitBase.schema(),
|
108
|
+
),
|
109
|
+
Tool(
|
110
|
+
name=CodeAssistTools.GIT_DIFF_UNSTAGED,
|
111
|
+
description="Shows changes in the working directory that are not yet staged",
|
112
|
+
inputSchema=GitBase.schema(),
|
113
|
+
),
|
114
|
+
Tool(
|
115
|
+
name=CodeAssistTools.GIT_DIFF_STAGED,
|
116
|
+
description="Shows changes that are staged for commit",
|
117
|
+
inputSchema=GitBase.schema(),
|
118
|
+
),
|
119
|
+
Tool(
|
120
|
+
name=CodeAssistTools.GIT_DIFF,
|
121
|
+
description="Shows differences between branches or commits",
|
122
|
+
inputSchema=GitDiff.schema(),
|
123
|
+
),
|
124
|
+
Tool(
|
125
|
+
name=CodeAssistTools.GIT_COMMIT,
|
126
|
+
description="Records changes to the repository",
|
127
|
+
inputSchema=GitCommit.schema(),
|
128
|
+
),
|
129
|
+
Tool(
|
130
|
+
name=CodeAssistTools.GIT_ADD,
|
131
|
+
description="Adds file contents to the staging area",
|
132
|
+
inputSchema=GitAdd.schema(),
|
133
|
+
),
|
134
|
+
Tool(
|
135
|
+
name=CodeAssistTools.GIT_RESET,
|
136
|
+
description="Unstages all staged changes",
|
137
|
+
inputSchema=GitBase.schema(),
|
138
|
+
),
|
139
|
+
Tool(
|
140
|
+
name=CodeAssistTools.GIT_LOG,
|
141
|
+
description="Shows the commit logs",
|
142
|
+
inputSchema=GitLog.schema(),
|
143
|
+
),
|
144
|
+
Tool(
|
145
|
+
name=CodeAssistTools.GIT_CREATE_BRANCH,
|
146
|
+
description="Creates a new branch from an optional base branch",
|
147
|
+
inputSchema=GitCreateBranch.schema(),
|
148
|
+
),
|
149
|
+
Tool(
|
150
|
+
name=CodeAssistTools.GIT_CHECKOUT,
|
151
|
+
description="Switches branches",
|
152
|
+
inputSchema=GitCheckout.schema(),
|
153
|
+
),
|
154
|
+
Tool(
|
155
|
+
name=CodeAssistTools.GIT_SHOW,
|
156
|
+
description="Shows the contents of a commit",
|
157
|
+
inputSchema=GitShow.schema(),
|
158
|
+
),
|
159
|
+
]
|
160
|
+
|
161
|
+
@server.call_tool()
|
162
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
163
|
+
match name:
|
164
|
+
case CodeAssistTools.FILE_CREATE:
|
165
|
+
result = await FileTools.create_file(arguments["path"], arguments.get("content", ""))
|
166
|
+
return [TextContent(type="text", text=result)]
|
167
|
+
|
168
|
+
case CodeAssistTools.FILE_DELETE:
|
169
|
+
result = await FileTools.delete_file(arguments["path"])
|
170
|
+
return [TextContent(type="text", text=result)]
|
171
|
+
|
172
|
+
case CodeAssistTools.FILE_MODIFY:
|
173
|
+
result = await FileTools.modify_file(arguments["path"], arguments["replacements"])
|
174
|
+
return [TextContent(type="text", text=result)]
|
175
|
+
|
176
|
+
case CodeAssistTools.FILE_REWRITE:
|
177
|
+
result = await FileTools.rewrite_file(arguments["path"], arguments["content"])
|
178
|
+
return [TextContent(type="text", text=result)]
|
179
|
+
|
180
|
+
case CodeAssistTools.GIT_STATUS:
|
181
|
+
repo = git.Repo(arguments["repo_path"])
|
182
|
+
status = git_status(repo)
|
183
|
+
return [TextContent(type="text", text=f"Repository status:\n{status}")]
|
184
|
+
|
185
|
+
case CodeAssistTools.GIT_DIFF_UNSTAGED:
|
186
|
+
repo = git.Repo(arguments["repo_path"])
|
187
|
+
diff = git_diff_unstaged(repo)
|
188
|
+
return [TextContent(type="text", text=f"Unstaged changes:\n{diff}")]
|
189
|
+
|
190
|
+
case CodeAssistTools.GIT_DIFF_STAGED:
|
191
|
+
repo = git.Repo(arguments["repo_path"])
|
192
|
+
diff = git_diff_staged(repo)
|
193
|
+
return [TextContent(type="text", text=f"Staged changes:\n{diff}")]
|
194
|
+
|
195
|
+
case CodeAssistTools.GIT_DIFF:
|
196
|
+
repo = git.Repo(arguments["repo_path"])
|
197
|
+
diff = git_diff(repo, arguments["target"])
|
198
|
+
return [TextContent(type="text", text=f"Diff with {arguments['target']}:\n{diff}")]
|
199
|
+
|
200
|
+
case CodeAssistTools.GIT_COMMIT:
|
201
|
+
repo = git.Repo(arguments["repo_path"])
|
202
|
+
result = git_commit(repo, arguments["message"])
|
203
|
+
return [TextContent(type="text", text=result)]
|
204
|
+
|
205
|
+
case CodeAssistTools.GIT_ADD:
|
206
|
+
repo = git.Repo(arguments["repo_path"])
|
207
|
+
result = git_add(repo, arguments["files"])
|
208
|
+
return [TextContent(type="text", text=result)]
|
209
|
+
|
210
|
+
case CodeAssistTools.GIT_RESET:
|
211
|
+
repo = git.Repo(arguments["repo_path"])
|
212
|
+
result = git_reset(repo)
|
213
|
+
return [TextContent(type="text", text=result)]
|
214
|
+
|
215
|
+
case CodeAssistTools.GIT_LOG:
|
216
|
+
repo = git.Repo(arguments["repo_path"])
|
217
|
+
log = git_log(repo, arguments.get("max_count", 10))
|
218
|
+
return [TextContent(type="text", text="Commit history:\n" + "\n".join(log))]
|
219
|
+
|
220
|
+
case CodeAssistTools.GIT_CREATE_BRANCH:
|
221
|
+
repo = git.Repo(arguments["repo_path"])
|
222
|
+
result = git_create_branch(repo, arguments["branch_name"], arguments.get("base_branch"))
|
223
|
+
return [TextContent(type="text", text=result)]
|
224
|
+
|
225
|
+
case CodeAssistTools.GIT_CHECKOUT:
|
226
|
+
repo = git.Repo(arguments["repo_path"])
|
227
|
+
result = git_checkout(repo, arguments["branch_name"])
|
228
|
+
return [TextContent(type="text", text=result)]
|
229
|
+
|
230
|
+
case CodeAssistTools.GIT_SHOW:
|
231
|
+
repo = git.Repo(arguments["repo_path"])
|
232
|
+
result = git_show(repo, arguments["revision"])
|
233
|
+
return [TextContent(type="text", text=result)]
|
234
|
+
|
235
|
+
case _:
|
236
|
+
raise ValueError(f"Unknown tool: {name}")
|
237
|
+
|
238
|
+
options = server.create_initialization_options()
|
239
|
+
async with stdio_server() as (read_stream, write_stream):
|
240
|
+
await server.run(read_stream, write_stream, options, raise_exceptions=True)
|
File without changes
|
@@ -0,0 +1,220 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
import os
|
3
|
+
import shutil
|
4
|
+
import difflib
|
5
|
+
import fnmatch
|
6
|
+
import json
|
7
|
+
from typing import Union, Dict, Tuple, Optional, Set
|
8
|
+
import git
|
9
|
+
|
10
|
+
class FileTools:
|
11
|
+
_allowed_paths: list[str] = []
|
12
|
+
|
13
|
+
@classmethod
|
14
|
+
def init_allowed_paths(cls, paths: list[str]):
|
15
|
+
cls._allowed_paths = [os.path.abspath(p) for p in paths]
|
16
|
+
|
17
|
+
@classmethod
|
18
|
+
async def validate_path(cls, path: str) -> str:
|
19
|
+
abs_path = os.path.abspath(path)
|
20
|
+
if not any(abs_path.startswith(p) for p in cls._allowed_paths):
|
21
|
+
raise ValueError(f"Path {path} is outside allowed directories")
|
22
|
+
return abs_path
|
23
|
+
|
24
|
+
@classmethod
|
25
|
+
async def read_file(cls, path: str) -> str:
|
26
|
+
path = await cls.validate_path(path)
|
27
|
+
return Path(path).read_text()
|
28
|
+
|
29
|
+
@classmethod
|
30
|
+
async def write_file(cls, path: str, content: str) -> None:
|
31
|
+
path = await cls.validate_path(path)
|
32
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
33
|
+
Path(path).write_text(content)
|
34
|
+
|
35
|
+
@classmethod
|
36
|
+
async def read_multiple_files(cls, paths: list[str]) -> Dict[str, str]:
|
37
|
+
result = {}
|
38
|
+
for path in paths:
|
39
|
+
try:
|
40
|
+
content = await cls.read_file(path)
|
41
|
+
result[path] = content
|
42
|
+
except Exception as e:
|
43
|
+
result[path] = str(e)
|
44
|
+
return result
|
45
|
+
|
46
|
+
@classmethod
|
47
|
+
async def create_file(cls, path: str, content: str = "") -> str:
|
48
|
+
await cls.write_file(path, content)
|
49
|
+
return f"Created file: {path}"
|
50
|
+
|
51
|
+
@classmethod
|
52
|
+
async def delete_file(cls, path: str) -> str:
|
53
|
+
path = await cls.validate_path(path)
|
54
|
+
path_obj = Path(path)
|
55
|
+
if path_obj.is_file():
|
56
|
+
path_obj.unlink()
|
57
|
+
return f"Deleted file: {path}"
|
58
|
+
elif path_obj.is_dir():
|
59
|
+
shutil.rmtree(path)
|
60
|
+
return f"Deleted directory: {path}"
|
61
|
+
return f"Path not found: {path}"
|
62
|
+
|
63
|
+
@classmethod
|
64
|
+
async def modify_file(cls, path: str, replacements: Dict[str, str]) -> str:
|
65
|
+
path = await cls.validate_path(path)
|
66
|
+
content = await cls.read_file(path)
|
67
|
+
original = content
|
68
|
+
|
69
|
+
for old, new in replacements.items():
|
70
|
+
content = content.replace(old, new)
|
71
|
+
|
72
|
+
await cls.write_file(path, content)
|
73
|
+
return cls.generate_diff(original, content)
|
74
|
+
|
75
|
+
@classmethod
|
76
|
+
async def rewrite_file(cls, path: str, content: str) -> str:
|
77
|
+
path = await cls.validate_path(path)
|
78
|
+
original = await cls.read_file(path) if Path(path).exists() else ""
|
79
|
+
await cls.write_file(path, content)
|
80
|
+
return cls.generate_diff(original, content)
|
81
|
+
|
82
|
+
@staticmethod
|
83
|
+
def generate_diff(original: str, modified: str) -> str:
|
84
|
+
diff = difflib.unified_diff(
|
85
|
+
original.splitlines(keepends=True),
|
86
|
+
modified.splitlines(keepends=True),
|
87
|
+
fromfile='original',
|
88
|
+
tofile='modified'
|
89
|
+
)
|
90
|
+
return ''.join(diff)
|
91
|
+
|
92
|
+
@classmethod
|
93
|
+
async def list_directory(cls, path: str) -> list[str]:
|
94
|
+
path = await cls.validate_path(path)
|
95
|
+
entries = []
|
96
|
+
for item in Path(path).iterdir():
|
97
|
+
entries.append(str(item))
|
98
|
+
return entries
|
99
|
+
|
100
|
+
@classmethod
|
101
|
+
async def create_directory(cls, path: str) -> None:
|
102
|
+
path = await cls.validate_path(path)
|
103
|
+
Path(path).mkdir(parents=True, exist_ok=True)
|
104
|
+
|
105
|
+
@classmethod
|
106
|
+
def _load_gitignore(cls, path: str) -> list[str]:
|
107
|
+
gitignore_path = os.path.join(path, ".gitignore")
|
108
|
+
patterns = []
|
109
|
+
if os.path.exists(gitignore_path):
|
110
|
+
with open(gitignore_path, 'r') as f:
|
111
|
+
for line in f:
|
112
|
+
line = line.strip()
|
113
|
+
if line and not line.startswith('#'):
|
114
|
+
patterns.append(line)
|
115
|
+
return patterns
|
116
|
+
|
117
|
+
@classmethod
|
118
|
+
def _get_tracked_files(cls, repo_path: str) -> Optional[Set[str]]:
|
119
|
+
try:
|
120
|
+
repo = git.Repo(repo_path)
|
121
|
+
return set(repo.git.ls_files().splitlines())
|
122
|
+
except git.exc.InvalidGitRepositoryError:
|
123
|
+
return None
|
124
|
+
|
125
|
+
@classmethod
|
126
|
+
async def directory_tree(cls, path: str) -> Tuple[str, int, int]:
|
127
|
+
path = await cls.validate_path(path)
|
128
|
+
base_path = Path(path)
|
129
|
+
|
130
|
+
# Try git tracking first
|
131
|
+
tracked_files = cls._get_tracked_files(path)
|
132
|
+
gitignore = cls._load_gitignore(path) if tracked_files is None else []
|
133
|
+
|
134
|
+
def gen_tree(path: Path, prefix: str = "") -> Tuple[list[str], int, int]:
|
135
|
+
entries = []
|
136
|
+
dir_count = 0
|
137
|
+
file_count = 0
|
138
|
+
|
139
|
+
items = sorted(path.iterdir(), key=lambda x: (x.is_file(), x.name))
|
140
|
+
for i, item in enumerate(items):
|
141
|
+
rel_path = str(item.relative_to(base_path))
|
142
|
+
|
143
|
+
# Skip if file should be ignored
|
144
|
+
if tracked_files is not None:
|
145
|
+
if rel_path not in tracked_files and not any(str(p.relative_to(base_path)) in tracked_files for p in item.rglob("*") if p.is_file()):
|
146
|
+
continue
|
147
|
+
else:
|
148
|
+
# Use gitignore
|
149
|
+
if cls._should_ignore(rel_path, gitignore):
|
150
|
+
continue
|
151
|
+
|
152
|
+
is_last = i == len(items) - 1
|
153
|
+
curr_prefix = "└── " if is_last else "├── "
|
154
|
+
curr_line = prefix + curr_prefix + item.name
|
155
|
+
|
156
|
+
if item.is_dir():
|
157
|
+
next_prefix = prefix + (" " if is_last else "│ ")
|
158
|
+
subtree, sub_dirs, sub_files = gen_tree(item, next_prefix)
|
159
|
+
if tracked_files is not None and not subtree:
|
160
|
+
continue
|
161
|
+
entries.extend([curr_line] + subtree)
|
162
|
+
dir_count += 1 + sub_dirs
|
163
|
+
file_count += sub_files
|
164
|
+
else:
|
165
|
+
if tracked_files is not None and rel_path not in tracked_files:
|
166
|
+
continue
|
167
|
+
entries.append(curr_line)
|
168
|
+
file_count += 1
|
169
|
+
|
170
|
+
return entries, dir_count, file_count
|
171
|
+
|
172
|
+
tree_lines, total_dirs, total_files = gen_tree(Path(path))
|
173
|
+
return "\n".join(tree_lines), total_dirs, total_files
|
174
|
+
|
175
|
+
@classmethod
|
176
|
+
def _should_ignore(cls, path: str, patterns: list[str]) -> bool:
|
177
|
+
if not patterns:
|
178
|
+
return False
|
179
|
+
|
180
|
+
parts = Path(path).parts
|
181
|
+
for pattern in patterns:
|
182
|
+
pattern = pattern.strip()
|
183
|
+
if not pattern or pattern.startswith('#'):
|
184
|
+
continue
|
185
|
+
|
186
|
+
if pattern.endswith('/'):
|
187
|
+
pattern = pattern.rstrip('/')
|
188
|
+
if pattern in parts:
|
189
|
+
return True
|
190
|
+
else:
|
191
|
+
if fnmatch.fnmatch(parts[-1], pattern): # Match basename
|
192
|
+
return True
|
193
|
+
# Match full path
|
194
|
+
if fnmatch.fnmatch(path, pattern):
|
195
|
+
return True
|
196
|
+
|
197
|
+
return False
|
198
|
+
|
199
|
+
@classmethod
|
200
|
+
async def search_files(cls, path: str, pattern: str, excludes: Optional[list[str]] = None) -> list[str]:
|
201
|
+
path = await cls.validate_path(path)
|
202
|
+
gitignore = cls._load_gitignore(path)
|
203
|
+
if excludes:
|
204
|
+
gitignore.extend(excludes)
|
205
|
+
|
206
|
+
results = []
|
207
|
+
for root, _, files in os.walk(path):
|
208
|
+
rel_root = os.path.relpath(root, path)
|
209
|
+
if rel_root != "." and cls._should_ignore(rel_root, gitignore):
|
210
|
+
continue
|
211
|
+
|
212
|
+
for file in files:
|
213
|
+
rel_path = os.path.join(rel_root, file)
|
214
|
+
if rel_path != "." and cls._should_ignore(rel_path, gitignore):
|
215
|
+
continue
|
216
|
+
|
217
|
+
if pattern in file or fnmatch.fnmatch(file, pattern):
|
218
|
+
results.append(os.path.join(root, file))
|
219
|
+
|
220
|
+
return results
|
@@ -0,0 +1,25 @@
|
|
1
|
+
import git
|
2
|
+
|
3
|
+
def git_status(repo: git.Repo) -> str:
|
4
|
+
return repo.git.status()
|
5
|
+
|
6
|
+
def git_diff_unstaged(repo: git.Repo) -> str:
|
7
|
+
return repo.git.diff()
|
8
|
+
|
9
|
+
def git_diff_staged(repo: git.Repo) -> str:
|
10
|
+
return repo.git.diff("--cached")
|
11
|
+
|
12
|
+
def git_diff(repo: git.Repo, target: str) -> str:
|
13
|
+
return repo.git.diff(target)
|
14
|
+
|
15
|
+
def git_log(repo: git.Repo, max_count: int = 10) -> str:
|
16
|
+
commits = list(repo.iter_commits(max_count=max_count))
|
17
|
+
log = []
|
18
|
+
for commit in commits:
|
19
|
+
log.append(f"Commit: {commit.hexsha}\nAuthor: {commit.author}\nDate: {commit.authored_datetime}\nMessage: {commit.message}\n")
|
20
|
+
return "\n".join(log)
|
21
|
+
|
22
|
+
def git_show(repo: git.Repo, revision: str) -> str:
|
23
|
+
commit = repo.commit(revision)
|
24
|
+
output = [f"Commit: {commit.hexsha}\nAuthor: {commit.author}\nDate: {commit.authored_datetime}\nMessage: {commit.message}\n"]
|
25
|
+
return "".join(output)
|
@@ -0,0 +1,7 @@
|
|
1
|
+
"""Function invocation helper."""
|
2
|
+
from typing import Dict, Any
|
3
|
+
|
4
|
+
def invoke_git(function: str, params: Dict[str, Any]) -> str:
|
5
|
+
"""Invokes a git function with given parameters."""
|
6
|
+
# Functions will be invoked via the assistant
|
7
|
+
return f"git_{function}({', '.join(f'{k}={v!r}' for k,v in params.items())})"
|
@@ -0,0 +1,48 @@
|
|
1
|
+
from typing import Union, Optional
|
2
|
+
from pydantic import BaseModel
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
class FileCreate(BaseModel):
|
6
|
+
path: Union[str, Path]
|
7
|
+
content: str = ""
|
8
|
+
|
9
|
+
class FileDelete(BaseModel):
|
10
|
+
path: Union[str, Path]
|
11
|
+
|
12
|
+
class FileModify(BaseModel):
|
13
|
+
path: Union[str, Path]
|
14
|
+
replacements: dict[str, str]
|
15
|
+
|
16
|
+
class FileRewrite(BaseModel):
|
17
|
+
path: Union[str, Path]
|
18
|
+
content: str
|
19
|
+
|
20
|
+
class GitBase(BaseModel):
|
21
|
+
repo_path: str
|
22
|
+
|
23
|
+
class GitAdd(GitBase):
|
24
|
+
files: list[str]
|
25
|
+
|
26
|
+
class GitCommit(GitBase):
|
27
|
+
message: str
|
28
|
+
|
29
|
+
class GitDiff(GitBase):
|
30
|
+
target: str
|
31
|
+
|
32
|
+
class GitCreateBranch(GitBase):
|
33
|
+
branch_name: str
|
34
|
+
base_branch: Optional[str] = None
|
35
|
+
|
36
|
+
class GitCheckout(GitBase):
|
37
|
+
branch_name: str
|
38
|
+
|
39
|
+
class GitShow(GitBase):
|
40
|
+
revision: str
|
41
|
+
|
42
|
+
class GitLog(GitBase):
|
43
|
+
max_count: int = 10
|
44
|
+
|
45
|
+
class RepositoryOperation(BaseModel):
|
46
|
+
path: str
|
47
|
+
content: Optional[str] = None
|
48
|
+
replacements: Optional[dict[str, str]] = None
|
@@ -0,0 +1,76 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from git import Repo
|
3
|
+
import aiofiles
|
4
|
+
from typing import Optional
|
5
|
+
|
6
|
+
class RepositoryTools:
|
7
|
+
def __init__(self, repo_path: str):
|
8
|
+
self.path = Path(repo_path).resolve()
|
9
|
+
if not self.path.exists():
|
10
|
+
raise ValueError(f"Invalid repository path: {repo_path}")
|
11
|
+
self.repo = Repo(self.path)
|
12
|
+
|
13
|
+
async def read_file(self, path: str) -> str:
|
14
|
+
file_path = self.path / path
|
15
|
+
async with aiofiles.open(file_path, 'r') as f:
|
16
|
+
return await f.read()
|
17
|
+
|
18
|
+
async def read_multiple(self, paths: list[str]) -> dict[str, str]:
|
19
|
+
contents = {}
|
20
|
+
for path in paths:
|
21
|
+
contents[path] = await self.read_file(path)
|
22
|
+
return contents
|
23
|
+
|
24
|
+
async def create_file(self, path: str, content: str = '') -> None:
|
25
|
+
file_path = self.path / path
|
26
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
27
|
+
async with aiofiles.open(file_path, 'w') as f:
|
28
|
+
await f.write(content)
|
29
|
+
|
30
|
+
async def modify_file(self, path: str, replacements: dict[str, str]) -> None:
|
31
|
+
file_path = self.path / path
|
32
|
+
content = await self.read_file(path)
|
33
|
+
for old, new in replacements.items():
|
34
|
+
content = content.replace(old, new)
|
35
|
+
await self.rewrite_file(path, content)
|
36
|
+
|
37
|
+
async def rewrite_file(self, path: str, content: str) -> None:
|
38
|
+
file_path = self.path / path
|
39
|
+
async with aiofiles.open(file_path, 'w') as f:
|
40
|
+
await f.write(content)
|
41
|
+
|
42
|
+
async def delete_file(self, path: str) -> None:
|
43
|
+
file_path = self.path / path
|
44
|
+
file_path.unlink()
|
45
|
+
|
46
|
+
def status(self) -> str:
|
47
|
+
return self.repo.git.status()
|
48
|
+
|
49
|
+
def add(self, files: list[str]) -> str:
|
50
|
+
return self.repo.git.add(files)
|
51
|
+
|
52
|
+
def commit(self, message: str) -> str:
|
53
|
+
return self.repo.git.commit('-m', message)
|
54
|
+
|
55
|
+
def diff_unstaged(self) -> str:
|
56
|
+
return self.repo.git.diff()
|
57
|
+
|
58
|
+
def diff_staged(self) -> str:
|
59
|
+
return self.repo.git.diff('--cached')
|
60
|
+
|
61
|
+
def diff(self, target: str) -> str:
|
62
|
+
return self.repo.git.diff(target)
|
63
|
+
|
64
|
+
def checkout(self, branch_name: str) -> str:
|
65
|
+
return self.repo.git.checkout(branch_name)
|
66
|
+
|
67
|
+
def create_branch(self, branch_name: str, base_branch: Optional[str] = None) -> str:
|
68
|
+
if base_branch:
|
69
|
+
return self.repo.git.branch(branch_name, base_branch)
|
70
|
+
return self.repo.git.branch(branch_name)
|
71
|
+
|
72
|
+
def log(self, max_count: int = 10) -> str:
|
73
|
+
return self.repo.git.log(f'-n {max_count}')
|
74
|
+
|
75
|
+
def show(self, revision: str) -> str:
|
76
|
+
return self.repo.git.show(revision)
|
@@ -0,0 +1,48 @@
|
|
1
|
+
import xml.etree.ElementTree as ET
|
2
|
+
from pathlib import Path
|
3
|
+
import xmlschema
|
4
|
+
import re
|
5
|
+
|
6
|
+
class XMLProcessor:
|
7
|
+
def __init__(self):
|
8
|
+
schema_path = Path(__file__).parent / "schema.xsd"
|
9
|
+
self.validator = xmlschema.XMLSchema(schema_path)
|
10
|
+
|
11
|
+
def _normalize_text(self, text: str) -> str:
|
12
|
+
"""Normalize whitespace in text content"""
|
13
|
+
return re.sub(r'\s+', ' ', text).strip()
|
14
|
+
|
15
|
+
def parse(self, xml_str: str) -> dict[str, str | dict[str, str]]:
|
16
|
+
root = ET.fromstring(self._normalize_text(xml_str))
|
17
|
+
self.validator.validate(root)
|
18
|
+
|
19
|
+
result = {
|
20
|
+
"function": self._normalize_text(root.find("function").text),
|
21
|
+
"path": self._normalize_text(root.find("path").text),
|
22
|
+
}
|
23
|
+
|
24
|
+
if (content_elem := root.find("content")) is not None:
|
25
|
+
result["content"] = self._normalize_text(content_elem.text)
|
26
|
+
|
27
|
+
if (replacements_elem := root.find("replacements")) is not None:
|
28
|
+
result["replacements"] = {
|
29
|
+
elem.tag: self._normalize_text(elem.text)
|
30
|
+
for elem in replacements_elem
|
31
|
+
}
|
32
|
+
|
33
|
+
return result
|
34
|
+
|
35
|
+
def generate(self, data: dict[str, str | dict[str, str]]) -> str:
|
36
|
+
root = ET.Element("instruction")
|
37
|
+
ET.SubElement(root, "function").text = data["function"]
|
38
|
+
ET.SubElement(root, "path").text = data["path"]
|
39
|
+
|
40
|
+
if "content" in data:
|
41
|
+
ET.SubElement(root, "content").text = data["content"]
|
42
|
+
|
43
|
+
if "replacements" in data:
|
44
|
+
replacements = ET.SubElement(root, "replacements")
|
45
|
+
for key, value in data["replacements"].items():
|
46
|
+
ET.SubElement(replacements, key).text = value
|
47
|
+
|
48
|
+
return f'<?xml version="1.0"?>\n{ET.tostring(root, encoding="unicode")}'
|
@@ -0,0 +1,11 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: mcp-server-code-assist
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: MCP Code Assist Server
|
5
|
+
Requires-Python: >=3.11
|
6
|
+
Requires-Dist: aiofiles>=24.0.0
|
7
|
+
Requires-Dist: gitpython>=3.1.40
|
8
|
+
Requires-Dist: pydantic>=2.0.0
|
9
|
+
Provides-Extra: test
|
10
|
+
Requires-Dist: pytest-asyncio>=0.25.0; extra == 'test'
|
11
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
@@ -0,0 +1,14 @@
|
|
1
|
+
mcp_server_code_assist/__init__.py,sha256=Aai1h9YUGDDWQn9rsHq5gCZXO7QCQs2pJtyGC9mYV0o,661
|
2
|
+
mcp_server_code_assist/__main__.py,sha256=lbLwix6a0gDF7YKoqbXF88oTdmr8KQGKWchLKEIn5SU,47
|
3
|
+
mcp_server_code_assist/schema.xsd,sha256=vtfQQDzCHvbILT78zF_53N-68GrJRbELg1Xgq3Uu9DE,1235
|
4
|
+
mcp_server_code_assist/server.py,sha256=qtz9pD3_JxIJGare5Sal25E8DafZY-DX4thrghulT1s,10012
|
5
|
+
mcp_server_code_assist/xml_parser.py,sha256=FMmcAk7PbnTNidBGUynq6ZbUs1O9OHHI298REAQEMqw,1827
|
6
|
+
mcp_server_code_assist/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
7
|
+
mcp_server_code_assist/tools/file_tools.py,sha256=iGdVGP9phD5wMZ5HxbRv3gbUpNx-QF27xDLrUI9wUkU,7978
|
8
|
+
mcp_server_code_assist/tools/git_functions.py,sha256=iGIhi8i7BxIrAL8JytWjfcZP7xgla0uVweazSKUNtB4,882
|
9
|
+
mcp_server_code_assist/tools/invoke.py,sha256=lYIu5bJYBaKDDVYz6HRqodpLBMnfVpcvgAref10LO2s,313
|
10
|
+
mcp_server_code_assist/tools/models.py,sha256=3ZUfzodSVbwSoIKG5oKiLbnjVeptT5QKLaQSwl7McJY,937
|
11
|
+
mcp_server_code_assist/tools/repository_tools.py,sha256=5OG4ecmqXVEeuX9U5jdegbQ2So4lxKkIjG3M3e5rgFI,2700
|
12
|
+
mcp_server_code_assist-0.1.0.dist-info/METADATA,sha256=JuQfHEwqOIt1x3KF7vY1hsGpmpoXWnGrTndqb-5L4a0,340
|
13
|
+
mcp_server_code_assist-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
14
|
+
mcp_server_code_assist-0.1.0.dist-info/RECORD,,
|