mcp-server-code-assist 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,,
|