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.
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from devtoolkit-mcp!")
@@ -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
+ }