devtoolkit-mcp 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.
- devtoolkit_mcp/__init__.py +2 -0
- devtoolkit_mcp/server.py +23 -0
- devtoolkit_mcp/tools/__init__.py +0 -0
- devtoolkit_mcp/tools/analyze_error.py +108 -0
- devtoolkit_mcp/tools/check_dependencies.py +366 -0
- devtoolkit_mcp/tools/explain_code.py +233 -0
- devtoolkit_mcp/tools/repository.py +134 -0
- devtoolkit_mcp/tools/run_tests.py +332 -0
- devtoolkit_mcp-0.1.0.dist-info/METADATA +371 -0
- devtoolkit_mcp-0.1.0.dist-info/RECORD +12 -0
- devtoolkit_mcp-0.1.0.dist-info/WHEEL +4 -0
- devtoolkit_mcp-0.1.0.dist-info/entry_points.txt +3 -0
devtoolkit_mcp/server.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from fastmcp import FastMCP
|
|
2
|
+
|
|
3
|
+
from devtoolkit_mcp.tools.repository import analyze_repository
|
|
4
|
+
from devtoolkit_mcp.tools.explain_code import explain_code
|
|
5
|
+
from devtoolkit_mcp.tools.analyze_error import analyze_error
|
|
6
|
+
from devtoolkit_mcp.tools.check_dependencies import check_dependencies
|
|
7
|
+
from devtoolkit_mcp.tools.run_tests import run_tests
|
|
8
|
+
|
|
9
|
+
mcp = FastMCP("DevToolKit MCP")
|
|
10
|
+
|
|
11
|
+
mcp.tool(analyze_repository)
|
|
12
|
+
mcp.tool(explain_code)
|
|
13
|
+
mcp.tool(analyze_error)
|
|
14
|
+
mcp.tool(check_dependencies)
|
|
15
|
+
mcp.tool(run_tests)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
"""Main entry point for the DevToolKit MCP server"""
|
|
20
|
+
mcp.run()
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import builtins
|
|
3
|
+
|
|
4
|
+
MAX_TRACEBACK_SIZE_BYTES = 100_000
|
|
5
|
+
|
|
6
|
+
def _validate_error_input(error_text: str) -> str:
|
|
7
|
+
"""validate and normalize the supplied python error text"""
|
|
8
|
+
|
|
9
|
+
if not isinstance(error_text, str):
|
|
10
|
+
raise ValueError("Error input must be a string.")
|
|
11
|
+
|
|
12
|
+
if not error_text.strip():
|
|
13
|
+
raise ValueError("Error input cannot be empty.")
|
|
14
|
+
|
|
15
|
+
if len(error_text.encode("utf-8")) > MAX_TRACEBACK_SIZE_BYTES:
|
|
16
|
+
raise ValueError("Error input exceeds the 100 KB size limit.")
|
|
17
|
+
|
|
18
|
+
return error_text.strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _extract_exception(error_text: str) -> dict | None:
|
|
23
|
+
"""Extract the exception type and message."""
|
|
24
|
+
|
|
25
|
+
lines = error_text.splitlines()
|
|
26
|
+
|
|
27
|
+
for line in reversed(lines):
|
|
28
|
+
line = line.strip()
|
|
29
|
+
|
|
30
|
+
if not line:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
match = re.match(
|
|
34
|
+
r"^([A-Za-z_][A-Za-z0-9_.]*)(?::\s*(.*))?$",
|
|
35
|
+
line,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if not match:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
exception_type = match.group(1)
|
|
42
|
+
message = match.group(2)
|
|
43
|
+
|
|
44
|
+
# A colon indicates an exception name followed by a message.
|
|
45
|
+
if message is None:
|
|
46
|
+
# For exceptions without messages, accept known built-in
|
|
47
|
+
# exception classes only.
|
|
48
|
+
builtin_exception = getattr(
|
|
49
|
+
builtins,
|
|
50
|
+
exception_type,
|
|
51
|
+
None,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not (
|
|
55
|
+
isinstance(builtin_exception, type)
|
|
56
|
+
and issubclass(builtin_exception, BaseException)
|
|
57
|
+
):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
message = ""
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"type": exception_type,
|
|
64
|
+
"message": message,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _extract_traceback_frames(error_text: str) -> list[dict]:
|
|
71
|
+
"""Extract file, line number, and function from traceback frames."""
|
|
72
|
+
|
|
73
|
+
frame_pattern = re.compile(
|
|
74
|
+
r'^\s*File "(.+)", line (\d+), in (.+)$'
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
frames = []
|
|
78
|
+
|
|
79
|
+
for line in error_text.splitlines():
|
|
80
|
+
match = frame_pattern.match(line)
|
|
81
|
+
|
|
82
|
+
if match:
|
|
83
|
+
frames.append({
|
|
84
|
+
"filename": match.group(1),
|
|
85
|
+
"line_number": int(match.group(2)),
|
|
86
|
+
"function": match.group(3),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return frames
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def analyze_error(error_text: str) -> dict:
|
|
93
|
+
"""Analyze Python errors and extract structured debugging information."""
|
|
94
|
+
|
|
95
|
+
validated_text = _validate_error_input(error_text)
|
|
96
|
+
|
|
97
|
+
exception = _extract_exception(validated_text)
|
|
98
|
+
frames = _extract_traceback_frames(validated_text)
|
|
99
|
+
|
|
100
|
+
error_location = frames[-1] if frames else None
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"exception": exception,
|
|
104
|
+
"frames": frames,
|
|
105
|
+
"error_location": error_location,
|
|
106
|
+
"has_traceback": bool(frames)
|
|
107
|
+
}
|
|
108
|
+
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import tomllib
|
|
3
|
+
from packaging.requirements import Requirement
|
|
4
|
+
from packaging.version import InvalidVersion, Version
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
MAX_MANIFEST_SIZE_BYTES = 1_000_000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _validate_project_path(project_path: str) -> Path:
|
|
12
|
+
"""Validate the supplied project directory."""
|
|
13
|
+
|
|
14
|
+
if not isinstance(project_path, str):
|
|
15
|
+
raise ValueError("Project path must be a string.")
|
|
16
|
+
|
|
17
|
+
if not project_path.strip():
|
|
18
|
+
raise ValueError("Project path cannot be empty.")
|
|
19
|
+
|
|
20
|
+
root = Path(project_path).expanduser()
|
|
21
|
+
|
|
22
|
+
if not root.exists():
|
|
23
|
+
raise ValueError("Project directory does not exist.")
|
|
24
|
+
|
|
25
|
+
if root.is_symlink():
|
|
26
|
+
raise ValueError("Symbolic links are not supported.")
|
|
27
|
+
|
|
28
|
+
if not root.is_dir():
|
|
29
|
+
raise ValueError("Path must point to a directory.")
|
|
30
|
+
|
|
31
|
+
return root.resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SUPPORTED_MANIFESTS = (
|
|
35
|
+
"requirements.txt",
|
|
36
|
+
"pyproject.toml",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _discover_manifests(project_root: Path) -> list[Path]:
|
|
41
|
+
"""Find supported dependency manifest files in the project root."""
|
|
42
|
+
|
|
43
|
+
if not project_root.is_dir():
|
|
44
|
+
raise ValueError("Project root must be a directory.")
|
|
45
|
+
|
|
46
|
+
manifests = []
|
|
47
|
+
|
|
48
|
+
for filename in SUPPORTED_MANIFESTS:
|
|
49
|
+
manifest_path = project_root / filename
|
|
50
|
+
|
|
51
|
+
if manifest_path.is_symlink():
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Symbolic links are not supported: {filename}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if manifest_path.exists():
|
|
57
|
+
if not manifest_path.is_file():
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Manifest must be a file: {filename}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
manifests.append(manifest_path)
|
|
63
|
+
|
|
64
|
+
if not manifests:
|
|
65
|
+
raise ValueError("No supported dependency manifest found.")
|
|
66
|
+
|
|
67
|
+
return manifests
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Parse the requirements.txt file
|
|
71
|
+
def _parse_requirements_txt(manifest_path: Path) -> list[dict]:
|
|
72
|
+
"""Prase dependencies from a requirements.txt file"""
|
|
73
|
+
|
|
74
|
+
dependencies = []
|
|
75
|
+
|
|
76
|
+
content = manifest_path.read_text(encoding='utf-8')
|
|
77
|
+
|
|
78
|
+
for line_number, line in enumerate(content.splitlines(), start=1):
|
|
79
|
+
line = line.strip()
|
|
80
|
+
|
|
81
|
+
if not line or line.startswith("#"):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
## also skip pip options
|
|
85
|
+
if line.startswith("-"):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
requirement = Requirement(line)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Invalid dependency on line {line_number} : {line}"
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
dependencies.append({
|
|
96
|
+
"name": requirement.name,
|
|
97
|
+
"specifier": str(requirement.specifier)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return dependencies
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
## Parse the pyproject.toml file
|
|
104
|
+
def _parse_pyproject_toml(manifest_path: Path) -> list[dict]:
|
|
105
|
+
"""Prase dependencies from a pep 621 pyproject.toml file"""
|
|
106
|
+
|
|
107
|
+
with manifest_path.open("rb") as file:
|
|
108
|
+
data = tomllib.load(file)
|
|
109
|
+
|
|
110
|
+
project = data.get("project", {})
|
|
111
|
+
dependency_lines = list(project.get("dependencies",[]))
|
|
112
|
+
|
|
113
|
+
optional_dependencies = project.get("optional-dependencies", {})
|
|
114
|
+
|
|
115
|
+
for optional_group in optional_dependencies.values():
|
|
116
|
+
dependency_lines.extend(optional_group)
|
|
117
|
+
|
|
118
|
+
dependencies = []
|
|
119
|
+
|
|
120
|
+
for line in dependency_lines:
|
|
121
|
+
try:
|
|
122
|
+
requirement =Requirement(line)
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"Invalid dependency declaration: {line}"
|
|
126
|
+
) from exc
|
|
127
|
+
|
|
128
|
+
dependencies.append({
|
|
129
|
+
"name": requirement.name,
|
|
130
|
+
"specifier": str(requirement.specifier),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return dependencies
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _get_latest_version(package_name: str) -> dict:
|
|
137
|
+
"""Fetch the latest published package version from PyPI"""
|
|
138
|
+
|
|
139
|
+
url = f"https://pypi.org/pypi/{package_name}/json"
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
response = httpx.get(url, timeout=10)
|
|
143
|
+
except httpx.TimeoutException:
|
|
144
|
+
return {
|
|
145
|
+
"latest_version": None,
|
|
146
|
+
"status": "timeout",
|
|
147
|
+
}
|
|
148
|
+
except httpx.RequestError:
|
|
149
|
+
return{
|
|
150
|
+
"latest_version": None,
|
|
151
|
+
"status": "network_error",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if response.status_code == 404:
|
|
155
|
+
return{
|
|
156
|
+
"latest_version": None,
|
|
157
|
+
"status": "package_not_found",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
response.raise_for_status()
|
|
162
|
+
data = response.json()
|
|
163
|
+
latest_version = data["info"]["version"]
|
|
164
|
+
|
|
165
|
+
if not isinstance(latest_version, str) or not latest_version:
|
|
166
|
+
raise ValueError("Invalid version returned by PyPI")
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"latest_version": latest_version,
|
|
170
|
+
"status":"success",
|
|
171
|
+
}
|
|
172
|
+
except (httpx.HTTPStatusError, ValueError, KeyError, TypeError):
|
|
173
|
+
return{
|
|
174
|
+
"latest_version": None,
|
|
175
|
+
"status": 'api_error',
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _compare_versions(
|
|
180
|
+
package_name: str,
|
|
181
|
+
specifier: str,
|
|
182
|
+
latest_version: str | None,
|
|
183
|
+
) -> dict:
|
|
184
|
+
"""Compare a declared exact version with the latest PyPI version."""
|
|
185
|
+
|
|
186
|
+
declared_version = None
|
|
187
|
+
|
|
188
|
+
# Only compare exact pinned versions for now.
|
|
189
|
+
if specifier.startswith("==") and not specifier.startswith("==="):
|
|
190
|
+
declared_version = specifier[2:]
|
|
191
|
+
|
|
192
|
+
if latest_version is None:
|
|
193
|
+
return {
|
|
194
|
+
"name": package_name,
|
|
195
|
+
"declared_version": declared_version,
|
|
196
|
+
"latest_version": None,
|
|
197
|
+
"update_available": None,
|
|
198
|
+
"status": "version_unavailable",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if declared_version is None:
|
|
202
|
+
return {
|
|
203
|
+
"name": package_name,
|
|
204
|
+
"declared_version": None,
|
|
205
|
+
"latest_version": latest_version,
|
|
206
|
+
"update_available": None,
|
|
207
|
+
"status": "unpinned_version",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
current = Version(declared_version)
|
|
212
|
+
latest = Version(latest_version)
|
|
213
|
+
|
|
214
|
+
except InvalidVersion:
|
|
215
|
+
return {
|
|
216
|
+
"name": package_name,
|
|
217
|
+
"declared_version": declared_version,
|
|
218
|
+
"latest_version": latest_version,
|
|
219
|
+
"update_available": None,
|
|
220
|
+
"status": "invalid_version",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
"name": package_name,
|
|
225
|
+
"declared_version": declared_version,
|
|
226
|
+
"latest_version": latest_version,
|
|
227
|
+
"update_available": latest > current,
|
|
228
|
+
"status": "success",
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _check_vulnerabilities(
|
|
233
|
+
package_name: str,
|
|
234
|
+
version: str | None,
|
|
235
|
+
) -> dict:
|
|
236
|
+
"""Check a python package version against the OSV database"""
|
|
237
|
+
|
|
238
|
+
if not version:
|
|
239
|
+
return {
|
|
240
|
+
"vulnerabilities": [],
|
|
241
|
+
"status": "version_unavailable",
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
url = "https://api.osv.dev/v1/query"
|
|
245
|
+
|
|
246
|
+
payload = {
|
|
247
|
+
"package": {
|
|
248
|
+
"name": package_name,
|
|
249
|
+
"ecosystem": "PyPI",
|
|
250
|
+
},
|
|
251
|
+
"version": version,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
response = httpx.post(
|
|
256
|
+
url,
|
|
257
|
+
json=payload,
|
|
258
|
+
timeout=10,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
response.raise_for_status()
|
|
262
|
+
data = response.json()
|
|
263
|
+
|
|
264
|
+
except httpx.TimeoutException:
|
|
265
|
+
return {
|
|
266
|
+
"vulnerabilities": [],
|
|
267
|
+
"status":"timeout",
|
|
268
|
+
}
|
|
269
|
+
except httpx.RequestError:
|
|
270
|
+
return{
|
|
271
|
+
"vulnerabilities": [],
|
|
272
|
+
"status": "network_error"
|
|
273
|
+
}
|
|
274
|
+
except (httpx.HTTPStatusError, ValueError):
|
|
275
|
+
return {
|
|
276
|
+
"vulnerabilities": [],
|
|
277
|
+
"status": "api_error",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
vulnerabilities = []
|
|
281
|
+
|
|
282
|
+
for vulnerability in data.get("vulns", []):
|
|
283
|
+
vulnerabilities.append({
|
|
284
|
+
"id": vulnerability.get("id"),
|
|
285
|
+
"summary": vulnerability.get("summary"),
|
|
286
|
+
"aliases": vulnerability.get("aliases",[]),
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
"vulnerabilities": vulnerabilities,
|
|
291
|
+
"status": "success"
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def check_dependencies(project_path: str) -> dict:
|
|
296
|
+
"""Analyze project dependencies, latest versions, and known vulnerabilities"""
|
|
297
|
+
|
|
298
|
+
project_root = _validate_project_path(project_path=project_path)
|
|
299
|
+
manifest_paths = _discover_manifests(project_root)
|
|
300
|
+
|
|
301
|
+
dependencies = []
|
|
302
|
+
|
|
303
|
+
for manifest_path in manifest_paths:
|
|
304
|
+
if manifest_path.stat().st_size > MAX_MANIFEST_SIZE_BYTES:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"Manifest exceeds the 1 MB size limit : {manifest_path.name}"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
if manifest_path.name == "requirements.txt":
|
|
311
|
+
parsed = _parse_requirements_txt(manifest_path)
|
|
312
|
+
else:
|
|
313
|
+
parsed = _parse_pyproject_toml(manifest_path)
|
|
314
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
|
|
315
|
+
raise ValueError(
|
|
316
|
+
f"Could not read manifest {manifest_path.name} : {exc}"
|
|
317
|
+
) from exc
|
|
318
|
+
|
|
319
|
+
for dependency in parsed:
|
|
320
|
+
dependencies.append({
|
|
321
|
+
**dependency,
|
|
322
|
+
"source": manifest_path.name
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
## avoid checking the same package and version twice
|
|
326
|
+
unique_dependencies = {}
|
|
327
|
+
for dependency in dependencies:
|
|
328
|
+
key = (
|
|
329
|
+
dependency["name"].lower(),
|
|
330
|
+
dependency["specifier"],
|
|
331
|
+
)
|
|
332
|
+
unique_dependencies.setdefault(key, dependency)
|
|
333
|
+
|
|
334
|
+
results = []
|
|
335
|
+
|
|
336
|
+
for dependency in unique_dependencies.values():
|
|
337
|
+
name = dependency["name"]
|
|
338
|
+
specifier = dependency["specifier"]
|
|
339
|
+
|
|
340
|
+
pypi_result = _get_latest_version(name)
|
|
341
|
+
|
|
342
|
+
version_result = _compare_versions(
|
|
343
|
+
name,
|
|
344
|
+
specifier,
|
|
345
|
+
pypi_result["latest_version"]
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
vulnerability_result = _check_vulnerabilities(
|
|
349
|
+
name,
|
|
350
|
+
version_result["declared_version"],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
results.append({
|
|
354
|
+
**version_result,
|
|
355
|
+
"source": dependency["source"],
|
|
356
|
+
"pypi_status": pypi_result["status"],
|
|
357
|
+
"vulnerability_status": vulnerability_result["status"],
|
|
358
|
+
"vulnerabilities": vulnerability_result["vulnerabilities"],
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
"project_path": str(project_path),
|
|
363
|
+
"manifests": [path.name for path in manifest_paths],
|
|
364
|
+
"total_dependencies": len(results),
|
|
365
|
+
"dependencies": results,
|
|
366
|
+
}
|